Skip to content

Commit cdddf83

Browse files
committed
Fully fix iterative deepening for latest git
1 parent 2f9f17a commit cdddf83

5 files changed

Lines changed: 81 additions & 15 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"type": "patch",
5+
"comment": "Fully fix iterative deepening for latest git",
6+
"packageName": "beachball",
7+
"email": "elcraig@microsoft.com",
8+
"dependentChangeType": "patch"
9+
}
10+
]
11+
}

packages/beachball/src/__functional__/git/ensureSharedHistory.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,8 @@ describe('ensureSharedHistory', () => {
251251
expect(logs.mocks.warn).not.toHaveBeenCalled();
252252
expect(logs.mocks.error).not.toHaveBeenCalled();
253253

254-
const deepen = `fetch --deepen=2 origin ${defaultRefSpec}`;
254+
const testRefSpec = `+refs/heads/${testBranch}:refs/remotes/origin/${testBranch}`;
255+
const deepen = `fetch --deepen=2 origin ${defaultRefSpec} ${testRefSpec}`;
255256
expect(filteredGitCalls()).toEqual([`fetch --depth=2 origin ${defaultRefSpec}`, deepen, deepen, deepen]);
256257
});
257258

@@ -283,7 +284,8 @@ describe('ensureSharedHistory', () => {
283284
expect(logs.mocks.warn).not.toHaveBeenCalled();
284285
expect(logs.mocks.error).not.toHaveBeenCalled();
285286

286-
const deepen = `fetch --deepen=2 origin ${defaultRefSpec}`;
287+
const testRefSpec = `+refs/heads/${testBranch}:refs/remotes/origin/${testBranch}`;
288+
const deepen = `fetch --deepen=2 origin ${defaultRefSpec} ${testRefSpec}`;
287289
expect(filteredGitCalls()).toEqual([`fetch --depth=2 origin ${defaultRefSpec}`, deepen, deepen, deepen]);
288290
});
289291

@@ -301,13 +303,14 @@ describe('ensureSharedHistory', () => {
301303
});
302304

303305
expect(logs.getMockLines('all')).toMatch("Still didn't find a common commit after deepening by 3. Unshallowing...");
304-
const deepen = `fetch --deepen=1 origin ${defaultRefSpec}`;
306+
const testRefSpec = `+refs/heads/${testBranch}:refs/remotes/origin/${testBranch}`;
307+
const deepen = `fetch --deepen=1 origin ${defaultRefSpec} ${testRefSpec}`;
305308
expect(filteredGitCalls()).toEqual([
306309
`fetch --depth=1 origin ${defaultRefSpec}`,
307310
deepen,
308311
deepen,
309312
deepen,
310-
`fetch --unshallow origin ${defaultRefSpec}`,
313+
`fetch --unshallow origin ${defaultRefSpec} ${testRefSpec}`,
311314
]);
312315
});
313316

packages/beachball/src/__functional__/git/fetch.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,35 @@ describe('gitFetch', () => {
154154
);
155155
});
156156

157+
it('fetches multiple branches in a single invocation', () => {
158+
// Multiple refspecs let one --deepen / --unshallow cover both refs in a single network
159+
// round-trip — used by ensureSharedHistory when it has to deepen both HEAD and the target.
160+
gitOverride = noOpSuccess;
161+
const otherBranch = 'feature';
162+
const res = gitFetch({
163+
cwd: repo.rootPath,
164+
remote: defaultRemoteName,
165+
branch: [defaultBranchName, otherBranch],
166+
deepen: 5,
167+
verbose: true,
168+
});
169+
170+
const refspec1 = `+refs/heads/${defaultBranchName}:refs/remotes/${defaultRemoteName}/${defaultBranchName}`;
171+
const refspec2 = `+refs/heads/${otherBranch}:refs/remotes/${defaultRemoteName}/${otherBranch}`;
172+
expect(gitSpy).toHaveBeenCalledWith(
173+
['fetch', '--deepen=5', defaultRemoteName, refspec1, refspec2],
174+
expect.anything()
175+
);
176+
expect(res).toMatchObject({ success: true });
177+
expect(logs.mocks.log).toHaveBeenCalledWith(
178+
`Fetching branches "${defaultBranchName}", "${otherBranch}" from remote "${defaultRemoteName}" ` +
179+
`(${refspec1} ${refspec2}) (with --deepen=5)...`
180+
);
181+
expect(logs.mocks.log).toHaveBeenCalledWith(
182+
`Fetching branches "${defaultBranchName}", "${otherBranch}" from remote "${defaultRemoteName}" completed successfully`
183+
);
184+
});
185+
157186
it('preserves the tracking ref after a real fetch', () => {
158187
// With a bare branch name like 'master' as the refspec source, git can fail to resolve it
159188
// on the remote and treat it as absent, pruning refs/remotes/origin/master (exit code 0).

packages/beachball/src/git/ensureSharedHistory.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,19 @@ function deepenHistory(params: {
110110

111111
console.log(`This is a shallow clone. Deepening to check for changes...`);
112112

113+
// git fetch --deepen only deepens the histories of the refs explicitly listed in the
114+
// refspec args, not other local refs. To find the common ancestor, both the target branch
115+
// and HEAD (when on a different branch) need enough history to reach it. We pass both
116+
// refspecs to a single git invocation so one --deepen / --unshallow covers both refs in one
117+
// network round-trip.
118+
const headBranch = getHeadBranch(cwd);
119+
const branchesToFetch = headBranch && headBranch !== remoteBranch ? [remoteBranch, headBranch] : [remoteBranch];
120+
113121
// Iteratively deepen the history
114122
const maxAttempts = 3;
115123
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
116124
console.log(`Deepening by ${depth} more commits (attempt ${attempt}/${maxAttempts})...`);
117-
const result = gitFetch({ remote, branch: remoteBranch, deepen: depth, cwd, verbose });
125+
const result = gitFetch({ remote, branch: branchesToFetch, deepen: depth, cwd, verbose });
118126
if (!result.success) {
119127
throw new BeachballError(`Failed to fetch more history (see above for details)`);
120128
}
@@ -130,7 +138,7 @@ function deepenHistory(params: {
130138

131139
// No common commit was found and the repo is still shallow, so fully unshallow it
132140
console.log(`Still didn't find a common commit after deepening by ${depth * maxAttempts}. Unshallowing...`);
133-
const result = gitFetch({ remote, branch: remoteBranch, unshallow: true, cwd, verbose });
141+
const result = gitFetch({ remote, branch: branchesToFetch, unshallow: true, cwd, verbose });
134142
if (!result.success) {
135143
throw new BeachballError(`Failed to unshallow repo (see above for details)`);
136144
}
@@ -187,6 +195,13 @@ function hasBranchRef(branch: string, cwd: string): boolean {
187195
return git(['rev-parse', '--verify', branch], { cwd }).success;
188196
}
189197

198+
/** Returns the current branch name, or undefined if in detached HEAD state */
199+
function getHeadBranch(cwd: string): string | undefined {
200+
const result = git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
201+
const branch = result.stdout.trim();
202+
return result.success && branch !== 'HEAD' ? branch : undefined;
203+
}
204+
190205
function isShallowRepository(cwd: string): boolean {
191206
return git(['rev-parse', '--is-shallow-repository'], { cwd }).stdout.trim() === 'true';
192207
}

packages/beachball/src/git/fetch.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@ type GitFetchParams = {
99
*/
1010
remote: string;
1111
/**
12-
* Branch to fetch. It will be converted to a full refspec for fetching:
13-
* e.g. `branch: 'main', remote: 'origin'` will be converted to `+main:refs/remotes/origin/main`.
12+
* Branch(es) to fetch. Each will be converted to a full refspec for fetching:
13+
* e.g. `branch: 'main', remote: 'origin'` will be converted to `+refs/heads/main:refs/remotes/origin/main`.
14+
* Pass an array to fetch multiple branches in a single git invocation, which lets a single
15+
* `--deepen` or `--unshallow` apply to all of them (saves network round-trips when both HEAD
16+
* and the target branch need deepening).
1417
*/
15-
branch: string;
18+
branch: string | string[];
1619
/** Set depth to this number of commits (mutually exclusive with `deepen` and `unshallow`) */
1720
depth?: number;
1821
/** Deepen a shallow clone by this number of commits (mutually exclusive with `depth` and `unshallow`) */
@@ -31,7 +34,8 @@ type GitFetchParams = {
3134
* the remote branch is tracked or not in the local repository.
3235
*/
3336
export function gitFetch(params: GitFetchParams): GitProcessOutput & { errorMessage?: string } {
34-
const { remote, branch, depth, deepen, unshallow, cwd, verbose } = params;
37+
const { remote, depth, deepen, unshallow, cwd, verbose } = params;
38+
const branches = Array.isArray(params.branch) ? params.branch : [params.branch];
3539
const { shouldLog } = getGitEnv(verbose);
3640

3741
if ([depth, deepen, unshallow].filter(v => v !== undefined).length > 1) {
@@ -40,20 +44,24 @@ export function gitFetch(params: GitFetchParams): GitProcessOutput & { errorMess
4044

4145
const extraArgs = depth ? [`--depth=${depth}`] : deepen ? [`--deepen=${deepen}`] : unshallow ? ['--unshallow'] : [];
4246

43-
// Be specific with the ref being fetched, so we don't have to worry about tracking configs.
47+
// Be specific with each ref being fetched, so we don't have to worry about tracking configs.
4448
// In git fetch <remote> +<src>:<dst>...
4549
// - The + means allow non-fast-forward updates (in case the remote was force pushed).
4650
// - <src> refs/heads/${branch} is resolved against the remote's advertised refs. The fully
4751
// qualified ref is unambiguous, whereas bare branch names can be silently misresolved,
4852
// causing git to treat the ref as absent and delete the local tracking ref.
4953
// - <dst> refs/remotes/${remote}/${branch} is resolved locally and only moves the tracking ref
5054
// for the remote branch, not the local refs/heads/${branch} or its tracking config.
51-
const resolvedBranch = remote ? `+refs/heads/${branch}:refs/remotes/${remote}/${branch}` : undefined;
55+
const resolvedRefspecs = remote ? branches.map(b => `+refs/heads/${b}:refs/remotes/${remote}/${b}`) : [];
5256

53-
const shortDescription = `Fetching ${resolvedBranch ? `branch "${branch}" from remote "${remote}"` : 'all remotes'}`;
57+
const branchLabel =
58+
branches.length > 1 ? `branches ${branches.map(b => `"${b}"`).join(', ')}` : `branch "${branches[0]}"`;
59+
const shortDescription = `Fetching ${
60+
resolvedRefspecs.length ? `${branchLabel} from remote "${remote}"` : 'all remotes'
61+
}`;
5462

5563
let description = shortDescription;
56-
resolvedBranch && (description += ` (${resolvedBranch})`);
64+
resolvedRefspecs.length && (description += ` (${resolvedRefspecs.join(' ')})`);
5765
extraArgs.length && (description += ` (with ${extraArgs.join(' ')})`);
5866
shouldLog && console.log(description + '...');
5967

@@ -62,7 +70,7 @@ export function gitFetch(params: GitFetchParams): GitProcessOutput & { errorMess
6270
'fetch',
6371
...extraArgs,
6472
// If the remote is unknown, don't specify the branch (fetching a branch without a remote is invalid)
65-
...(resolvedBranch ? [remote, resolvedBranch] : []),
73+
...(resolvedRefspecs.length ? [remote, ...resolvedRefspecs] : []),
6674
],
6775
{ cwd, stdio: shouldLog === 'live' ? 'inherit' : 'pipe' }
6876
);

0 commit comments

Comments
 (0)