@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
44import { join } from "node:path" ;
55import { afterAll , beforeAll , describe , expect , it } from "vitest" ;
66import { findBaseSha , type FindBaseShaDeps } from "./base-sha" ;
7- import { commitExists , ensureCommitAvailable , getCommitContextsBetweenShas , isAncestor } from "./git" ;
7+ import { getCommitContextsBetweenShas , verifyAncestorReachable } from "./git" ;
88import type { Release } from "./types" ;
99
1010function 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 ) ] ;
0 commit comments