Skip to content

Commit d6f2afd

Browse files
committed
Ensure merges are properly handled for detection of issues & PRs
1 parent 17b7854 commit d6f2afd

3 files changed

Lines changed: 332 additions & 90 deletions

File tree

src/git.test.ts

Lines changed: 265 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import {
1111
extractBranchNameFromMergeMessage,
1212
getCommitContext,
1313
getCommitContextsBetweenShas,
14+
getCommitParents,
1415
getRepoInfo,
15-
isMergeCommit,
1616
normalizePathspec,
1717
parseRepoUrl,
1818
} from "./git";
@@ -422,6 +422,25 @@ type TempRepoWithMerge = {
422422
};
423423
};
424424

425+
type TempRepoWithMultipleMerges = {
426+
cwd: string;
427+
commits: {
428+
base: string;
429+
merge100: string; // Merge of feature/LIN-100 (touches frontend/)
430+
merge200: string; // Merge of feature/LIN-200 (touches backend/)
431+
merge300: string; // Merge of feature/LIN-300 (touches infra/ — outside includePaths)
432+
headMerge: string; // Merge of release branch into main
433+
};
434+
};
435+
436+
type TempRepoReleaseBranch = {
437+
cwd: string;
438+
commits: {
439+
base: string;
440+
headMerge: string; // The rel-branch → main merge (HEAD)
441+
};
442+
};
443+
425444
function runGit(command: string, cwd: string): string {
426445
return execSync(`git ${command}`, {
427446
cwd,
@@ -430,6 +449,31 @@ function runGit(command: string, cwd: string): string {
430449
}).trim();
431450
}
432451

452+
/**
453+
* Cuts a feature branch off `baseBranch`, lands one file change, merges it
454+
* back via `--no-ff` with a GitHub-style PR-merge message, then deletes the
455+
* branch (mirroring CI checkout state where merged feature branches are
456+
* gone). Returns the merge commit SHA.
457+
*/
458+
function mergeFeatureBranch(opts: {
459+
cwd: string;
460+
baseBranch: string;
461+
branch: string;
462+
file: string;
463+
prNumber: number;
464+
}): string {
465+
const { cwd, baseBranch, branch, file, prNumber } = opts;
466+
runGit(`checkout -b ${branch} ${baseBranch}`, cwd);
467+
writeFileSync(join(cwd, file), "x");
468+
runGit("add .", cwd);
469+
runGit(`commit -m "feature work on ${branch}"`, cwd);
470+
runGit(`checkout ${baseBranch}`, cwd);
471+
runGit(`merge --no-ff ${branch} -m "Merge pull request #${prNumber} from owner/${branch}"`, cwd);
472+
const sha = runGit("rev-parse HEAD", cwd);
473+
runGit(`branch -D ${branch}`, cwd);
474+
return sha;
475+
}
476+
433477
/**
434478
* Build a deterministic git repo for integration tests.
435479
*
@@ -521,6 +565,113 @@ function createTempRepoWithMerge(): TempRepoWithMerge {
521565
return { cwd, commits: { base, featureBranch, mergeCommit } };
522566
}
523567

568+
/**
569+
* Each feature branch is merged directly into main, then a release branch is
570+
* cut, gets one commit, and merges back. merge300 touches infra/ — outside
571+
* the includePaths used in tests, so it must not leak.
572+
*/
573+
function createTempRepoWithMultipleMerges(): TempRepoWithMultipleMerges {
574+
const cwd = mkdtempSync(join(tmpdir(), "linear-release-multi-merge-"));
575+
runGit("init", cwd);
576+
runGit('config user.email "test@example.com"', cwd);
577+
runGit('config user.name "Test User"', cwd);
578+
579+
mkdirSync(join(cwd, "frontend"), { recursive: true });
580+
mkdirSync(join(cwd, "backend"), { recursive: true });
581+
mkdirSync(join(cwd, "infra"), { recursive: true });
582+
writeFileSync(join(cwd, "frontend", "seed.txt"), "seed");
583+
runGit("add .", cwd);
584+
runGit('commit -m "Initial"', cwd);
585+
runGit("branch -M main", cwd);
586+
const base = runGit("rev-parse HEAD", cwd);
587+
588+
const merge100 = mergeFeatureBranch({
589+
cwd,
590+
baseBranch: "main",
591+
branch: "feature/LIN-100-add-foo",
592+
file: "frontend/foo.txt",
593+
prNumber: 100,
594+
});
595+
const merge200 = mergeFeatureBranch({
596+
cwd,
597+
baseBranch: "main",
598+
branch: "feature/LIN-200-fix-bar",
599+
file: "backend/bar.txt",
600+
prNumber: 200,
601+
});
602+
const merge300 = mergeFeatureBranch({
603+
cwd,
604+
baseBranch: "main",
605+
branch: "feature/LIN-300-infra",
606+
file: "infra/three.txt",
607+
prNumber: 300,
608+
});
609+
610+
// rel branch needs at least one of its own commits, otherwise --no-ff is a
611+
// no-op when the branches are identical.
612+
runGit("checkout -b rel/2026-05-06 main", cwd);
613+
writeFileSync(join(cwd, "frontend", "release-notes.txt"), "notes");
614+
runGit("add .", cwd);
615+
runGit('commit -m "release notes"', cwd);
616+
runGit("checkout main", cwd);
617+
runGit('merge --no-ff rel/2026-05-06 -m "Merge pull request #324 from owner/rel/2026-05-06"', cwd);
618+
const headMerge = runGit("rev-parse HEAD", cwd);
619+
runGit("branch -D rel/2026-05-06", cwd);
620+
621+
return { cwd, commits: { base, merge100, merge200, merge300, headMerge } };
622+
}
623+
624+
/**
625+
* Customer's release-branch workflow: features are merged INTO a release
626+
* branch (not main), then rel is merged into main as HEAD. LIN-300 is mobile-
627+
* only — outside the path filter used in tests.
628+
*/
629+
function createTempRepoReleaseBranch(): TempRepoReleaseBranch {
630+
const cwd = mkdtempSync(join(tmpdir(), "linear-release-rel-branch-"));
631+
runGit("init", cwd);
632+
runGit('config user.email "test@example.com"', cwd);
633+
runGit('config user.name "Test User"', cwd);
634+
635+
mkdirSync(join(cwd, "frontend-nuxt3"), { recursive: true });
636+
mkdirSync(join(cwd, "backend"), { recursive: true });
637+
mkdirSync(join(cwd, "mobile-android"), { recursive: true });
638+
writeFileSync(join(cwd, "frontend-nuxt3", "seed.ts"), "seed");
639+
runGit("add .", cwd);
640+
runGit('commit -m "Initial"', cwd);
641+
runGit("branch -M main", cwd);
642+
const base = runGit("rev-parse HEAD", cwd);
643+
644+
runGit("checkout -b rel/2026-05-06 main", cwd);
645+
mergeFeatureBranch({
646+
cwd,
647+
baseBranch: "rel/2026-05-06",
648+
branch: "feature/LIN-100-foo",
649+
file: "frontend-nuxt3/foo.ts",
650+
prNumber: 100,
651+
});
652+
mergeFeatureBranch({
653+
cwd,
654+
baseBranch: "rel/2026-05-06",
655+
branch: "feature/LIN-200-bar",
656+
file: "backend/bar.ts",
657+
prNumber: 200,
658+
});
659+
mergeFeatureBranch({
660+
cwd,
661+
baseBranch: "rel/2026-05-06",
662+
branch: "feature/LIN-300-mobile",
663+
file: "mobile-android/m.kt",
664+
prNumber: 300,
665+
});
666+
667+
runGit("checkout main", cwd);
668+
runGit('merge --no-ff rel/2026-05-06 -m "Merge pull request #324 from owner/rel/2026-05-06"', cwd);
669+
const headMerge = runGit("rev-parse HEAD", cwd);
670+
runGit("branch -D rel/2026-05-06", cwd);
671+
672+
return { cwd, commits: { base, headMerge } };
673+
}
674+
524675
describe("getCommitContextsBetweenShas", () => {
525676
let repo: TempRepo;
526677

@@ -697,21 +848,6 @@ describe("merge commit handling", () => {
697848
rmSync(mergeRepo.cwd, { recursive: true, force: true });
698849
});
699850

700-
describe("isMergeCommit", () => {
701-
it("should return true for a merge commit", () => {
702-
expect(isMergeCommit(mergeRepo.commits.mergeCommit, mergeRepo.cwd)).toBe(true);
703-
});
704-
705-
it("should return false for a regular commit", () => {
706-
expect(isMergeCommit(mergeRepo.commits.featureBranch, mergeRepo.cwd)).toBe(false);
707-
expect(isMergeCommit(mergeRepo.commits.base, mergeRepo.cwd)).toBe(false);
708-
});
709-
710-
it("should return false for invalid SHA", () => {
711-
expect(isMergeCommit("invalid-sha", mergeRepo.cwd)).toBe(false);
712-
});
713-
});
714-
715851
describe("getCommitContext", () => {
716852
it("should return commit context for a valid SHA", () => {
717853
const context = getCommitContext(mergeRepo.commits.mergeCommit, mergeRepo.cwd);
@@ -733,6 +869,25 @@ describe("merge commit handling", () => {
733869
});
734870
});
735871

872+
describe("getCommitParents", () => {
873+
it("returns 2 parents for a merge commit", () => {
874+
const parents = getCommitParents(mergeRepo.commits.mergeCommit, mergeRepo.cwd);
875+
expect(parents).toEqual([mergeRepo.commits.base, mergeRepo.commits.featureBranch]);
876+
});
877+
878+
it("returns 1 parent for a regular commit", () => {
879+
expect(getCommitParents(mergeRepo.commits.featureBranch, mergeRepo.cwd)).toEqual([mergeRepo.commits.base]);
880+
});
881+
882+
it("returns [] for the root commit", () => {
883+
expect(getCommitParents(mergeRepo.commits.base, mergeRepo.cwd)).toEqual([]);
884+
});
885+
886+
it("returns [] for an unknown SHA", () => {
887+
expect(getCommitParents("0000000000000000000000000000000000000000", mergeRepo.cwd)).toEqual([]);
888+
});
889+
});
890+
736891
describe("getCommitContextsBetweenShas with merge commits", () => {
737892
it("should include merge commit when path filtering would exclude it", () => {
738893
// Without the fix, path filtering for "src/**" would only return the feature branch commit
@@ -768,6 +923,100 @@ describe("merge commit handling", () => {
768923
expect(mergeCommitCount).toBe(1);
769924
});
770925
});
926+
927+
describe("getCommitContextsBetweenShas with multiple merges in range", () => {
928+
let multiRepo: TempRepoWithMultipleMerges;
929+
930+
beforeAll(() => {
931+
multiRepo = createTempRepoWithMultipleMerges();
932+
});
933+
934+
afterAll(() => {
935+
rmSync(multiRepo.cwd, { recursive: true, force: true });
936+
});
937+
938+
it("should return in-path merges and drop out-of-path merges across a multi-merge range (LIN-69346)", () => {
939+
// Without the fix, git's history simplification drops every merge commit
940+
// because they're TREESAME to a parent within the paths, losing the
941+
// feature/LIN-XXX branch names that are the only carrier of issue keys.
942+
const result = getCommitContextsBetweenShas(multiRepo.commits.base, multiRepo.commits.headMerge, {
943+
includePaths: ["frontend/**", "backend/**"],
944+
cwd: multiRepo.cwd,
945+
});
946+
947+
const shas = new Set(result.map((c) => c.sha));
948+
expect(shas.has(multiRepo.commits.merge100)).toBe(true);
949+
expect(shas.has(multiRepo.commits.merge200)).toBe(true);
950+
// merge300 only touched infra/ — kept by the merges-only scan, then dropped
951+
// by commitTouchesPaths so LIN-300 doesn't leak into a frontend release.
952+
expect(shas.has(multiRepo.commits.merge300)).toBe(false);
953+
expect(shas.has(multiRepo.commits.headMerge)).toBe(true);
954+
955+
const branchNames = result.map((c) => c.branchName).filter((b): b is string => !!b);
956+
expect(branchNames).toEqual(
957+
expect.arrayContaining(["feature/LIN-100-add-foo", "feature/LIN-200-fix-bar", "rel/2026-05-06"]),
958+
);
959+
expect(branchNames).not.toContain("feature/LIN-300-infra");
960+
});
961+
962+
it("should return HEAD merge commit when fromSha === toSha and HEAD is a merge", () => {
963+
const result = getCommitContextsBetweenShas(multiRepo.commits.headMerge, multiRepo.commits.headMerge, {
964+
includePaths: ["frontend/**", "backend/**"],
965+
cwd: multiRepo.cwd,
966+
});
967+
968+
const headResult = result.find((c) => c.sha === multiRepo.commits.headMerge);
969+
expect(headResult).toBeDefined();
970+
expect(headResult?.branchName).toBe("rel/2026-05-06");
971+
});
972+
973+
it("should not drift to an unrelated ancestor when fromSha === toSha and HEAD is outside includePaths", () => {
974+
// The legacy `git log -1 <sha> -- <paths>` walked back from <sha> when it
975+
// didn't match, returning an unrelated ancestor. We use --no-walk semantics
976+
// instead: match the exact commit or return nothing.
977+
const result = getCommitContextsBetweenShas(multiRepo.commits.merge300, multiRepo.commits.merge300, {
978+
includePaths: ["frontend/**"],
979+
cwd: multiRepo.cwd,
980+
});
981+
982+
expect(result).toEqual([]);
983+
});
984+
});
985+
986+
describe("getCommitContextsBetweenShas with release-branch workflow (LIN-69346)", () => {
987+
// Mirrors the customer's first-sync scenario exactly: features are merged
988+
// INTO a release branch (not main), then rel is merged into main as HEAD.
989+
// With no prior release SHA on the pipeline, the CLI uses HEAD^1 as the
990+
// implicit boundary — without that boundary, scanning HEAD alone would
991+
// miss every issue key.
992+
let relRepo: TempRepoReleaseBranch;
993+
994+
beforeAll(() => {
995+
relRepo = createTempRepoReleaseBranch();
996+
});
997+
998+
afterAll(() => {
999+
rmSync(relRepo.cwd, { recursive: true, force: true });
1000+
});
1001+
1002+
it("should surface feature merges from inside the rel branch when scanning HEAD^1..HEAD", () => {
1003+
const parents = getCommitParents(relRepo.commits.headMerge, relRepo.cwd);
1004+
expect(parents.length).toBeGreaterThanOrEqual(1);
1005+
const parent = parents[0]!;
1006+
1007+
const result = getCommitContextsBetweenShas(parent, relRepo.commits.headMerge, {
1008+
includePaths: ["frontend-nuxt3/**", "backend/**"],
1009+
cwd: relRepo.cwd,
1010+
});
1011+
1012+
const branchNames = result.map((c) => c.branchName).filter((b): b is string => !!b);
1013+
expect(branchNames).toEqual(
1014+
expect.arrayContaining(["feature/LIN-100-foo", "feature/LIN-200-bar", "rel/2026-05-06"]),
1015+
);
1016+
// LIN-300 is mobile-only — outside the path filter — must not leak.
1017+
expect(branchNames).not.toContain("feature/LIN-300-mobile");
1018+
});
1019+
});
7711020
});
7721021

7731022
describe("assertGitAvailable", () => {

0 commit comments

Comments
 (0)