Skip to content

Commit 9ff0789

Browse files
committed
Verify ancestor walkability on shallow clones in findBaseSha
1 parent 4e382c4 commit 9ff0789

4 files changed

Lines changed: 148 additions & 50 deletions

File tree

src/base-sha.test.ts

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { afterAll, beforeAll, describe, expect, it } from "vitest";
66
import { findBaseSha, type FindBaseShaDeps } from "./base-sha";
7-
import { commitExists, ensureCommitAvailable, getCommitContextsBetweenShas, isAncestor } from "./git";
7+
import { getCommitContextsBetweenShas, verifyAncestorReachable } from "./git";
88
import type { Release } from "./types";
99

1010
function runGit(args: string, cwd: string): string {
@@ -75,9 +75,7 @@ describe("findBaseSha", () => {
7575
beforeAll(() => {
7676
repo = buildRepo();
7777
deps = {
78-
isAncestor: (sha, head) => isAncestor(sha, head, repo.cwd),
79-
commitExists: (sha) => commitExists(sha, repo.cwd),
80-
ensureCommitAvailable: (sha) => ensureCommitAvailable(sha, repo.cwd),
78+
verifyAncestorReachable: (sha, head) => verifyAncestorReachable(sha, head, repo.cwd),
8179
};
8280
});
8381

@@ -131,6 +129,67 @@ describe("findBaseSha", () => {
131129
});
132130
});
133131

132+
/**
133+
* Same topology as scenario B but inside a true shallow clone of just HEAD
134+
* (depth=1, detached HEAD, default refspec — the shape actions/checkout@v4
135+
* produces for PR/tag/single-SHA builds). The trap: side-branch deepening
136+
* pulls A's object into the DB as B's boundary parent without extending
137+
* main's shallow graft, so `commitExists(A)` is true but A is still beyond
138+
* the walk horizon from C. See `verifyAncestorReachable` in `git.ts`.
139+
*/
140+
describe("findBaseSha — shallow clone with sibling release branch", () => {
141+
let origin: string;
142+
let ci: string;
143+
let A: string; // 1.52.0 on main, ~252 commits before HEAD
144+
let B: string; // 1.52.1 on release/1.52, NOT on main
145+
let C: string; // 1.53.0 HEAD on main
146+
let deps: FindBaseShaDeps;
147+
148+
beforeAll(() => {
149+
origin = mkdtempSync(join(tmpdir(), "lr-origin-shallow-"));
150+
runGit("init -q -b main", origin);
151+
runGit('config user.email "t@t"', origin);
152+
runGit('config user.name "t"', origin);
153+
commit(origin, "f", "0", "root");
154+
A = commit(origin, "f", "A", "A (1.52.0 release on main)");
155+
156+
runGit(`checkout -q -b release/1.52 ${A}`, origin);
157+
B = commit(origin, "f", "B", "B (1.52.1 hotfix on release/1.52)");
158+
159+
// 250 main commits past A so A sits beyond the first deepen step (200).
160+
runGit("checkout -q main", origin);
161+
for (let i = 0; i < 250; i++) commit(origin, `m${i}`, `${i}`, `main commit ${i}`);
162+
C = commit(origin, "f", "C", "C (1.53.0 HEAD on main)");
163+
164+
// file:// forces the wire protocol so --depth actually applies — local
165+
// paths hardlink the full pack. The default `+refs/heads/*` refspec stays
166+
// present so subsequent fetches pull all branches, including release/1.52.
167+
ci = mkdtempSync(join(tmpdir(), "lr-ci-shallow-"));
168+
runGit("init -q", ci);
169+
runGit(`remote add origin "file://${origin}"`, ci);
170+
runGit(`config --add remote.origin.fetch "+${C}:refs/remotes/pull/0"`, ci);
171+
runGit(`fetch --no-tags --prune --depth=1 origin "+${C}:refs/remotes/pull/0"`, ci);
172+
runGit(`checkout --force ${C}`, ci);
173+
174+
deps = {
175+
verifyAncestorReachable: (sha, head) => verifyAncestorReachable(sha, head, ci),
176+
};
177+
});
178+
179+
afterAll(() => {
180+
if (origin) rmSync(origin, { recursive: true, force: true });
181+
if (ci) rmSync(ci, { recursive: true, force: true });
182+
});
183+
184+
it("picks main-train release A even though A's object was pulled in as a side-branch boundary", () => {
185+
const candidates = [
186+
release("1.52.1", B, 3), // side-branch candidate first (sorted by createdAt DESC)
187+
release("1.52.0", A, 10),
188+
];
189+
expect(findBaseSha(candidates, C, deps)).toEqual({ kind: "found", sha: A });
190+
});
191+
});
192+
134193
/**
135194
* Pairs scenario B's base selection with the actual `git log` range
136195
* computation: instead of asserting only on the picked SHA, feed it into
@@ -147,9 +206,7 @@ describe("end-to-end: concurrent trains", () => {
147206
beforeAll(() => {
148207
repo = buildRepo();
149208
deps = {
150-
isAncestor: (sha, head) => isAncestor(sha, head, repo.cwd),
151-
commitExists: (sha) => commitExists(sha, repo.cwd),
152-
ensureCommitAvailable: (sha) => ensureCommitAvailable(sha, repo.cwd),
209+
verifyAncestorReachable: (sha, head) => verifyAncestorReachable(sha, head, repo.cwd),
153210
};
154211
// Hotfix sorts ahead of the main-train release in the candidate list.
155212
candidates = [release("1.70.1", repo.hotfixSha, 3), release("1.71.0", repo.mainPrev, 10)];

src/base-sha.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,14 @@ import type { Release } from "./types";
44
export type BaseShaResult = { kind: "found"; sha: string } | { kind: "fallback" };
55

66
export type FindBaseShaDeps = {
7-
isAncestor: (sha: string, headSha: string) => boolean;
8-
commitExists: (sha: string) => boolean;
9-
ensureCommitAvailable: (sha: string) => void;
7+
verifyAncestorReachable: (sha: string, headSha: string) => boolean;
108
};
119

1210
/**
1311
* Picks the base SHA for `git log <base>..<HEAD>` from a list of recent
1412
* release candidates (most-relevant first). Returns the first candidate whose
1513
* `commitSha` is reachable from `headSha` — the API can't disambiguate
1614
* 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.
2015
*/
2116
export function findBaseSha(candidates: Release[], headSha: string, deps: FindBaseShaDeps): BaseShaResult {
2217
for (const candidate of candidates) {
@@ -25,16 +20,7 @@ export function findBaseSha(candidates: Release[], headSha: string, deps: FindBa
2520
verbose(`Skipping base SHA candidate "${candidate.name}": no commit SHA`);
2621
continue;
2722
}
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(`Skipping base SHA candidate "${candidate.name}" (${sha.slice(0, 7)}): ${message}`);
34-
continue;
35-
}
36-
}
37-
if (!deps.isAncestor(sha, headSha)) {
23+
if (!deps.verifyAncestorReachable(sha, headSha)) {
3824
verbose(
3925
`Skipping base SHA candidate "${candidate.name}" (${sha.slice(0, 7)}): not an ancestor of ${headSha.slice(0, 7)}`,
4026
);

src/git.ts

Lines changed: 80 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,11 @@ export function commitExists(sha: string, cwd: string = process.cwd()): boolean
171171
* Used to verify that a candidate base SHA is actually on HEAD's history before
172172
* we hand it to `git log <base>..<HEAD>` — a candidate from a side branch (e.g.
173173
* a hotfix release) will scan a wrong range otherwise.
174+
*
175+
* Caveat on shallow clones: `git merge-base --is-ancestor` exits 1 both when
176+
* `sha` is genuinely not an ancestor AND when the walk hits a shallow boundary
177+
* before reaching `sha`. Callers that need to disambiguate should use
178+
* `verifyAncestorReachable`, which deepens and retries on shallow cutoffs.
174179
*/
175180
export function isAncestor(sha: string, headSha: string, cwd: string = process.cwd()): boolean {
176181
try {
@@ -184,6 +189,78 @@ export function isAncestor(sha: string, headSha: string, cwd: string = process.c
184189
}
185190
}
186191

192+
/** True iff the repository at `cwd` is a shallow clone. */
193+
export function isShallowRepository(cwd: string = process.cwd()): boolean {
194+
try {
195+
const out = execSync("git rev-parse --is-shallow-repository", {
196+
cwd,
197+
encoding: "utf8",
198+
stdio: ["ignore", "pipe", "ignore"],
199+
});
200+
return out.trim() === "true";
201+
} catch {
202+
return false;
203+
}
204+
}
205+
206+
const DEEPEN_STRATEGIES = [
207+
{ command: "git fetch --deepen=200 origin", label: "Deepening by 200 commits" },
208+
{ command: "git fetch --deepen=500 origin", label: "Deepening by 500 commits" },
209+
{ command: "git fetch --unshallow origin", label: "Fetching full history" },
210+
];
211+
212+
function deepenUntil(cwd: string, check: () => boolean): boolean {
213+
for (const { command, label } of DEEPEN_STRATEGIES) {
214+
verbose(label);
215+
try {
216+
execSync(command, { cwd, stdio: ["ignore", "ignore", "pipe"], timeout: 30_000 });
217+
} catch (e) {
218+
const reason = e instanceof Error ? e.message : String(e);
219+
verbose(`Strategy "${label}" failed: ${reason}`);
220+
continue;
221+
}
222+
if (check()) {
223+
return true;
224+
}
225+
}
226+
return false;
227+
}
228+
229+
/**
230+
* Returns true iff `sha` is an ancestor of `headSha`, deepening a shallow clone
231+
* as needed to obtain a definitive answer.
232+
*
233+
* `isAncestor` alone isn't enough on shallow repos: `merge-base --is-ancestor`
234+
* exits 1 both for genuine non-ancestors and for walks that hit a shallow graft
235+
* before reaching `sha` — the two cases share an exit code. And `commitExists`
236+
* can return true for an object that was pulled in as a side-branch boundary
237+
* parent even when that commit isn't yet walkable from `headSha`. Disambiguate
238+
* by deepening and retrying.
239+
*/
240+
export function verifyAncestorReachable(sha: string, headSha: string, cwd: string = process.cwd()): boolean {
241+
if (sha === headSha) {
242+
return true;
243+
}
244+
245+
const reachable = () => commitExists(sha, cwd) && isAncestor(sha, headSha, cwd);
246+
247+
if (reachable()) {
248+
return true;
249+
}
250+
if (!isShallowRepository(cwd)) {
251+
// On a deep repo both negatives are definitive; no fetch can recover.
252+
return false;
253+
}
254+
255+
verbose(`Cannot confirm ancestry of ${sha.slice(0, 7)} from ${headSha.slice(0, 7)} on shallow repo; deepening`);
256+
257+
if (deepenUntil(cwd, reachable)) {
258+
verbose(`Confirmed ${sha.slice(0, 7)} is an ancestor of ${headSha.slice(0, 7)}`);
259+
return true;
260+
}
261+
return false;
262+
}
263+
187264
const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
188265

189266
/**
@@ -254,32 +331,11 @@ export function ensureCommitAvailable(sha: string, cwd: string = process.cwd()):
254331
return;
255332
}
256333

257-
const strategies = [
258-
{
259-
command: "git fetch --deepen=200 origin",
260-
label: "Deepening by 200 commits",
261-
},
262-
{
263-
command: "git fetch --deepen=500 origin",
264-
label: "Deepening by 500 commits",
265-
},
266-
{ command: "git fetch --unshallow origin", label: "Fetching full history" },
267-
];
268-
269334
verbose(`Commit ${sha} not in local history (likely shallow clone)`);
270335

271-
for (const { command, label } of strategies) {
272-
verbose(label);
273-
try {
274-
execSync(command, { cwd, stdio: ["ignore", "ignore", "pipe"], timeout: 30_000 });
275-
if (commitExists(sha, cwd)) {
276-
verbose(`Found commit ${sha}`);
277-
return;
278-
}
279-
} catch (e) {
280-
const reason = e instanceof Error ? e.message : String(e);
281-
verbose(`Strategy "${label}" failed: ${reason}`);
282-
}
336+
if (deepenUntil(cwd, () => commitExists(sha, cwd))) {
337+
verbose(`Found commit ${sha}`);
338+
return;
283339
}
284340

285341
const currentBranch = getCurrentGitInfo(cwd).branch ?? "unknown";

src/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { LinearClient, LinearClientOptions } from "@linear/sdk";
22
import {
33
assertGitAvailable,
4-
commitExists,
54
ensureCommitAvailable,
65
getCommitContextsBetweenShas,
76
getCurrentGitInfo,
87
getRepoInfo,
9-
isAncestor,
108
resolveFirstSyncBoundary,
9+
verifyAncestorReachable,
1110
} from "./git";
1211
import { findBaseSha } from "./base-sha";
1312
import { scanCommits } from "./scan";
@@ -342,7 +341,7 @@ async function getLatestSha(): Promise<string> {
342341
}
343342

344343
const candidates = await getRecentReleases();
345-
const result = findBaseSha(candidates, currentSha, { isAncestor, commitExists, ensureCommitAvailable });
344+
const result = findBaseSha(candidates, currentSha, { verifyAncestorReachable });
346345
if (result.kind === "found") {
347346
return result.sha;
348347
}

0 commit comments

Comments
 (0)