Skip to content

Commit bbeffdf

Browse files
authored
Ensure stale branch are not taken into account by --include-paths (#104)
* Ensure stale branch are not taken into account by --include-paths * Address review: drop #62 links and clarify parent-parsing/stale-merge comments
1 parent 927b1e5 commit bbeffdf

3 files changed

Lines changed: 172 additions & 9 deletions

File tree

src/git.test.ts

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,15 @@ type TempRepoReleaseBranch = {
493493
};
494494
};
495495

496+
type TempRepoStaleMerge = {
497+
cwd: string;
498+
commits: {
499+
base: string;
500+
staleMerge: string; // Merge of feat/ABC-1-stale — edited app-a/ only, merged after app-b/ landed
501+
subjectMerge: string; // Merge of feat/XYZ-2-impl — edited app-b/, key only on the merge subject (HEAD)
502+
};
503+
};
504+
496505
function runGit(command: string, cwd: string): string {
497506
return execSync(`git ${command}`, {
498507
cwd,
@@ -729,6 +738,52 @@ function createTempRepoReleaseBranch(): TempRepoReleaseBranch {
729738
return { cwd, commits: { base, headMerge } };
730739
}
731740

741+
/**
742+
* Two PR merges into main, each carrying its issue key only in the branch name
743+
* (no content commit carries a key):
744+
* - feat/ABC-1-stale is rooted at `base`, edits app-a/ only, and is merged
745+
* AFTER app-b/ appears on main — a stale branch never rebased. The merge
746+
* differs from its first parent for app-b/ only because app-b/ advanced on
747+
* main while the branch was open, so `--full-history` keeps it under an app-b
748+
* pathspec even though the branch delivered nothing to app-b/.
749+
* - feat/XYZ-2-impl is rooted at the stale merge and genuinely edits app-b/.
750+
* Its key lives only on the merge subject, so dropping the merge would lose
751+
* the key entirely even though the merge did deliver app-b/ changes.
752+
*/
753+
function createTempRepoStaleMerge(): TempRepoStaleMerge {
754+
const { cwd, base } = initTempRepo({
755+
prefix: "linear-release-stale-merge-",
756+
dirs: ["app-a", "app-b"],
757+
seedFile: { path: "app-a/file.txt", content: "a0" },
758+
});
759+
760+
runGit(`checkout -b feat/ABC-1-stale ${base}`, cwd);
761+
writeFileSync(join(cwd, "app-a", "file.txt"), "a1");
762+
runGit("add .", cwd);
763+
runGit('commit -m "rework app-a internals"', cwd);
764+
765+
runGit("checkout main", cwd);
766+
writeFileSync(join(cwd, "app-b", "file.txt"), "b0");
767+
runGit("add .", cwd);
768+
runGit('commit -m "add app-b on main"', cwd);
769+
770+
runGit('merge --no-ff feat/ABC-1-stale -m "Merge pull request #1 from owner/feat/ABC-1-stale"', cwd);
771+
const staleMerge = runGit("rev-parse HEAD", cwd);
772+
runGit("branch -D feat/ABC-1-stale", cwd);
773+
774+
runGit(`checkout -b feat/XYZ-2-impl ${staleMerge}`, cwd);
775+
writeFileSync(join(cwd, "app-b", "file.txt"), "b1");
776+
runGit("add .", cwd);
777+
runGit('commit -m "implement the thing"', cwd);
778+
779+
runGit("checkout main", cwd);
780+
runGit('merge --no-ff feat/XYZ-2-impl -m "Merge pull request #2 from owner/feat/XYZ-2-impl"', cwd);
781+
const subjectMerge = runGit("rev-parse HEAD", cwd);
782+
runGit("branch -D feat/XYZ-2-impl", cwd);
783+
784+
return { cwd, commits: { base, staleMerge, subjectMerge } };
785+
}
786+
732787
describe("getCommitContextsBetweenShas", () => {
733788
let repo: TempRepo;
734789

@@ -1044,8 +1099,9 @@ describe("merge commit handling", () => {
10441099
const shas = new Set(result.map((c) => c.sha));
10451100
expect(shas.has(multiRepo.commits.merge100)).toBe(true);
10461101
expect(shas.has(multiRepo.commits.merge200)).toBe(true);
1047-
// merge300 only touched infra/ — kept by the merges-only scan, then dropped
1048-
// by commitTouchesPaths so LIN-300 doesn't leak into a frontend release.
1102+
// merge300 only touched infra/, so under the frontend/backend pathspec it
1103+
// changed nothing relative to its parents and `--full-history` drops it
1104+
// natively — LIN-300 never reaches a frontend release.
10491105
expect(shas.has(multiRepo.commits.merge300)).toBe(false);
10501106
expect(shas.has(multiRepo.commits.headMerge)).toBe(true);
10511107

@@ -1115,6 +1171,63 @@ describe("merge commit handling", () => {
11151171
expect(branchNames).not.toContain("feature/LIN-300-mobile");
11161172
});
11171173
});
1174+
1175+
describe("getCommitContextsBetweenShas with stale-branch merges under a path filter", () => {
1176+
let repo: TempRepoStaleMerge;
1177+
1178+
beforeAll(() => {
1179+
repo = createTempRepoStaleMerge();
1180+
});
1181+
1182+
afterAll(() => {
1183+
rmSync(repo.cwd, { recursive: true, force: true });
1184+
});
1185+
1186+
it("drops a stale-branch merge that delivered no change to the filtered paths", () => {
1187+
// feat/ABC-1-stale edited app-a/ only but merged after app-b/ landed, so
1188+
// `--full-history` keeps its merge under the app-b pathspec. The merge
1189+
// delivered nothing to app-b/, so its subject key must not be attributed.
1190+
const result = getCommitContextsBetweenShas(repo.commits.base, repo.commits.subjectMerge, {
1191+
includePaths: ["app-b/**"],
1192+
cwd: repo.cwd,
1193+
});
1194+
1195+
const shas = new Set(result.map((c) => c.sha));
1196+
expect(shas.has(repo.commits.staleMerge)).toBe(false);
1197+
1198+
const branchNames = result.map((c) => c.branchName).filter((b): b is string => !!b);
1199+
expect(branchNames).not.toContain("feat/ABC-1-stale");
1200+
});
1201+
1202+
it("retains a merge whose key lives only on the subject when it delivered the filtered paths", () => {
1203+
// feat/XYZ-2-impl genuinely edited app-b/ and carries its key only on the
1204+
// merge subject, so dropping the merge would lose the key entirely.
1205+
const result = getCommitContextsBetweenShas(repo.commits.base, repo.commits.subjectMerge, {
1206+
includePaths: ["app-b/**"],
1207+
cwd: repo.cwd,
1208+
});
1209+
1210+
const shas = new Set(result.map((c) => c.sha));
1211+
expect(shas.has(repo.commits.subjectMerge)).toBe(true);
1212+
1213+
const branchNames = result.map((c) => c.branchName).filter((b): b is string => !!b);
1214+
expect(branchNames).toContain("feat/XYZ-2-impl");
1215+
});
1216+
1217+
it("still attributes a stale merge to the surface it actually touched", () => {
1218+
// The same stale merge DID deliver app-a/ changes, so under an app-a filter
1219+
// its subject key is correctly retained — the fix discards leaks, not work.
1220+
// And the app-b-only merge must not leak into the app-a surface.
1221+
const result = getCommitContextsBetweenShas(repo.commits.base, repo.commits.subjectMerge, {
1222+
includePaths: ["app-a/**"],
1223+
cwd: repo.cwd,
1224+
});
1225+
1226+
const branchNames = result.map((c) => c.branchName).filter((b): b is string => !!b);
1227+
expect(branchNames).toContain("feat/ABC-1-stale");
1228+
expect(branchNames).not.toContain("feat/XYZ-2-impl");
1229+
});
1230+
});
11181231
});
11191232

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

src/git.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -332,13 +332,20 @@ export function extractBranchNameFromMergeMessage(message: string | null | undef
332332
* Prefers branch name from merge message over decorations for issue tracking.
333333
*/
334334
function parseCommitChunk(chunk: string): CommitContext {
335-
const [sha, rawMessage, rawDecorations] = chunk.split("\x1f");
335+
const [sha, rawMessage, rawDecorations, rawParents] = chunk.split("\x1f");
336336
// Collapse runs of horizontal whitespace, but keep newlines so downstream
337337
// extractors can tell the title from the body and skip nested commit blocks.
338338
const message = (rawMessage ?? "").trim().replace(/[ \t]+/g, " ");
339339
const branchName = extractBranchNameFromMergeMessage(message) ?? extractBranchName(rawDecorations);
340-
341-
return { sha: sha.trim(), branchName, message };
340+
// %P is the parent SHAs, space-separated and empty for a root commit. Keep only
341+
// full 40-char hashes so a root commit yields [] rather than [""], letting
342+
// parents.length reliably tell a merge (2+) from a normal commit (1).
343+
const parents = (rawParents ?? "")
344+
.trim()
345+
.split(/\s+/)
346+
.filter((p) => /^[0-9a-f]{40}$/i.test(p));
347+
348+
return { sha: sha.trim(), branchName, message, parents };
342349
}
343350

344351
/**
@@ -387,7 +394,7 @@ export function ensureCommitAvailable(sha: string, cwd: string = process.cwd()):
387394
const RUN_LOG_MAX_BUFFER = 256 * 1024 * 1024;
388395

389396
function runLog(rangeArgs: string, cwd: string): CommitContext[] {
390-
const output = execSync(`git log --format=%H%x1f%B%x1f%D%x1e ${rangeArgs}`, {
397+
const output = execSync(`git log --format=%H%x1f%B%x1f%D%x1f%P%x1e ${rangeArgs}`, {
391398
cwd,
392399
stdio: ["ignore", "pipe", "pipe"],
393400
encoding: "utf8",
@@ -399,14 +406,54 @@ function runLog(rangeArgs: string, cwd: string): CommitContext[] {
399406
.map(parseCommitChunk);
400407
}
401408

409+
/**
410+
* Whether merge `commit` delivered net changes to the filtered paths, compared to
411+
* its first parent (the branch it was merged into). Non-merges pass through.
412+
*
413+
* Without this, `--full-history` retains a stale branch's merge for paths it never
414+
* touched — they differ across the merge only because the target branch advanced
415+
* while the branch was open — leaking the issue key on the merge subject. The
416+
* first-parent diff is empty for those and non-empty when the merge really
417+
* delivered the paths, so a merge whose issue key lives only on its subject is
418+
* still kept when it actually touched the paths. On an unexpected diff failure,
419+
* keep the merge rather than drop real work.
420+
*/
421+
function mergeDeliversToPaths(commit: CommitContext, pathspec: string, cwd: string): boolean {
422+
const parents = commit.parents ?? [];
423+
if (parents.length <= 1) {
424+
return true;
425+
}
426+
try {
427+
execSync(`git diff --quiet ${parents[0]} ${commit.sha} ${pathspec}`, {
428+
cwd,
429+
stdio: ["ignore", "ignore", "pipe"],
430+
});
431+
return false;
432+
} catch (e) {
433+
const status = (e as { status?: number }).status;
434+
if (status === 1) {
435+
return true;
436+
}
437+
warn(
438+
`Could not diff merge ${commit.sha.slice(0, 7)} against its first parent; keeping it under the path filter. ${
439+
e instanceof Error ? e.message : String(e)
440+
}`,
441+
);
442+
return true;
443+
}
444+
}
445+
402446
/**
403447
* Returns commits between two SHAs, optionally filtered by file paths.
404448
*
405449
* `--full-history` (only when `includePaths` is set): a non-evil merge's
406450
* tree equals one of its parents' trees, so under a pathspec it's TREESAME
407451
* and git's default simplification drops it. That's true of every provider's
408452
* merge commit (GitHub, GitLab MR, Bitbucket PR, plain `git merge --no-ff`)
409-
* and would lose the issue keys encoded in their feature-branch names.
453+
* and would lose the issue keys encoded in their feature-branch names. Merges
454+
* it keeps are then passed through `mergeDeliversToPaths`, which drops a
455+
* stale-branch merge that delivered no net change to the filtered paths so its
456+
* subject key isn't attributed to a surface it never touched.
410457
*
411458
* `--no-walk` (only when `fromSha === toSha`): without it, `git log -1 <sha>
412459
* -- <paths>` walks back from `<sha>` to the first ancestor matching the
@@ -437,14 +484,16 @@ export function getCommitContextsBetweenShas(
437484
}
438485

439486
const inspectingSingleCommit = fromSha === toSha && inspectSingleCommit;
487+
const pathspec = buildPathspecArgs(includePaths);
440488
const args = [
441489
includePaths?.length ? "--full-history" : "",
442490
inspectingSingleCommit ? `--no-walk ${toSha}` : `${fromSha}..${toSha}`,
443-
buildPathspecArgs(includePaths),
491+
pathspec,
444492
]
445493
.filter(Boolean)
446494
.join(" ");
447-
const commits = runLog(args, cwd);
495+
const logged = runLog(args, cwd);
496+
const commits = pathspec ? logged.filter((commit) => mergeDeliversToPaths(commit, pathspec, cwd)) : logged;
448497

449498
if (commits.length === 0) {
450499
if (inspectingSingleCommit) {

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export type CommitContext = {
6868
sha: string;
6969
branchName?: string | null;
7070
message?: string | null;
71+
parents?: string[];
7172
};
7273

7374
export type GitInfo = {

0 commit comments

Comments
 (0)