diff --git a/src/git.test.ts b/src/git.test.ts index 24dc1f8..dd3cb1c 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -502,6 +502,24 @@ type TempRepoStaleMerge = { }; }; +type TempRepoStaleBranchDecoration = { + cwd: string; + commits: { + base: string; + downMerge: string; // Interior merge commit a stale branch was cut from; subject is not a parseable merge message + tip: string; + }; +}; + +type TempRepoFastForwardFeature = { + cwd: string; + commits: { + base: string; + feature: string; // Fast-forwarded regular commit whose issue key lives only in the branch name + tip: string; + }; +}; + function runGit(command: string, cwd: string): string { return execSync(`git ${command}`, { cwd, @@ -784,6 +802,68 @@ function createTempRepoStaleMerge(): TempRepoStaleMerge { return { cwd, commits: { base, staleMerge, subjectMerge } }; } +/** + * A stale branch cut from an interior down-merge commit and never committed onto, + * so its ref only decorates that merge. The merge subject is not a parseable merge + * message, leaving the decoration as the sole — wrong — branch-name source. + */ +function createTempRepoStaleBranchDecoration(): TempRepoStaleBranchDecoration { + const { cwd, base } = initTempRepo({ + prefix: "linear-release-stale-decoration-", + dirs: ["src"], + seedFile: { path: "src/a.txt", content: "a" }, + }); + + runGit(`checkout -b trunk ${base}`, cwd); + writeFileSync(join(cwd, "src", "b.txt"), "b"); + runGit("add .", cwd); + runGit('commit -m "trunk work"', cwd); + + runGit("checkout main", cwd); + writeFileSync(join(cwd, "src", "c.txt"), "c"); + runGit("add .", cwd); + runGit('commit -m "release work"', cwd); + runGit('merge --no-ff trunk -m "Down merge trunk into release (#501)"', cwd); + const downMerge = runGit("rev-parse HEAD", cwd); + + runGit(`branch ZED-7 ${downMerge}`, cwd); + + writeFileSync(join(cwd, "src", "d.txt"), "d"); + runGit("add .", cwd); + runGit('commit -m "[ARC-3]: fix worker lookup for restricted roles (#502)"', cwd); + const tip = runGit("rev-parse HEAD", cwd); + + return { cwd, commits: { base, downMerge, tip } }; +} + +/** + * A GitLab fast-forward merge: the feature branch's commit lands verbatim on main + * with the issue key only in the branch name, and a later commit leaves it + * interior. The kept branch ref is the sole source of the key — it must survive. + */ +function createTempRepoFastForwardFeature(): TempRepoFastForwardFeature { + const { cwd, base } = initTempRepo({ + prefix: "linear-release-ff-feature-", + dirs: ["src"], + seedFile: { path: "src/a.txt", content: "a" }, + }); + + runGit(`checkout -b user/REL-9-feature ${base}`, cwd); + writeFileSync(join(cwd, "src", "b.txt"), "b"); + runGit("add .", cwd); + runGit('commit -m "Add feature"', cwd); + const feature = runGit("rev-parse HEAD", cwd); + + runGit("checkout main", cwd); + runGit("merge --ff-only user/REL-9-feature", cwd); + writeFileSync(join(cwd, "src", "c.txt"), "c"); + runGit("add .", cwd); + runGit('commit -m "chore: follow-up"', cwd); + const tip = runGit("rev-parse HEAD", cwd); + + return { cwd, commits: { base, feature, tip } }; +} + describe("getCommitContextsBetweenShas", () => { let repo: TempRepo; @@ -1228,6 +1308,38 @@ describe("merge commit handling", () => { expect(branchNames).not.toContain("feat/XYZ-2-impl"); }); }); + + describe("getCommitContextsBetweenShas with branch refs decorating interior commits", () => { + let stale: TempRepoStaleBranchDecoration; + let ff: TempRepoFastForwardFeature; + + beforeAll(() => { + stale = createTempRepoStaleBranchDecoration(); + ff = createTempRepoFastForwardFeature(); + }); + + afterAll(() => { + rmSync(stale.cwd, { recursive: true, force: true }); + rmSync(ff.cwd, { recursive: true, force: true }); + }); + + it("ignores a stale ref decorating an interior merge commit", () => { + const result = getCommitContextsBetweenShas(stale.commits.base, stale.commits.tip, { cwd: stale.cwd }); + + const interior = result.find((c) => c.sha === stale.commits.downMerge); + expect(interior).toBeDefined(); + expect(interior?.branchName).toBeNull(); + }); + + it("keeps a fast-forwarded feature branch decorating an interior regular commit", () => { + // The key lives only in the branch name (GitLab fast-forward / direct push), + // so dropping interior decorations here would silently lose the issue. + const result = getCommitContextsBetweenShas(ff.commits.base, ff.commits.tip, { cwd: ff.cwd }); + + const interior = result.find((c) => c.sha === ff.commits.feature); + expect(interior?.branchName).toBe("user/REL-9-feature"); + }); + }); }); describe("assertGitAvailable", () => { diff --git a/src/git.ts b/src/git.ts index 9a3b945..c8eb6fe 100644 --- a/src/git.ts +++ b/src/git.ts @@ -330,13 +330,19 @@ export function extractBranchNameFromMergeMessage(message: string | null | undef /** * Parses a commit chunk (from git log --format=%H%x1f%B%x1f%D) into a CommitContext. * Prefers branch name from merge message over decorations for issue tracking. + * + * A ref decoration is only read on a regular commit. A merge commit is an + * integration node whose real content arrives via its parents; a ref pointing at + * one is the mainline or a branch merely cut from it and never committed onto + * (whose key is not this commit's work), so a merge takes its branch name solely + * from a parseable merge message. A regular commit may fall back to decorations — + * the GitLab fast-forward / direct-push case where the key lives only in the ref. */ function parseCommitChunk(chunk: string): CommitContext { const [sha, rawMessage, rawDecorations, rawParents] = chunk.split("\x1f"); // Collapse runs of horizontal whitespace, but keep newlines so downstream // extractors can tell the title from the body and skip nested commit blocks. const message = (rawMessage ?? "").trim().replace(/[ \t]+/g, " "); - const branchName = extractBranchNameFromMergeMessage(message) ?? extractBranchName(rawDecorations); // %P is the parent SHAs, space-separated and empty for a root commit. Keep only // full 40-char hashes so a root commit yields [] rather than [""], letting // parents.length reliably tell a merge (2+) from a normal commit (1). @@ -344,6 +350,8 @@ function parseCommitChunk(chunk: string): CommitContext { .trim() .split(/\s+/) .filter((p) => /^[0-9a-f]{40}$/i.test(p)); + const isMerge = parents.length >= 2; + const branchName = extractBranchNameFromMergeMessage(message) ?? (isMerge ? null : extractBranchName(rawDecorations)); return { sha: sha.trim(), branchName, message, parents }; }