Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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", () => {
Expand Down
10 changes: 9 additions & 1 deletion src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,20 +330,28 @@ 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).
const parents = (rawParents ?? "")
.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 };
}
Expand Down
Loading