Skip to content

Commit 31c4ec1

Browse files
committed
Add findBaseSha walk with scenario tests
Pure helper that picks a base SHA from a list of candidate releases by asking git which one is reachable from HEAD. Tests cover the four bug shapes (concurrent trains, mirror, all-non-ancestors, null commitSha) plus baseline + first-sync, and an end-to-end pairing with getCommitContextsBetweenShas that turns the LIN-69430 bug into a concrete reproduction.
1 parent 9f50f1e commit 31c4ec1

2 files changed

Lines changed: 228 additions & 0 deletions

File tree

src/base-sha.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { execSync } from "node:child_process";
2+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
6+
import { findBaseSha, type FindBaseShaDeps } from "./base-sha";
7+
import { commitExists, ensureCommitAvailable, getCommitContextsBetweenShas, isAncestor } from "./git";
8+
import type { Release } from "./types";
9+
10+
function runGit(args: string, cwd: string): string {
11+
return execSync(`git ${args}`, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
12+
}
13+
14+
function commit(cwd: string, file: string, content: string, message: string): string {
15+
writeFileSync(join(cwd, file), content);
16+
runGit("add .", cwd);
17+
runGit(`commit -qm "${message}"`, cwd);
18+
return runGit("rev-parse HEAD", cwd);
19+
}
20+
21+
function release(name: string, commitSha: string | undefined, daysAgoCreated: number): Release {
22+
return {
23+
id: `id-${name}`,
24+
name,
25+
commitSha,
26+
createdAt: new Date(Date.now() - daysAgoCreated * 24 * 60 * 60 * 1000).toISOString(),
27+
};
28+
}
29+
30+
/**
31+
* Topology shared by most scenarios:
32+
*
33+
* main: root ─ m1 ─ m2 ─ m3 ─ mainHead (HEAD when CI is on main)
34+
* │
35+
* └─ h1 ─ h2 (hotfix side branch)
36+
* └─ hotfixHead (HEAD when CI is on hotfix)
37+
*
38+
* - mainPrev = the "1.71.0" sha on main (m3 here, one before mainHead).
39+
* - hotfixSha = h2, the "1.70.1" release sha on the hotfix branch.
40+
* - mainHead = main's tip, used as HEAD for "CI on main" scenarios.
41+
* - hotfixHead = an extra commit on top of h2, used as HEAD for "CI on hotfix" scenarios.
42+
*
43+
* Side branches are kept alive (named refs) so the SHAs stay reachable in this
44+
* test repo regardless of GC; the walk doesn't care about branch names, only
45+
* ancestry from the HEAD it's given.
46+
*/
47+
function buildRepo() {
48+
const cwd = mkdtempSync(join(tmpdir(), "base-sha-"));
49+
runGit("init -q -b main", cwd);
50+
runGit('config user.email "t@t"', cwd);
51+
runGit('config user.name "t"', cwd);
52+
53+
commit(cwd, "f", "0", "root");
54+
const m1 = commit(cwd, "f", "1", "m1");
55+
56+
// Branch off m1 for the hotfix
57+
runGit(`checkout -q -b hotfix ${m1}`, cwd);
58+
commit(cwd, "f", "h1", "h1");
59+
const hotfixSha = commit(cwd, "f", "h2", "h2 (1.70.1 release)");
60+
const hotfixHead = commit(cwd, "f", "h3", "h3 (hotfix HEAD)");
61+
62+
// Back to main
63+
runGit("checkout -q main", cwd);
64+
commit(cwd, "f", "2", "m2");
65+
const mainPrev = commit(cwd, "f", "3", "m3 (1.71.0 release)");
66+
const mainHead = commit(cwd, "f", "4", "m4 (1.72.0 HEAD)");
67+
68+
return { cwd, hotfixSha, hotfixHead, mainPrev, mainHead };
69+
}
70+
71+
describe("findBaseSha", () => {
72+
let repo: ReturnType<typeof buildRepo>;
73+
let deps: FindBaseShaDeps;
74+
75+
beforeAll(() => {
76+
repo = buildRepo();
77+
deps = {
78+
isAncestor: (sha, head) => isAncestor(sha, head, repo.cwd),
79+
commitExists: (sha) => commitExists(sha, repo.cwd),
80+
ensureCommitAvailable: (sha) => ensureCommitAvailable(sha, repo.cwd),
81+
};
82+
});
83+
84+
afterAll(() => {
85+
if (repo) rmSync(repo.cwd, { recursive: true, force: true });
86+
});
87+
88+
it("scenario A — healthy single train: picks the only candidate", () => {
89+
const candidates = [release("1.71.0", repo.mainPrev, 5)];
90+
expect(findBaseSha(candidates, repo.mainHead, deps)).toEqual({ kind: "found", sha: repo.mainPrev });
91+
});
92+
93+
it("scenario B — concurrent trains, hotfix listed first: skips hotfix, picks main", () => {
94+
// The hotfix candidate sorts ahead of the main-train candidate, but its
95+
// commitSha sits on a side branch — not reachable from HEAD. Using it as
96+
// the base would scan a range covering everything the main train already
97+
// shipped between the fork point and HEAD. Walk past it to the main
98+
// release whose SHA is reachable.
99+
const candidates = [release("1.70.1", repo.hotfixSha, 3), release("1.71.0", repo.mainPrev, 10)];
100+
expect(findBaseSha(candidates, repo.mainHead, deps)).toEqual({ kind: "found", sha: repo.mainPrev });
101+
});
102+
103+
it("scenario C — CI on the hotfix branch, main listed first: skips main, picks hotfix", () => {
104+
// Mirror of scenario B: HEAD is on the hotfix branch and the main-train
105+
// candidate sorts first. The main SHA isn't reachable from the hotfix
106+
// HEAD, so the walk continues to the hotfix's own previous release.
107+
const candidates = [release("1.71.0", repo.mainPrev, 3), release("1.70.1", repo.hotfixSha, 10)];
108+
expect(findBaseSha(candidates, repo.hotfixHead, deps)).toEqual({ kind: "found", sha: repo.hotfixSha });
109+
});
110+
111+
it("scenario D — newly created release with null commitSha: skipped, walks to previous release", () => {
112+
// A release just created via the API has no commitSha until the first CI
113+
// sync writes one. Treating null as "no prior release" would under-cover
114+
// everything that landed since the actual previous release; the walk
115+
// skips the null entry and lands on the previous real release.
116+
const candidates = [release("1.72.0", undefined, 1), release("1.71.0", repo.mainPrev, 10)];
117+
expect(findBaseSha(candidates, repo.mainHead, deps)).toEqual({ kind: "found", sha: repo.mainPrev });
118+
});
119+
120+
it("scenario E — all candidates non-ancestors: returns fallback", () => {
121+
// Every candidate's commitSha lives on a history disjoint from HEAD —
122+
// shape produced by force-pushes that orphan old release SHAs, manual
123+
// edits, or stale rows the API hasn't pruned. The walk exhausts the list
124+
// and returns fallback so the caller can decide how to scan.
125+
const candidates = [release("1.70.1", repo.hotfixSha, 3), release("hotfix-tip", repo.hotfixHead, 1)];
126+
expect(findBaseSha(candidates, repo.mainHead, deps)).toEqual({ kind: "fallback" });
127+
});
128+
129+
it("scenario F — empty list (first-ever sync): returns fallback", () => {
130+
expect(findBaseSha([], repo.mainHead, deps)).toEqual({ kind: "fallback" });
131+
});
132+
});
133+
134+
/**
135+
* Pairs scenario B's base selection with the actual `git log` range
136+
* computation: instead of asserting only on the picked SHA, feed it into
137+
* `getCommitContextsBetweenShas` and check the resulting commit list. Makes
138+
* concrete why ancestor checking matters — a naive "use the first candidate"
139+
* pick produces a range that includes commits the main train already shipped,
140+
* while the walk's pick collapses the range to just the new bump.
141+
*/
142+
describe("end-to-end: concurrent trains", () => {
143+
let repo: ReturnType<typeof buildRepo>;
144+
let deps: FindBaseShaDeps;
145+
let candidates: Release[];
146+
147+
beforeAll(() => {
148+
repo = buildRepo();
149+
deps = {
150+
isAncestor: (sha, head) => isAncestor(sha, head, repo.cwd),
151+
commitExists: (sha) => commitExists(sha, repo.cwd),
152+
ensureCommitAvailable: (sha) => ensureCommitAvailable(sha, repo.cwd),
153+
};
154+
// Hotfix sorts ahead of the main-train release in the candidate list.
155+
candidates = [release("1.70.1", repo.hotfixSha, 3), release("1.71.0", repo.mainPrev, 10)];
156+
});
157+
158+
afterAll(() => {
159+
if (repo) rmSync(repo.cwd, { recursive: true, force: true });
160+
});
161+
162+
it("naive 'use first candidate' base scans commits already shipped via the main train", () => {
163+
const naiveBase = candidates[0]!.commitSha!;
164+
const range = getCommitContextsBetweenShas(naiveBase, repo.mainHead, { cwd: repo.cwd });
165+
const messages = range.map((c) => c.message?.split("\n")[0]).filter(Boolean);
166+
167+
// m2 and m3 belong to 1.71.0; only m4 is the 1.72.0 bump. Using the
168+
// hotfix SHA as base scans all three — exactly the re-attachment shape
169+
// we want to avoid.
170+
expect(messages).toEqual(["m4 (1.72.0 HEAD)", "m3 (1.71.0 release)", "m2"]);
171+
});
172+
173+
it("findBaseSha picks the main release; range collapses to just the new bump", () => {
174+
const result = findBaseSha(candidates, repo.mainHead, deps);
175+
expect(result).toEqual({ kind: "found", sha: repo.mainPrev });
176+
if (result.kind !== "found") return;
177+
178+
const range = getCommitContextsBetweenShas(result.sha, repo.mainHead, { cwd: repo.cwd });
179+
const messages = range.map((c) => c.message?.split("\n")[0]).filter(Boolean);
180+
181+
expect(messages).toEqual(["m4 (1.72.0 HEAD)"]);
182+
});
183+
});

src/base-sha.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { verbose } from "./log";
2+
import type { Release } from "./types";
3+
4+
export type BaseShaResult = { kind: "found"; sha: string } | { kind: "fallback" };
5+
6+
export type FindBaseShaDeps = {
7+
isAncestor: (sha: string, headSha: string) => boolean;
8+
commitExists: (sha: string) => boolean;
9+
ensureCommitAvailable: (sha: string) => void;
10+
};
11+
12+
/**
13+
* Picks the base SHA for `git log <base>..<HEAD>` from a list of recent
14+
* release candidates (most-relevant first). Returns the first candidate whose
15+
* `commitSha` is reachable from `headSha` — the API can't disambiguate
16+
* concurrent release trains via SQL alone, so we use git as ground truth.
17+
*
18+
* `commitExists` gates `ensureCommitAvailable` so a shallow clone doesn't pay
19+
* a `git fetch` per candidate when the SHAs are already local.
20+
*/
21+
export function findBaseSha(candidates: Release[], headSha: string, deps: FindBaseShaDeps): BaseShaResult {
22+
for (const candidate of candidates) {
23+
const sha = candidate.commitSha;
24+
if (!sha) {
25+
verbose(`findBaseSha: skipping ${candidate.name}: no commitSha`);
26+
continue;
27+
}
28+
if (!deps.commitExists(sha)) {
29+
try {
30+
deps.ensureCommitAvailable(sha);
31+
} catch (err) {
32+
const message = err instanceof Error ? err.message : String(err);
33+
verbose(`findBaseSha: skipping ${candidate.name} (${sha}): ${message}`);
34+
continue;
35+
}
36+
}
37+
if (!deps.isAncestor(sha, headSha)) {
38+
verbose(`findBaseSha: skipping ${candidate.name} (${sha}): not an ancestor of ${headSha}`);
39+
continue;
40+
}
41+
verbose(`findBaseSha: using ${candidate.name} (${sha})`);
42+
return { kind: "found", sha };
43+
}
44+
return { kind: "fallback" };
45+
}

0 commit comments

Comments
 (0)