From b50df1afc869d2f2acb0e302109d53431a6731ba Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Fri, 2 Oct 2026 18:21:36 -0700 Subject: [PATCH 1/6] feat!: fill commitInfo fields from CI variables and warn when git fails commitInfo() takes each field from COMMIT_INFO_*, then git, then the CI provider's variables, so the Currents CLI and reporter no longer combine them themselves. The CI values are the ones the Playwright reporter read. Branch values from COMMIT_INFO_BRANCH and CI are not changed; git's HEAD is still no branch. When git fails and fields stay empty, commitInfo() prints one warning per process with the git error and the COMMIT_INFO_* variables to set. It says so plainly when git is not in PATH. Outside a repository and off CI there is no warning. Adds TypeScript types. ramda is no longer used. BREAKING CHANGE: commitInfo() returns CI provider values where 1.x returned null, and can print a warning to stderr. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KVkH9Ei4iCN2WvGMAUT1d --- README.md | 29 ++++--- package-lock.json | 9 ++- package.json | 5 +- src/commit-info-repos-spec.js | 84 ++++++++++++++++++-- src/git-api.js | 1 + src/index.d.ts | 56 ++++++++++++++ src/index.js | 140 +++++++++++++++++++++++++++++----- src/utils-spec.js | 8 -- src/utils.js | 30 ++++---- 9 files changed, 300 insertions(+), 62 deletions(-) create mode 100644 src/index.d.ts diff --git a/README.md b/README.md index 81b568b..327b978 100644 --- a/README.md +++ b/README.md @@ -26,21 +26,28 @@ commitInfo(folder) // sha // timestamp (in seconds since epoch) // remote (without credentials) + // ghaEventData (GitHub Actions pull request events only) }) ``` +Each property comes from the first of these that has a value, or is `null`: + +1. the `COMMIT_INFO_*` environment variable, see [Fallback environment variables](#fallback-environment-variables) +2. git, see [src/git-api.js](src/git-api.js) +3. the CI provider's environment variables, see [CI provider variables](#ci-provider-variables) + Notes: -- Code assumes there is `.git` folder and uses Git commands to get each property, like `git show -s --pretty=%B`, see [src/git-api.js](src/git-api.js). Note: there is fallback to environment variables. -- Resolves with [Bluebird](https://github.com/petkaantonov/bluebird) promise. -- Only uses Git commands, see [src/git-api.js](src/git-api.js) -- If a command fails, returns `null` for each property -- `remote` never contains a user name or password, also when it comes from `COMMIT_INFO_REMOTE`. +- git reports no branch for a detached checkout (`HEAD`), so the branch comes from the CI provider. Branch values from `COMMIT_INFO_BRANCH` and the CI provider are reported as they are. +- `remote` never contains a user name or password, wherever it came from. GitLab's `CI_REPOSITORY_URL`, for example, holds a job token. +- Resolves with a [Bluebird](https://github.com/petkaantonov/bluebird) promise. - If you need to debug, run with `DEBUG=commit-info` environment variable. The debug output does not contain the remote's credentials. +- When git fails and fields are still empty after the CI provider's variables, `commitInfo` prints one warning with the git error and the `COMMIT_INFO_*` variables to set. No warning when there is no repository and the CI provider's variables fill the fields, or when there is no repository and no CI provider. +- When git is not installed or not in `PATH`, all the git values are empty and the warning says that git was not found in `PATH`. Install git or set the `COMMIT_INFO_*` variables. ## CI provider variables -`getCiCommitInfo()` reads the commit from the variables of the CI provider the process runs on. The branch comes from: +When git does not return a value, it comes from the variables of the CI provider the process runs on. The branch comes from: | Provider | Branch | | --- | --- | @@ -52,13 +59,13 @@ Notes: | Bitbucket Pipelines | `BITBUCKET_BRANCH` | | Buildkite | `BUILDKITE_BRANCH` | -AWS CodeBuild gives no branch. The other properties and providers are in [src/ci.js](src/ci.js). +AWS CodeBuild gives no branch. The other properties and providers are in [src/ci.js](src/ci.js). `getCiCommitInfo()` returns these values and the provider name. ## Containers git 2.35.2 and later refuse to read a repository owned by another user, with `fatal: unsafe repository` (2.35.2 to 2.37.x) or `fatal: detected dubious ownership in repository` (2.38.0 and later). This is common when a container runs as root on a checkout made by another user. On CI, the read-only git commands then run again with `-c safe.directory=*`. CI means that the `CI` variable is set to a value other than `false` or `0`, or that one of the CI providers in [src/ci-provider.js](src/ci-provider.js) is detected; Jenkins, for example, does not set `CI`. `GOOGLE_CLOUD_PROJECT`, `GCP_PROJECT`, `GCLOUD_PROJECT` and `JENKINS_HOME` do not count, because developers often have them set in their shell. `*` because the folder can be a subfolder of the repository. -git 2.35.2 to 2.37.x ignore `safe.directory` on the command line, so there the git values are `null` (git 2.38.0 and later respect it); run `git config --global --add safe.directory '*'` in the container or set the `COMMIT_INFO_*` variables. +git 2.35.2 to 2.37.x ignore `safe.directory` on the command line, so there `commitInfo` prints the warning (git 2.38.0 and later respect it); run `git config --global --add safe.directory '*'` in the container or set the `COMMIT_INFO_*` variables. ## Pull request builds @@ -80,7 +87,7 @@ The `COMMIT_INFO_*` variables below still take priority. When `COMMIT_INFO_SHA` ## Fallback environment variables -If getting the commit information using `git` fails for some reason, you can provide the commit information by setting the environment variables. This module will look at the following environment variables as a fallback +You can provide the commit information by setting these environment variables. They take priority over git and the CI provider's variables. ``` branch: COMMIT_INFO_BRANCH @@ -108,11 +115,11 @@ See [docker-example](docker-example) for a full example. ## Individual methods In addition to `commitInfo` this module also exposes individual promise-returning -methods `getBranch`, `getMessage`, `getEmail`, `getAuthor`, `getSha`, `getTimestamp`, `getRemoteOrigin`. These methods do NOT use fallback environment variables. `getRemoteOrigin` returns the remote without credentials. +methods `getBranch`, `getMessage`, `getEmail`, `getAuthor`, `getSha`, `getTimestamp`, `getRemoteOrigin`. These methods use git only, not the environment variables. `getRemoteOrigin` returns the remote without credentials. Other exports: -- `getCiCommitInfo(env = process.env)`: the CI provider's values and the provider name, see [CI provider variables](#ci-provider-variables). The `remote` has no credentials. +- `getCiCommitInfo(env = process.env)`: the CI provider's values, see [CI provider variables](#ci-provider-variables). The `remote` has no credentials. - `detectCiProvider(env = process.env)`: the CI provider name, such as `githubActions`, or `null`. - `removeCredentials(url)`: removes the user name and password from a URL, keeping the port. Returns other values, such as `git@github.com:o/r.git`, as they are. diff --git a/package-lock.json b/package-lock.json index 2e71245..82c08f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,7 @@ "check-more-types": "2.24.0", "debug": "4.3.4", "execa": "1.0.0", - "lazy-ass": "1.6.0", - "ramda": "0.26.1" + "lazy-ass": "1.6.0" }, "devDependencies": { "ban-sensitive-files": "1.9.2", @@ -14317,7 +14316,8 @@ "node_modules/ramda": { "version": "0.26.1", "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz", - "integrity": "sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ==" + "integrity": "sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ==", + "dev": true }, "node_modules/rc": { "version": "1.2.8", @@ -29952,7 +29952,8 @@ "ramda": { "version": "0.26.1", "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz", - "integrity": "sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ==" + "integrity": "sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ==", + "dev": true }, "rc": { "version": "1.2.8", diff --git a/package.json b/package.json index a08daeb..cb8c8d2 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ }, "files": [ "src/*.js", + "src/index.d.ts", "!src/*-spec.js" ], "homepage": "https://github.com/currents-dev/commit-info#readme", @@ -43,6 +44,7 @@ ], "license": "MIT", "main": "src/", + "types": "src/index.d.ts", "private": false, "publishConfig": { "registry": "https://registry.npmjs.org/" @@ -99,7 +101,6 @@ "check-more-types": "2.24.0", "debug": "4.3.4", "execa": "1.0.0", - "lazy-ass": "1.6.0", - "ramda": "0.26.1" + "lazy-ass": "1.6.0" } } diff --git a/src/commit-info-repos-spec.js b/src/commit-info-repos-spec.js index 447c4e7..83aa709 100644 --- a/src/commit-info-repos-spec.js +++ b/src/commit-info-repos-spec.js @@ -28,7 +28,7 @@ const git = (cwd, ...args) => /** * Runs commitInfo in a new process, so the CI variables of the machine that - * runs the tests do not apply. + * runs the tests do not apply and each test can get its warning. * Resolves with the result and everything the process printed. */ function runCommitInfo (cwd, env) { @@ -94,6 +94,28 @@ describe('commitInfo in real repositories', function () { assert.strictEqual(stderr, '') }) + it('takes the branch from CI for a detached checkout', () => { + git(repo, 'checkout', '-q', '--detach') + const { info } = runCommitInfo(repo, { + GITHUB_ACTIONS: 'true', + GITHUB_REF: 'refs/pull/12/merge', + GITHUB_REF_NAME: '12/merge', + GITHUB_HEAD_REF: 'feature/x', + GITHUB_SHA: 'ci-sha' + }) + assert.strictEqual(info.branch, 'feature/x') + assert.strictEqual(info.sha, sha) + }) + + it('prefers the git branch to the CI branch', () => { + const { info } = runCommitInfo(repo, { + TF_BUILD: 'True', + AZURE_HTTP_USER_AGENT: 'agent', + BUILD_SOURCEBRANCH: 'refs/heads/other' + }) + assert.strictEqual(info.branch, 'main') + }) + it('prefers COMMIT_INFO_BRANCH to git and keeps it as it is', () => { const { info } = runCommitInfo(repo, { COMMIT_INFO_BRANCH: 'refs/heads/from-env' @@ -117,6 +139,12 @@ describe('commitInfo in real repositories', function () { assert(!output.includes(TOKEN), output) }) + it('are removed from the CI remote when there is no repository', () => { + const { info, output } = runCommitInfo(root, gitlabEnv) + assert.strictEqual(info.remote, 'https://gitlab.com/org/repo.git') + assert(!output.includes(TOKEN), output) + }) + it('are removed from COMMIT_INFO_REMOTE', () => { const { info, output } = runCommitInfo( repo, @@ -136,11 +164,54 @@ describe('commitInfo in real repositories', function () { assert.strictEqual(stderr, '') }) - it('has no values when git is not in PATH', () => { - const { info } = runCommitInfo(repo, { PATH: root }) + it('warns once when git is not in PATH', () => { + const { info, stderr } = runCommitInfo(repo, { PATH: root }) assert.strictEqual(info.sha, null) assert.strictEqual(info.branch, null) assert.strictEqual(info.remote, null) + assert(stderr.includes('git was not found in PATH'), stderr) + assert.strictEqual(stderr.match(/\[commit-info\]/g).length, 1, stderr) + }) + + describe('no repository', () => { + it('warns when CI variables do not fill the fields', () => { + const { info, stderr } = runCommitInfo(root, { + GITHUB_ACTIONS: 'true', + GITHUB_REF: 'refs/heads/main', + GITHUB_SHA: sha + }) + assert.strictEqual(info.branch, 'main') + assert.strictEqual(info.sha, sha) + assert.strictEqual(info.message, null) + assert(/git failed in .*not a git repository/i.test(stderr), stderr) + assert( + stderr.includes( + 'Set COMMIT_INFO_MESSAGE, COMMIT_INFO_AUTHOR, COMMIT_INFO_EMAIL' + ), + stderr + ) + assert.strictEqual(stderr.match(/git failed/g).length, 1, stderr) + }) + + it('does not warn outside CI', () => { + const { info, stderr } = runCommitInfo(root, {}) + assert.strictEqual(info.sha, null) + assert.strictEqual(stderr, '') + }) + + it('does not warn when CI variables fill the fields', () => { + const { info, stderr } = runCommitInfo(root, { + BUILDKITE: 'true', + BUILDKITE_BRANCH: 'main', + BUILDKITE_COMMIT: sha, + BUILDKITE_MESSAGE: 'feat: first', + BUILDKITE_BUILD_CREATOR: 'Jane', + BUILDKITE_BUILD_CREATOR_EMAIL: 'jane@example.com' + }) + assert.strictEqual(info.sha, sha) + assert.strictEqual(info.author, 'Jane') + assert.strictEqual(stderr, '') + }) }) describe('repository owned by another user', () => { @@ -173,9 +244,12 @@ describe('commitInfo in real repositories', function () { assert.strictEqual(stderr, '') }) - it('is not read outside CI', () => { - const { info } = runCommitInfo(repo, otherOwner) + it('is not read outside CI, with a warning', () => { + const { info, stderr } = runCommitInfo(repo, otherOwner) assert.strictEqual(info.sha, null) + assert(stderr.includes('dubious ownership'), stderr) + assert(stderr.includes('safe.directory'), stderr) + assert(stderr.includes('COMMIT_INFO_SHA'), stderr) }) }) }) diff --git a/src/git-api.js b/src/git-api.js index 62a879d..c7daeda 100644 --- a/src/git-api.js +++ b/src/git-api.js @@ -149,6 +149,7 @@ module.exports = { runGitCommand, runGitCommandWithError, getGitBranch, + checkIfDetached, getSubject, getBody, getMessage, diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..0bdfda7 --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,56 @@ +export interface GhaEventData { + headRef: string; + headSha: string; + baseRef: string; + baseSha: string; + issueUrl: string; + htmlUrl: string; + prTitle: string; + senderAvatarUrl: string; + senderHtmlUrl: string; +} + +export interface CommitInfo { + branch: string | null; + message: string | null; + email: string | null; + author: string | null; + sha: string | null; + /** seconds since epoch */ + timestamp: string | null; + /** without credentials */ + remote: string | null; + ghaEventData?: GhaEventData; +} + +export interface CiCommitInfo { + provider: string | null; + branch: string | null; + message: string | null; + email: string | null; + author: string | null; + sha: string | null; + /** without credentials */ + remote: string | null; + /** no CI provider sets it */ + timestamp: null; +} + +type Env = Record; + +export function commitInfo(folder?: string): Promise; +export function getCiCommitInfo(env?: Env): CiCommitInfo; +export function detectCiProvider(env?: Env): string | null; +export function removeCredentials( + url: T +): T; + +export function getBranch(folder?: string): Promise; +export function getMessage(folder?: string): Promise; +export function getEmail(folder?: string): Promise; +export function getAuthor(folder?: string): Promise; +export function getSha(folder?: string): Promise; +export function getRemoteOrigin(folder?: string): Promise; +export function getSubject(folder?: string): Promise; +export function getTimestamp(folder?: string): Promise; +export function getBody(folder?: string): Promise; diff --git a/src/index.js b/src/index.js index 9c1ac0b..4c625e1 100644 --- a/src/index.js +++ b/src/index.js @@ -9,51 +9,155 @@ const { getAuthor, getSha, getTimestamp, - getRemoteOrigin + getRemoteOrigin, + gitCommands, + runGitCommandWithError, + readRemoteOrigin, + checkIfDetached } = require('./git-api') const { getBranch, getCommitInfoFromEnvironment, + getEnvName, + getFields, getGhaEventData } = require('./utils') const { getPullRequestHeadCommit } = require('./pull-request-head') const { getCiCommitInfo, detectCiProvider } = require('./ci') const { removeCredentials } = require('./remove-credentials') +const { describeGitError, isCi, isDubiousOwnership } = require('./run-git') const Promise = require('bluebird') -const { mergeWith, or } = require('ramda') +const GIT_COMMANDS = { + branch: gitCommands.branch, + message: gitCommands.message, + email: gitCommands.email, + author: gitCommands.author, + sha: gitCommands.sha, + timestamp: gitCommands.timestamp +} + +// No CI provider sets a timestamp, and many repositories have no remote, so +// missing values there do not cause a warning +const WARN_FIELDS = ['branch', 'sha', 'message', 'author', 'email'] + +const withoutRemoteCredentials = info => + Object.assign({}, info, { remote: removeCredentials(info.remote) }) + +/** + * Resolves with `{ info, error }`: the values git returned, and the first + * error git failed with. + */ +function readGit (folder) { + const reads = { remote: readRemoteOrigin(folder) } + Object.keys(GIT_COMMANDS).forEach(field => { + reads[field] = runGitCommandWithError(GIT_COMMANDS[field], folder) + }) + return Promise.props(reads).then(results => { + const info = {} + let error = null + getFields().forEach(field => { + info[field] = results[field].value + error = error || results[field].error + }) + info.branch = checkIfDetached(info.branch) + return { info, error } + }) +} + +/** + * For each field the first value that is set wins: + * 1. the COMMIT_INFO_* variable + * 2. git + * 3. the CI provider's variables + */ +function combineCommitInfo (fromEnvironment, fromGit, fromCi) { + const combined = {} + getFields().forEach(field => { + combined[field] = + fromEnvironment[field] || fromGit[field] || fromCi[field] || null + }) + return combined +} + +// Playwright workers each call commitInfo; one warning per process is enough +let warned = false + +const isNotRepository = error => + /not a git repository/i.test(String(error.stderr || error.message || '')) + +const isGitMissing = error => error.code === 'ENOENT' + +function warnAboutMissingFields (folder, gitError, info) { + const missing = WARN_FIELDS.filter(field => !info[field]) + if (!gitError || !missing.length || warned) { + return + } + // a command run outside a repository on a developer machine has no commit + if (isNotRepository(gitError) && !isCi()) { + return + } + warned = true + const lines = [ + isGitMissing(gitError) + ? `[commit-info] git was not found in PATH, so the commit in ${folder} could not be read.` + : `[commit-info] git failed in ${folder}: ${describeGitError(gitError)}`, + `Missing commit fields: ${missing.join(', ')}. Set ${missing + .map(getEnvName) + .join(', ')} to provide them.` + ] + if (isDubiousOwnership(gitError)) { + lines.push( + "Or allow the repository: git config --global --add safe.directory '*'. " + + 'git 2.35.2 to 2.37.x ignore safe.directory set on the command line.' + ) + } + console.warn(lines.join('\n')) +} + +/** + * Resolves with the commit the folder has checked out. The COMMIT_INFO_* + * variables take priority over git; the CI provider's variables fill the + * fields git could not read. The remote has no credentials. + * + * @param {string} [folder] defaults to the current working directory + */ function commitInfo (folder) { folder = folder || process.cwd() debug('commit-info in folder', folder) return Promise.props({ - branch: getBranch(folder), - message: getMessage(folder), - email: getEmail(folder), - author: getAuthor(folder), - sha: getSha(folder), - timestamp: getTimestamp(folder), - remote: getRemoteOrigin(folder), + git: readGit(folder), ghaEventData: getGhaEventData( process.env.GITHUB_EVENT_PATH, process.env.GITHUB_ACTIONS ) }) - .then(info => { + .then(({ git, ghaEventData }) => { // COMMIT_INFO_SHA names the commit to report, so it is used as is if (process.env.COMMIT_INFO_SHA) { - return info + return Object.assign({ ghaEventData }, git) } - return getPullRequestHeadCommit(folder, info.sha, info.ghaEventData).then( - head => Object.assign({}, info, head) + return getPullRequestHeadCommit(folder, git.info.sha, ghaEventData).then( + head => ({ + info: Object.assign({}, git.info, head), + error: git.error, + ghaEventData + }) ) }) - .then(info => { - const envVariables = getCommitInfoFromEnvironment() - envVariables.remote = removeCredentials(envVariables.remote) - debug('git commit: %o', info) + .then(({ info: gitInfo, error: gitError, ghaEventData }) => { + const envVariables = withoutRemoteCredentials( + getCommitInfoFromEnvironment() + ) + const ciInfo = getCiCommitInfo() + debug('git commit: %o', gitInfo) debug('env commit: %o', envVariables) - return mergeWith(or, envVariables, info) + debug('CI commit: %o', ciInfo) + + const info = combineCommitInfo(envVariables, gitInfo, ciInfo) + warnAboutMissingFields(folder, gitError, info) + return Object.assign(info, { ghaEventData }) }) } diff --git a/src/utils-spec.js b/src/utils-spec.js index d4f1422..026ba65 100644 --- a/src/utils-spec.js +++ b/src/utils-spec.js @@ -2,7 +2,6 @@ const la = require('lazy-ass') const is = require('check-more-types') -const { mergeWith, or } = require('ramda') const sinon = require('sinon') const fs = require('fs') @@ -61,13 +60,6 @@ describe('utils', () => { }) }) - describe('R.mergeWith', () => { - it('keeps non-empty string', () => { - const o = mergeWith(or, { foo: 'foo' }, { foo: '' }) - la(o.foo === 'foo', o) - }) - }) - describe('firstFoundValue', () => { const { firstFoundValue } = require('./utils') diff --git a/src/utils.js b/src/utils.js index ad0e1a7..745668e 100644 --- a/src/utils.js +++ b/src/utils.js @@ -29,25 +29,26 @@ function getBranch (pathToRepo) { } /** - * Looks up commit information from environment keys. + * Returns list of Git properties that this module searches for */ -function getCommitInfoFromEnvironment (env = process.env) { - return { - branch: getValue('COMMIT_INFO_BRANCH')(env), - message: getValue('COMMIT_INFO_MESSAGE')(env), - email: getValue('COMMIT_INFO_EMAIL')(env), - author: getValue('COMMIT_INFO_AUTHOR')(env), - sha: getValue('COMMIT_INFO_SHA')(env), - timestamp: getValue('COMMIT_INFO_TIMESTAMP')(env), - remote: getValue('COMMIT_INFO_REMOTE')(env) - } +function getFields () { + return ['branch', 'message', 'email', 'author', 'sha', 'remote', 'timestamp'] } /** - * Returns list of Git properties that this module searches for + * Name of the environment variable that sets a field, as in COMMIT_INFO_SHA */ -function getFields () { - return ['branch', 'message', 'email', 'author', 'sha', 'remote', 'timestamp'] +const getEnvName = field => `COMMIT_INFO_${field.toUpperCase()}` + +/** + * Looks up commit information from environment keys. + */ +function getCommitInfoFromEnvironment (env = process.env) { + const info = {} + getFields().forEach(field => { + info[field] = getValue(getEnvName(field))(env) + }) + return info } /** @@ -85,6 +86,7 @@ module.exports = { firstFoundValue, getBranch, getCommitInfoFromEnvironment, + getEnvName, getFields, getGhaEventData } From 5d29eb43ee8605a9b574366d9dc08915286d4506 Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Fri, 2 Oct 2026 18:21:56 -0700 Subject: [PATCH 2/6] feat!: take the remote from the CI provider before git commitInfo() returns COMMIT_INFO_REMOTE, else the CI provider's remote, else the git remote, as the Playwright reporter did with CI_PREFERRED_KEYS (ENG-563). An Azure Pipelines clone often has an SSH remote, and the pull request link needs the HTTPS URL in BUILD_REPOSITORY_URI. The providers that set a remote are AWS CodeBuild, Azure Pipelines, Bamboo, Buildkite, CircleCI, Drone, GitLab, Semaphore and Netlify. Credentials are removed as before. BREAKING CHANGE: on those providers commitInfo().remote is the CI provider's remote, not the git remote. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KVkH9Ei4iCN2WvGMAUT1d --- README.md | 4 +++- src/commit-info-repos-spec.js | 32 ++++++++++++++++++++++++++++++++ src/index.js | 9 ++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 327b978..d3e9d4e 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ Each property comes from the first of these that has a value, or is `null`: 2. git, see [src/git-api.js](src/git-api.js) 3. the CI provider's environment variables, see [CI provider variables](#ci-provider-variables) +The `remote` is the exception: the CI provider's value comes before git. `COMMIT_INFO_REMOTE` still comes first. On Azure Pipelines, for example, the clone often has an SSH remote, and `BUILD_REPOSITORY_URI` has the HTTPS URL. The CI providers that set a remote are AWS CodeBuild, Azure Pipelines, Bamboo, Buildkite, CircleCI, Drone, GitLab, Semaphore and Netlify, see [src/ci.js](src/ci.js). + Notes: - git reports no branch for a detached checkout (`HEAD`), so the branch comes from the CI provider. Branch values from `COMMIT_INFO_BRANCH` and the CI provider are reported as they are. @@ -47,7 +49,7 @@ Notes: ## CI provider variables -When git does not return a value, it comes from the variables of the CI provider the process runs on. The branch comes from: +When git does not return a value, it comes from the variables of the CI provider the process runs on. The remote comes from these variables first. The branch comes from: | Provider | Branch | | --- | --- | diff --git a/src/commit-info-repos-spec.js b/src/commit-info-repos-spec.js index 83aa709..bf649aa 100644 --- a/src/commit-info-repos-spec.js +++ b/src/commit-info-repos-spec.js @@ -123,6 +123,38 @@ describe('commitInfo in real repositories', function () { assert.strictEqual(info.branch, 'refs/heads/from-env') }) + describe('remote', () => { + const azureEnv = { + TF_BUILD: 'True', + AZURE_HTTP_USER_AGENT: 'agent', + BUILD_REPOSITORY_URI: 'https://org@dev.azure.com/org/p/_git/repo' + } + + it('comes from the CI provider before git', () => { + const { info } = runCommitInfo(repo, azureEnv) + assert.strictEqual(info.remote, 'https://dev.azure.com/org/p/_git/repo') + }) + + it('comes from git when the CI provider has no remote', () => { + const { info } = runCommitInfo(repo, { + GITHUB_ACTIONS: 'true', + GITHUB_REF: 'refs/heads/main' + }) + assert.strictEqual(info.remote, 'https://gitlab.com/org/repo.git') + }) + + it('comes from COMMIT_INFO_REMOTE before the CI provider', () => { + const { info } = runCommitInfo( + repo, + Object.assign( + { COMMIT_INFO_REMOTE: 'git@github.com:o/r.git' }, + azureEnv + ) + ) + assert.strictEqual(info.remote, 'git@github.com:o/r.git') + }) + }) + describe('credentials', () => { const gitlabEnv = { GITLAB_CI: 'true', diff --git a/src/index.js b/src/index.js index 4c625e1..1cab828 100644 --- a/src/index.js +++ b/src/index.js @@ -70,6 +70,10 @@ function readGit (folder) { * 1. the COMMIT_INFO_* variable * 2. git * 3. the CI provider's variables + * + * For the remote the CI provider's value wins over git, as in the Currents + * Playwright reporter: an Azure Pipelines clone often has an SSH remote, and + * the pull request link needs the HTTPS URL in BUILD_REPOSITORY_URI. */ function combineCommitInfo (fromEnvironment, fromGit, fromCi) { const combined = {} @@ -77,6 +81,8 @@ function combineCommitInfo (fromEnvironment, fromGit, fromCi) { combined[field] = fromEnvironment[field] || fromGit[field] || fromCi[field] || null }) + combined.remote = + fromEnvironment.remote || fromCi.remote || fromGit.remote || null return combined } @@ -118,7 +124,8 @@ function warnAboutMissingFields (folder, gitError, info) { /** * Resolves with the commit the folder has checked out. The COMMIT_INFO_* * variables take priority over git; the CI provider's variables fill the - * fields git could not read. The remote has no credentials. + * fields git could not read, and take priority over git for the remote. The + * remote has no credentials. * * @param {string} [folder] defaults to the current working directory */ From d75c2ffafd8781270f6fcd3d6b4598a65b7b83ec Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Fri, 2 Oct 2026 18:21:56 -0700 Subject: [PATCH 3/6] chore: release 2.0.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KVkH9Ei4iCN2WvGMAUT1d --- CHANGELOG.md | 19 +++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9c3fa74 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## 2.0.0 + +Breaking: `commitInfo()` returns other values than 1.x. + +- Fills the fields git could not read from the CI provider's variables: GitHub Actions, GitLab, CircleCI, Jenkins, Azure Pipelines, Bitbucket Pipelines, Buildkite, AWS CodeBuild, and the providers listed in `src/ci.js`. Priority: `COMMIT_INFO_*`, then git, then the CI provider. The variables are the ones the Currents Playwright reporter used. +- `remote` comes from `COMMIT_INFO_REMOTE`, then the CI provider's variables, then git. The CI providers that set a remote are AWS CodeBuild, Azure Pipelines, Bamboo, Buildkite, CircleCI, Drone, GitLab, Semaphore and Netlify. This is the rule the Currents Playwright reporter used. +- On Semaphore, `remote` is the clone URL in `SEMAPHORE_GIT_URL`. The Currents Playwright reporter reported `SEMAPHORE_GIT_REPO_SLUG` (`owner/repo`), which Currents could not build commit links from. +- On Bamboo, `remote` comes from `bamboo_planRepository_repositoryUrl`. The Currents Playwright reporter read `bamboo_planRepository_repositoryURL`, which Bamboo does not set. +- `remote` has no user name or password, also in the `DEBUG=commit-info` output and from `getRemoteOrigin()` and `getCiCommitInfo()`. +- On CI, when git refuses a repository owned by another user ("dubious ownership"), the read-only git commands run again with `-c safe.directory=*`. CI means that `CI` is set or a CI provider is detected, so Jenkins counts. `GOOGLE_CLOUD_PROJECT`, `GCP_PROJECT`, `GCLOUD_PROJECT` and `JENKINS_HOME` alone do not count. +- Prints one warning when git fails and fields stay empty, also when git is not in `PATH`. +- New exports: `getCiCommitInfo`, `detectCiProvider`, `removeCredentials`, and TypeScript types. + +Migration: + +- If your code fills empty `commitInfo()` fields from CI provider variables, remove it: `commitInfo()` does that now. +- If your code replaces the git remote with the CI provider's remote, remove it: `commitInfo()` returns `COMMIT_INFO_REMOTE`, else the CI provider's remote, else the git remote, without credentials. diff --git a/package-lock.json b/package-lock.json index 82c08f1..5026058 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@currents/commit-info", - "version": "1.1.0", + "version": "2.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@currents/commit-info", - "version": "1.1.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "bluebird": "3.5.5", diff --git a/package.json b/package.json index cb8c8d2..05a7fcc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@currents/commit-info", "description": "Collects Git commit info from CI or from CLI", - "version": "1.1.0", + "version": "2.0.0", "author": "Gleb Bahmutov ", "contributors": [ "Gleb Bahmutov ", From d21d932cd2b02f27fd6d7de1a787190572fdc4bb Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Fri, 2 Oct 2026 22:07:24 -0700 Subject: [PATCH 4/6] fix: say when the folder does not exist instead of git missing Node reports a cwd that does not exist as `spawn git ENOENT`, the same error as a missing git, so commitInfo('/missing') warned that git was not in PATH. A folder that does not exist now counts as no repository: no warning outside CI, and on CI the warning names the folder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KVkH9Ei4iCN2WvGMAUT1d --- src/commit-info-repos-spec.js | 22 ++++++++++++++++++++-- src/index.js | 19 +++++++++++++++---- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/commit-info-repos-spec.js b/src/commit-info-repos-spec.js index bf649aa..ff3f604 100644 --- a/src/commit-info-repos-spec.js +++ b/src/commit-info-repos-spec.js @@ -31,8 +31,10 @@ const git = (cwd, ...args) => * runs the tests do not apply and each test can get its warning. * Resolves with the result and everything the process printed. */ -function runCommitInfo (cwd, env) { - const script = `require(${JSON.stringify(__dirname)}).commitInfo() +function runCommitInfo (cwd, env, folder) { + const script = `require(${JSON.stringify(__dirname)}).commitInfo(${ + folder ? JSON.stringify(folder) : '' + }) .then(info => console.log(JSON.stringify(info)))` const child = spawnSync(process.execPath, ['-e', script], { cwd, @@ -205,6 +207,22 @@ describe('commitInfo in real repositories', function () { assert.strictEqual(stderr.match(/\[commit-info\]/g).length, 1, stderr) }) + describe('a folder that does not exist', () => { + it('says so on CI', () => { + const missing = path.join(root, 'missing') + const { info, stderr } = runCommitInfo(root, { CI: 'true' }, missing) + assert.strictEqual(info.sha, null) + assert(stderr.includes(`${missing} does not exist`), stderr) + assert(!stderr.includes('git was not found'), stderr) + }) + + it('does not warn outside CI', () => { + const missing = path.join(root, 'missing') + const { stderr } = runCommitInfo(root, {}, missing) + assert.strictEqual(stderr, '') + }) + }) + describe('no repository', () => { it('warns when CI variables do not fill the fields', () => { const { info, stderr } = runCommitInfo(root, { diff --git a/src/index.js b/src/index.js index 1cab828..35d3adf 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ 'use strict' const debug = require('debug')('commit-info') +const fs = require('fs') const { getSubject, getBody, @@ -92,22 +93,32 @@ let warned = false const isNotRepository = error => /not a git repository/i.test(String(error.stderr || error.message || '')) +// Node reports a folder that does not exist as `spawn git ENOENT` too const isGitMissing = error => error.code === 'ENOENT' +function describeFailure (folder, gitError) { + if (!fs.existsSync(folder)) { + return `[commit-info] ${folder} does not exist.` + } + if (isGitMissing(gitError)) { + return `[commit-info] git was not found in PATH, so the commit in ${folder} could not be read.` + } + return `[commit-info] git failed in ${folder}: ${describeGitError(gitError)}` +} + function warnAboutMissingFields (folder, gitError, info) { const missing = WARN_FIELDS.filter(field => !info[field]) if (!gitError || !missing.length || warned) { return } // a command run outside a repository on a developer machine has no commit - if (isNotRepository(gitError) && !isCi()) { + const noRepository = isNotRepository(gitError) || !fs.existsSync(folder) + if (noRepository && !isCi()) { return } warned = true const lines = [ - isGitMissing(gitError) - ? `[commit-info] git was not found in PATH, so the commit in ${folder} could not be read.` - : `[commit-info] git failed in ${folder}: ${describeGitError(gitError)}`, + describeFailure(folder, gitError), `Missing commit fields: ${missing.join(', ')}. Set ${missing .map(getEnvName) .join(', ')} to provide them.` From 53cbd17901302d11f78ad9d25206fbc865bf7739 Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Fri, 2 Oct 2026 22:07:24 -0700 Subject: [PATCH 5/6] fix: GhaEventData fields can be undefined getGhaEventData copies the fields from the GitHub event payload without checking them, so a payload without a sender gives undefined senderAvatarUrl and senderHtmlUrl. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KVkH9Ei4iCN2WvGMAUT1d --- src/index.d.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/index.d.ts b/src/index.d.ts index 0bdfda7..9af542e 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,13 +1,14 @@ +/** Copied from the GitHub event payload; a field the payload lacks is undefined */ export interface GhaEventData { - headRef: string; - headSha: string; - baseRef: string; - baseSha: string; - issueUrl: string; - htmlUrl: string; - prTitle: string; - senderAvatarUrl: string; - senderHtmlUrl: string; + headRef?: string; + headSha?: string; + baseRef?: string; + baseSha?: string; + issueUrl?: string; + htmlUrl?: string; + prTitle?: string; + senderAvatarUrl?: string; + senderHtmlUrl?: string; } export interface CommitInfo { From 3f3f6d59abc742b188c569cc5a5ba0658b1bc146 Mon Sep 17 00:00:00 2001 From: Andrew Goldis Date: Fri, 2 Oct 2026 22:24:00 -0700 Subject: [PATCH 6/6] fix: removeCredentials returns string for a string, not the input's literal type The generic return type kept a literal input type, such as 'https://user:token@host/x', although the function returns a different string. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018KVkH9Ei4iCN2WvGMAUT1d --- src/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.d.ts b/src/index.d.ts index 9af542e..c7d3c31 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -44,7 +44,7 @@ export function getCiCommitInfo(env?: Env): CiCommitInfo; export function detectCiProvider(env?: Env): string | null; export function removeCredentials( url: T -): T; +): T extends string ? string : T; export function getBranch(folder?: string): Promise; export function getMessage(folder?: string): Promise;