From 7068b4e348333cc2072bfa222d155711c65e4fed Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Fri, 12 Jun 2026 10:48:53 +0200 Subject: [PATCH] Fix Bitbucket PR head SHA detection --- .../base/src/helpers/__tests__/ci.test.ts | 180 ++++++++++++++++++ packages/base/src/helpers/ci.ts | 107 +++++++++++ packages/base/src/helpers/git/get-git-data.ts | 15 ++ 3 files changed, 302 insertions(+) diff --git a/packages/base/src/helpers/__tests__/ci.test.ts b/packages/base/src/helpers/__tests__/ci.test.ts index 0c7ddcbbf1..e85b918829 100644 --- a/packages/base/src/helpers/__tests__/ci.test.ts +++ b/packages/base/src/helpers/__tests__/ci.test.ts @@ -13,6 +13,14 @@ jest.mock('../glob', () => { globSync: jest.fn(() => []), } }) +jest.mock('../git/get-git-data', () => { + const actual = jest.requireActual('../git/get-git-data') + + return { + ...actual, + gitRawSync: jest.fn(), + } +}) import { getCIEnv, @@ -28,6 +36,7 @@ import { isInteractive, shouldGetGithubJobDisplayName, } from '../ci' +import {gitRawSync} from '../git/get-git-data' import {globSync} from '../glob' import { CI_ENV_VARS, @@ -37,6 +46,7 @@ import { GIT_PULL_REQUEST_BASE_BRANCH, GIT_PULL_REQUEST_BASE_BRANCH_HEAD_SHA, GIT_PULL_REQUEST_BASE_BRANCH_SHA, + GIT_SHA, PR_NUMBER, } from '../tags' import {getUserCISpanTags, getUserGitSpanTags} from '../user-provided-git' @@ -44,6 +54,7 @@ import {getUserCISpanTags, getUserGitSpanTags} from '../user-provided-git' import {createMockContext} from './testing-tools' const mockedGlobSync = globSync as jest.MockedFunction +const mockedGitRawSync = gitRawSync as jest.MockedFunction // Synthetic hosted-runner diag dirs used to exercise the glob-expansion path. // The real patterns live in githubWellKnownDiagnosticDirPatternsUnix/Win; these @@ -216,6 +227,175 @@ describe('ci spec', () => { expect(tags).toEqual({}) }) + describe('bitbucket pull request head sha', () => { + const syntheticMergeSha = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + const sourceBranchSha = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + const destinationBranchSha = 'cccccccccccccccccccccccccccccccccccccccc' + const sourceMergeCommitSha = 'dddddddddddddddddddddddddddddddddddddddd' + + const setBitbucketPullRequestEnv = () => { + process.env = { + BITBUCKET_BRANCH: 'feature/one', + BITBUCKET_BUILD_NUMBER: 'bitbucket-build-num', + BITBUCKET_CLONE_DIR: '/foo/bar', + BITBUCKET_COMMIT: syntheticMergeSha, + BITBUCKET_GIT_HTTP_ORIGIN: 'https://bitbucket-repo-url.com/repo.git', + BITBUCKET_PIPELINE_UUID: '{bitbucket-uuid}', + BITBUCKET_PR_DESTINATION_BRANCH: 'main', + BITBUCKET_PR_ID: '42', + BITBUCKET_REPO_FULL_NAME: 'bitbucket-repo', + } + } + + beforeEach(() => { + mockedGitRawSync.mockReset() + mockedGitRawSync.mockReturnValue(undefined) + setBitbucketPullRequestEnv() + }) + + afterEach(() => { + mockedGitRawSync.mockReset() + }) + + it('keeps BITBUCKET_COMMIT when it is the pull request source branch head', () => { + process.env.BITBUCKET_COMMIT = sourceMergeCommitSha + mockedGitRawSync.mockImplementation((gitArgs) => { + if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'HEAD^{commit}') { + return sourceMergeCommitSha + } + if (gitArgs[0] === 'rev-list') { + return `${sourceMergeCommitSha} ${sourceBranchSha} ${destinationBranchSha}` + } + if (gitArgs[0] === 'ls-remote' && gitArgs[2] === 'refs/heads/feature/one') { + return `${sourceMergeCommitSha}\trefs/heads/feature/one` + } + + return undefined + }) + + const tags = getCISpanTags() as SpanTags + + expect(tags[GIT_SHA]).toBe(sourceMergeCommitSha) + expect(tags[GIT_HEAD_SHA]).toBe(sourceMergeCommitSha) + }) + + it('uses the first parent of a verified synthetic merge commit', () => { + mockedGitRawSync.mockImplementation((gitArgs) => { + if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'HEAD^{commit}') { + return syntheticMergeSha + } + if (gitArgs[0] === 'rev-list') { + return `${syntheticMergeSha} ${sourceBranchSha} ${destinationBranchSha}` + } + if (gitArgs[2] === 'refs/remotes/origin/feature/one^{commit}') { + return sourceBranchSha + } + if (gitArgs[2] === 'refs/remotes/origin/main^{commit}') { + return destinationBranchSha + } + + return undefined + }) + + const tags = getCISpanTags() as SpanTags + + expect(tags[GIT_HEAD_SHA]).toBe(sourceBranchSha) + }) + + it('falls back to remote branch refs when local refs are unavailable', () => { + mockedGitRawSync.mockImplementation((gitArgs) => { + if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'HEAD^{commit}') { + return syntheticMergeSha + } + if (gitArgs[0] === 'rev-list') { + return `${syntheticMergeSha} ${sourceBranchSha} ${destinationBranchSha}` + } + if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'refs/remotes/origin/feature/one^{commit}') { + return undefined + } + if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'refs/heads/feature/one^{commit}') { + return undefined + } + if (gitArgs[0] === 'ls-remote' && gitArgs[2] === 'refs/heads/main') { + return `${destinationBranchSha}\trefs/heads/main` + } + if (gitArgs[0] === 'ls-remote' && gitArgs[2] === 'refs/heads/feature/one') { + return `${sourceBranchSha}\trefs/heads/feature/one` + } + + return undefined + }) + + const tags = getCISpanTags() as SpanTags + + expect(tags[GIT_HEAD_SHA]).toBe(sourceBranchSha) + }) + + it('does not infer from merge parents when the destination parent cannot be verified', () => { + mockedGitRawSync.mockImplementation((gitArgs) => { + if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'HEAD^{commit}') { + return syntheticMergeSha + } + if (gitArgs[0] === 'rev-list') { + return `${syntheticMergeSha} ${sourceBranchSha} ${destinationBranchSha}` + } + if (gitArgs[2] === 'refs/remotes/origin/feature/one^{commit}') { + return sourceBranchSha + } + + return undefined + }) + + const tags = getCISpanTags() as SpanTags + + expect(tags[GIT_HEAD_SHA]).toBeUndefined() + }) + + it('does not use a source branch ref that no longer matches the checked-out commit graph', () => { + const newerSourceBranchSha = 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' + mockedGitRawSync.mockImplementation((gitArgs) => { + if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'HEAD^{commit}') { + return syntheticMergeSha + } + if (gitArgs[0] === 'rev-list') { + return `${syntheticMergeSha} ${sourceBranchSha} ${destinationBranchSha}` + } + if (gitArgs[0] === 'ls-remote' && gitArgs[2] === 'refs/heads/feature/one') { + return `${newerSourceBranchSha}\trefs/heads/feature/one` + } + if (gitArgs[2] === 'refs/remotes/origin/main^{commit}') { + return destinationBranchSha + } + + return undefined + }) + + const tags = getCISpanTags() as SpanTags + + expect(tags[GIT_HEAD_SHA]).toBeUndefined() + }) + + it('does not infer from merge parents when the source branch head cannot be verified', () => { + mockedGitRawSync.mockImplementation((gitArgs) => { + if (gitArgs[0] === 'rev-parse' && gitArgs[2] === 'HEAD^{commit}') { + return syntheticMergeSha + } + if (gitArgs[0] === 'rev-list') { + return `${syntheticMergeSha} ${sourceBranchSha} ${destinationBranchSha}` + } + if (gitArgs[2] === 'refs/remotes/origin/main^{commit}') { + return destinationBranchSha + } + + return undefined + }) + + const tags = getCISpanTags() as SpanTags + + expect(tags[GIT_HEAD_SHA]).toBeUndefined() + }) + }) + CI_PROVIDERS.forEach((ciProvider) => { const assertions = require(upath.join(__dirname, 'ci-env', ciProvider)) as [ {[key: string]: string}, diff --git a/packages/base/src/helpers/ci.ts b/packages/base/src/helpers/ci.ts index b5c90b1bc6..dc165688ce 100644 --- a/packages/base/src/helpers/ci.ts +++ b/packages/base/src/helpers/ci.ts @@ -6,6 +6,7 @@ import type {BaseContext} from 'clipanion' import chalk from 'chalk' import upath from 'upath' +import {gitRawSync} from './git/get-git-data' import {globSync, hasMagic} from './glob' import { CI_ENV_VARS, @@ -239,6 +240,106 @@ const resolveTilde = (filePath: string | undefined) => { return filePath } +const gitShaRegex = /^[0-9a-f]{7,64}$/i + +const getFirstGitSha = (output: string | undefined): string | undefined => { + const sha = output?.split(/\s+/)[0] + + return sha && gitShaRegex.test(sha) ? sha : undefined +} + +const getGitShas = (output: string | undefined): string[] => + output?.split(/\s+/).filter((sha) => gitShaRegex.test(sha)) ?? [] + +const normalizeBranchName = (branchName: string | undefined): string | undefined => + branchName + ?.replace(/^refs\/heads\//, '') + .replace(/^refs\/remotes\/origin\//, '') + .replace(/^origin\//, '') + +const getRemoteBranchHeadSha = (branchName: string | undefined, cwd?: string): string | undefined => { + const normalizedBranchName = normalizeBranchName(branchName) + if (!normalizedBranchName) { + return undefined + } + + return getFirstGitSha(gitRawSync(['ls-remote', 'origin', `refs/heads/${normalizedBranchName}`], cwd)) +} + +const getLocalBranchHeadShas = (branchName: string | undefined, cwd?: string): string[] => { + const normalizedBranchName = normalizeBranchName(branchName) + if (!normalizedBranchName) { + return [] + } + + return uniq( + [ + getFirstGitSha( + gitRawSync(['rev-parse', '--verify', `refs/remotes/origin/${normalizedBranchName}^{commit}`], cwd) + ), + getFirstGitSha(gitRawSync(['rev-parse', '--verify', `refs/heads/${normalizedBranchName}^{commit}`], cwd)), + ].filter((sha): sha is string => !!sha) + ) +} + +const findMatchingBranchHeadSha = ( + branchName: string | undefined, + candidateShas: string[], + cwd?: string +): string | undefined => { + const localBranchHeadSha = getLocalBranchHeadShas(branchName, cwd).find((sha) => candidateShas.includes(sha)) + if (localBranchHeadSha) { + return localBranchHeadSha + } + + const remoteBranchHeadSha = getRemoteBranchHeadSha(branchName, cwd) + + return remoteBranchHeadSha && candidateShas.includes(remoteBranchHeadSha) ? remoteBranchHeadSha : undefined +} + +const getBitbucketPullRequestHeadSha = ( + sourceBranchName: string | undefined, + bitbucketCommit: string | undefined, + destinationBranchName: string | undefined, + cwd?: string +): string | undefined => { + if (!sourceBranchName || !bitbucketCommit) { + return undefined + } + + const headSha = getFirstGitSha(gitRawSync(['rev-parse', '--verify', 'HEAD^{commit}'], cwd)) + if (headSha !== bitbucketCommit) { + return undefined + } + + const commitWithParents = getGitShas(gitRawSync(['rev-list', '--parents', '-n', '1', 'HEAD'], cwd)) + const firstParent = commitWithParents[1] + const sourceBranchHeadSha = findMatchingBranchHeadSha( + sourceBranchName, + [bitbucketCommit, firstParent].filter((sha): sha is string => !!sha), + cwd + ) + + if (sourceBranchHeadSha === bitbucketCommit) { + return bitbucketCommit + } + + if (!destinationBranchName || commitWithParents[0] !== bitbucketCommit || commitWithParents.length !== 3) { + return undefined + } + + const destinationParent = commitWithParents[2] + const destinationBranchHeadSha = findMatchingBranchHeadSha(destinationBranchName, [destinationParent], cwd) + + if (sourceBranchHeadSha !== firstParent || destinationBranchHeadSha !== destinationParent) { + return undefined + } + + // Bitbucket PR pipelines synthesize this merge by merging the destination + // branch into the source branch, so the first parent is the PR source head. + return firstParent +} + export const getCISpanTags = (fallbackGithubJobName?: string, fallbackGithubJobID?: string): SpanTags | undefined => { const env = process.env let tags: SpanTags = {} @@ -697,6 +798,12 @@ export const getCISpanTags = (fallbackGithubJobName?: string, fallbackGithubJobI if (BITBUCKET_PR_ID) { tags[PR_NUMBER] = BITBUCKET_PR_ID tags[GIT_PULL_REQUEST_BASE_BRANCH] = BITBUCKET_PR_DESTINATION_BRANCH + tags[GIT_HEAD_SHA] = getBitbucketPullRequestHeadSha( + BITBUCKET_BRANCH, + BITBUCKET_COMMIT, + BITBUCKET_PR_DESTINATION_BRANCH, + resolveTilde(BITBUCKET_CLONE_DIR) + ) } } diff --git a/packages/base/src/helpers/git/get-git-data.ts b/packages/base/src/helpers/git/get-git-data.ts index 6e1825053e..d2cb55e23e 100644 --- a/packages/base/src/helpers/git/get-git-data.ts +++ b/packages/base/src/helpers/git/get-git-data.ts @@ -1,9 +1,24 @@ +import {execFileSync} from 'child_process' import {URL} from 'url' import type {GitAuthorAndCommitterMetadata} from '../interfaces' import type * as simpleGit from 'simple-git' import type {BranchSummary} from 'simple-git' +export const gitRawSync = (args: string[], cwd?: string): string | undefined => { + try { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: {...process.env, GIT_TERMINAL_PROMPT: '0'}, + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }).trim() + } catch { + return undefined + } +} + // Returns the remote of the current repository. export const gitRemote = async (git: simpleGit.SimpleGit): Promise => { const remotes = await git.getRemotes(true)