Skip to content

Commit 91f44df

Browse files
committed
More targeted test
1 parent 9f5f13c commit 91f44df

3 files changed

Lines changed: 79 additions & 41 deletions

File tree

src/git.test.ts

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getRepoInfo,
1616
normalizePathspec,
1717
parseRepoUrl,
18+
resolveFirstSyncBoundary,
1819
} from "./git";
1920

2021
describe("normalizePathspec", () => {
@@ -449,6 +450,29 @@ function runGit(command: string, cwd: string): string {
449450
}).trim();
450451
}
451452

453+
/**
454+
* Initializes a tmpdir repo, configures user, creates the listed directories,
455+
* lands a seed commit, and renames the branch to `main`. Returns the cwd and
456+
* base SHA.
457+
*/
458+
function initTempRepo(opts: { prefix: string; dirs: string[]; seedFile: { path: string; content: string } }): {
459+
cwd: string;
460+
base: string;
461+
} {
462+
const cwd = mkdtempSync(join(tmpdir(), opts.prefix));
463+
runGit("init", cwd);
464+
runGit('config user.email "test@example.com"', cwd);
465+
runGit('config user.name "Test User"', cwd);
466+
for (const dir of opts.dirs) {
467+
mkdirSync(join(cwd, dir), { recursive: true });
468+
}
469+
writeFileSync(join(cwd, opts.seedFile.path), opts.seedFile.content);
470+
runGit("add .", cwd);
471+
runGit('commit -m "Initial"', cwd);
472+
runGit("branch -M main", cwd);
473+
return { cwd, base: runGit("rev-parse HEAD", cwd) };
474+
}
475+
452476
/**
453477
* Cuts `branch` off `baseBranch`, lands one file change, merges back via
454478
* `--no-ff` with a GitHub-style PR-merge message, then deletes `branch` to
@@ -569,19 +593,11 @@ function createTempRepoWithMerge(): TempRepoWithMerge {
569593
* commit merged back as HEAD. `merge300` touches `infra/` only.
570594
*/
571595
function createTempRepoWithMultipleMerges(): TempRepoWithMultipleMerges {
572-
const cwd = mkdtempSync(join(tmpdir(), "linear-release-multi-merge-"));
573-
runGit("init", cwd);
574-
runGit('config user.email "test@example.com"', cwd);
575-
runGit('config user.name "Test User"', cwd);
576-
577-
mkdirSync(join(cwd, "frontend"), { recursive: true });
578-
mkdirSync(join(cwd, "backend"), { recursive: true });
579-
mkdirSync(join(cwd, "infra"), { recursive: true });
580-
writeFileSync(join(cwd, "frontend", "seed.txt"), "seed");
581-
runGit("add .", cwd);
582-
runGit('commit -m "Initial"', cwd);
583-
runGit("branch -M main", cwd);
584-
const base = runGit("rev-parse HEAD", cwd);
596+
const { cwd, base } = initTempRepo({
597+
prefix: "linear-release-multi-merge-",
598+
dirs: ["frontend", "backend", "infra"],
599+
seedFile: { path: "frontend/seed.txt", content: "seed" },
600+
});
585601

586602
const merge100 = mergeFeatureBranch({
587603
cwd,
@@ -625,19 +641,11 @@ function createTempRepoWithMultipleMerges(): TempRepoWithMultipleMerges {
625641
* only.
626642
*/
627643
function createTempRepoReleaseBranch(): TempRepoReleaseBranch {
628-
const cwd = mkdtempSync(join(tmpdir(), "linear-release-rel-branch-"));
629-
runGit("init", cwd);
630-
runGit('config user.email "test@example.com"', cwd);
631-
runGit('config user.name "Test User"', cwd);
632-
633-
mkdirSync(join(cwd, "frontend-nuxt3"), { recursive: true });
634-
mkdirSync(join(cwd, "backend"), { recursive: true });
635-
mkdirSync(join(cwd, "mobile-android"), { recursive: true });
636-
writeFileSync(join(cwd, "frontend-nuxt3", "seed.ts"), "seed");
637-
runGit("add .", cwd);
638-
runGit('commit -m "Initial"', cwd);
639-
runGit("branch -M main", cwd);
640-
const base = runGit("rev-parse HEAD", cwd);
644+
const { cwd, base } = initTempRepo({
645+
prefix: "linear-release-rel-branch-",
646+
dirs: ["frontend-nuxt3", "backend", "mobile-android"],
647+
seedFile: { path: "frontend-nuxt3/seed.ts", content: "seed" },
648+
});
641649

642650
runGit("checkout -b rel/2026-05-06 main", cwd);
643651
mergeFeatureBranch({
@@ -887,6 +895,22 @@ describe("merge commit handling", () => {
887895
});
888896
});
889897

898+
describe("resolveFirstSyncBoundary", () => {
899+
it("expands to HEAD^1 when HEAD is a merge commit", () => {
900+
expect(resolveFirstSyncBoundary(mergeRepo.commits.mergeCommit, mergeRepo.cwd)).toBe(mergeRepo.commits.base);
901+
});
902+
903+
it("returns the commit itself when HEAD is a regular commit", () => {
904+
expect(resolveFirstSyncBoundary(mergeRepo.commits.featureBranch, mergeRepo.cwd)).toBe(
905+
mergeRepo.commits.featureBranch,
906+
);
907+
});
908+
909+
it("returns the commit itself when HEAD is the root commit", () => {
910+
expect(resolveFirstSyncBoundary(mergeRepo.commits.base, mergeRepo.cwd)).toBe(mergeRepo.commits.base);
911+
});
912+
});
913+
890914
describe("getCommitContextsBetweenShas with merge commits", () => {
891915
it("should include merge commit when path filtering would exclude it", () => {
892916
// The merge node itself adds no file changes, so default simplification
@@ -997,12 +1021,14 @@ describe("merge commit handling", () => {
9971021
rmSync(relRepo.cwd, { recursive: true, force: true });
9981022
});
9991023

1000-
it("should surface feature merges from inside the rel branch when scanning HEAD^1..HEAD", () => {
1001-
const parents = getCommitParents(relRepo.commits.headMerge, relRepo.cwd);
1002-
expect(parents.length).toBeGreaterThanOrEqual(1);
1003-
const parent = parents[0]!;
1024+
it("should surface feature merges from inside the rel branch when scanning the resolved first-sync boundary", () => {
1025+
// Mirrors the customer's first-sync flow: resolveFirstSyncBoundary picks
1026+
// HEAD^1 because HEAD is a merge, then getCommitContextsBetweenShas runs
1027+
// over that range.
1028+
const boundary = resolveFirstSyncBoundary(relRepo.commits.headMerge, relRepo.cwd);
1029+
expect(boundary).not.toBe(relRepo.commits.headMerge);
10041030

1005-
const result = getCommitContextsBetweenShas(parent, relRepo.commits.headMerge, {
1031+
const result = getCommitContextsBetweenShas(boundary, relRepo.commits.headMerge, {
10061032
includePaths: ["frontend-nuxt3/**", "backend/**"],
10071033
cwd: relRepo.cwd,
10081034
});

src/git.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,19 @@ export function extractBranchName(rawDecorations: string | undefined): string |
122122
return preferred.sort((a, b) => b.length - a.length)[0]!;
123123
}
124124

125+
/**
126+
* Implicit scan boundary for a first-time release sync (no prior release SHA).
127+
* Expands a merge HEAD to its first parent so the merged-in branch's commits
128+
* are in range — issue keys live there, not on the merge node itself.
129+
*/
130+
export function resolveFirstSyncBoundary(currentSha: string, cwd: string = process.cwd()): string {
131+
const parents = getCommitParents(currentSha, cwd);
132+
if (parents.length > 1 && parents[0]) {
133+
return parents[0];
134+
}
135+
return currentSha;
136+
}
137+
125138
/**
126139
* Returns `sha`'s parent SHAs in order. Empty array if the commit has no
127140
* reachable parents — root commit, unknown SHA, or shallow clone where the

src/index.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import {
33
assertGitAvailable,
44
ensureCommitAvailable,
55
getCommitContextsBetweenShas,
6-
getCommitParents,
76
getCurrentGitInfo,
87
getRepoInfo,
8+
resolveFirstSyncBoundary,
99
} from "./git";
1010
import { scanCommits } from "./scan";
1111
import {
@@ -350,16 +350,15 @@ async function getLatestSha(): Promise<string> {
350350
throw new Error("Could not get current commit");
351351
}
352352

353-
// Merge HEAD has no issue keys on itself — they live on HEAD^2's branch.
354-
// Scanning HEAD^1..HEAD picks them up. Non-merge HEAD carries its own key,
355-
// so HEAD-only stays correct.
356-
const parents = getCommitParents(currentSha);
357-
if (parents.length > 1 && parents[0]) {
358-
verbose(`First sync on merge HEAD: using HEAD^1 (${parents[0]}) as the scan boundary`);
359-
return parents[0];
353+
// For a merge HEAD the issue keys live on HEAD^2's branch, not on HEAD
354+
// itself, so HEAD-only would miss them. Non-merge HEAD carries its own key.
355+
const boundary = resolveFirstSyncBoundary(currentSha);
356+
if (boundary !== currentSha) {
357+
verbose(`First sync on merge HEAD: using HEAD^1 (${boundary}) as the scan boundary`);
358+
} else {
359+
verbose("First sync: only inspecting current commit");
360360
}
361-
verbose("First sync: only inspecting current commit");
362-
return currentSha;
361+
return boundary;
363362
}
364363

365364
async function getPipelineSettings(): Promise<{

0 commit comments

Comments
 (0)