Skip to content

Commit 3424f94

Browse files
authored
Offer to fetch more history when merge base fails in shallow clones (#136)
1 parent b67ac84 commit 3424f94

6 files changed

Lines changed: 338 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
## 1.20.0
22

33
* Add file filter functionality to tree view
4+
* Automatically offer to fetch more history when merge base cannot be found in shallow clones
5+
* Limit displayed diff entries to 10,000 to avoid performance issues
46

57
## 1.19.0
68

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "git-tree-compare",
33
"displayName": "Git Tree Compare",
44
"description": "Diff your worktree against a branch, tag, or commit in a tree -- especially useful for pull request preparation or merge preview",
5-
"version": "1.19.0",
5+
"version": "1.20.0",
66
"author": {
77
"name": "Maik Riechert",
88
"url": "https://github.com/letmaik"

src/deepenHelper.ts

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import * as path from 'path';
2+
import { promises as fs } from 'fs';
3+
4+
import { window, ProgressLocation } from 'vscode';
5+
import { Repository } from './git/git';
6+
import { Repository as GitAPIRepository } from './typings/git';
7+
import { getAbsGitCommonDir } from './gitHelper';
8+
9+
type LogFn = (msg: string) => void;
10+
11+
interface FetchTarget {
12+
remote: string;
13+
branch: string;
14+
estimatedDepth: number;
15+
}
16+
17+
const DEPTH_FLOOR = 1024;
18+
const DEPTH_STEPS = 4;
19+
const DEPTH_CAP = 1_000_000;
20+
21+
/**
22+
* If a merge base could not be determined and the repository is a shallow clone,
23+
* offers the user the option to fetch more history (and as a last resort, unshallow)
24+
* to resolve the merge base.
25+
*
26+
* Returns the discovered merge base, or undefined if the repository is not shallow,
27+
* the user declined, the user cancelled, or all attempts failed.
28+
*/
29+
export async function tryDeepenForMergeBase(
30+
repository: Repository,
31+
gitApiRepo: GitAPIRepository,
32+
headRef: string,
33+
headBranchName: string | undefined,
34+
baseRef: string,
35+
log: LogFn,
36+
): Promise<string | undefined> {
37+
const commonDir = await getAbsGitCommonDir(repository);
38+
const shallowBoundary = await readShallowBoundary(commonDir);
39+
if (!shallowBoundary) {
40+
// not a shallow clone; deepening cannot help
41+
return undefined;
42+
}
43+
log(`Repository is a shallow clone (${shallowBoundary.length} boundary commit(s))`);
44+
45+
const baseTarget = await resolveFetchTarget(repository, baseRef, log);
46+
if (baseTarget) {
47+
log(`Base ref "${baseRef}" resolved to fetchable target: ${baseTarget.remote}/${baseTarget.branch}`);
48+
} else {
49+
log(`Base ref "${baseRef}" could not be resolved to a fetchable target`);
50+
}
51+
const headTarget = headBranchName
52+
? await resolveFetchTarget(repository, headBranchName, log)
53+
: undefined;
54+
if (headBranchName) {
55+
if (headTarget) {
56+
log(`HEAD ref "${headBranchName}" resolved to fetchable target: ${headTarget.remote}/${headTarget.branch}`);
57+
} else {
58+
log(`HEAD ref "${headBranchName}" could not be resolved to a fetchable target`);
59+
}
60+
} else {
61+
log('HEAD is detached, skipping HEAD target resolution');
62+
}
63+
64+
if (!baseTarget && !headTarget) {
65+
log('Neither base nor HEAD ref maps to a fetchable remote; cannot deepen.');
66+
return undefined;
67+
}
68+
69+
const targets: FetchTarget[] = [];
70+
if (baseTarget) targets.push(baseTarget);
71+
if (headTarget && !sameTarget(headTarget, baseTarget)) targets.push(headTarget);
72+
73+
const action = 'Fetch more history';
74+
const choice = await window.showErrorMessage(
75+
`No merge base could be found between "${headRef}" and "${baseRef}". ` +
76+
`The repository is a shallow clone — fetching more history may resolve this.`,
77+
action);
78+
if (choice !== action) {
79+
return undefined;
80+
}
81+
82+
const headDepth = await estimateTargetDepths(repository, shallowBoundary, targets, log);
83+
// Sort shallowest first — that's the side most likely to need deepening.
84+
targets.sort((a, b) => a.estimatedDepth - b.estimatedDepth);
85+
// Schedule is based on the shallowest depth across all sides (including HEAD,
86+
// even if it's not a fetchable target), because the merge base can't be found
87+
// until the shallow side has enough history.
88+
const minDepth = Math.min(headDepth, ...targets.map(t => t.estimatedDepth));
89+
const schedule = buildSchedule(minDepth);
90+
log(`HEAD depth: ${headDepth}`);
91+
log(`Target depths: ${targets.map(t => `${t.remote}/${t.branch}=${t.estimatedDepth}`).join(', ')}`);
92+
log(`Min depth (for schedule): ${minDepth}`);
93+
log(`Deepening schedule: [${schedule.join(', ')}]`);
94+
95+
const found = await window.withProgress({
96+
location: ProgressLocation.Notification,
97+
title: 'Fetching more history',
98+
cancellable: true,
99+
}, async (progress, token) => {
100+
for (const depth of schedule) {
101+
if (token.isCancellationRequested) return undefined;
102+
for (const target of targets) {
103+
if (token.isCancellationRequested) return undefined;
104+
if (target.estimatedDepth >= depth) {
105+
log(`Skipping ${target.remote}/${target.branch} (estimated depth ${target.estimatedDepth} >= ${depth})`);
106+
continue;
107+
}
108+
progress.report({ message: `Fetching ${target.remote}/${target.branch} at depth ${depth}...` });
109+
try {
110+
log(`Fetching ${target.remote} ${target.branch} --depth=${depth}`);
111+
await gitApiRepo.fetch(target.remote, target.branch, depth);
112+
} catch (e: any) {
113+
log(`Fetch failed: ${e.message || e}`);
114+
// continue with the next target / depth
115+
}
116+
}
117+
if (token.isCancellationRequested) return undefined;
118+
const mb = await tryGetMergeBase(repository, headRef, baseRef, log);
119+
if (mb) return mb;
120+
}
121+
return undefined;
122+
});
123+
124+
if (found) return found;
125+
126+
// Last resort: offer to unshallow.
127+
const unshallow = 'Unshallow';
128+
const finalChoice = await window.showErrorMessage(
129+
`Still no merge base found between "${headRef}" and "${baseRef}". ` +
130+
`Fetch the full repository history?`,
131+
unshallow);
132+
if (finalChoice !== unshallow) {
133+
return undefined;
134+
}
135+
136+
return await window.withProgress({
137+
location: ProgressLocation.Notification,
138+
title: 'Unshallowing repository',
139+
cancellable: true,
140+
}, async (progress, token) => {
141+
progress.report({ message: 'Fetching full history...' });
142+
try {
143+
log('Unshallowing repository (git pull --unshallow)');
144+
await gitApiRepo.pull(true);
145+
} catch (e: any) {
146+
log(`Unshallow failed: ${e.message || e}`);
147+
return undefined;
148+
}
149+
if (token.isCancellationRequested) return undefined;
150+
return await tryGetMergeBase(repository, headRef, baseRef, log);
151+
});
152+
}
153+
154+
async function tryGetMergeBase(repository: Repository, ref1: string, ref2: string, log: LogFn): Promise<string | undefined> {
155+
try {
156+
const mb = await repository.getMergeBase(ref1, ref2);
157+
if (mb) {
158+
log(`Merge base found after deepening: ${mb}`);
159+
return mb;
160+
}
161+
} catch (e: any) {
162+
log(`getMergeBase still failing: ${e.message || e}`);
163+
}
164+
return undefined;
165+
}
166+
167+
/**
168+
* Reads .git/shallow (in the common gitdir) and returns the boundary commit hashes,
169+
* or undefined if the repository is not shallow.
170+
*/
171+
async function readShallowBoundary(commonDir: string): Promise<string[] | undefined> {
172+
const shallowPath = path.join(commonDir, 'shallow');
173+
let content: string;
174+
try {
175+
content = await fs.readFile(shallowPath, 'utf8');
176+
} catch (e: any) {
177+
if (e.code === 'ENOENT') return undefined;
178+
throw e;
179+
}
180+
const lines = content.split('\n').map(l => l.trim()).filter(l => l.length > 0);
181+
if (lines.length === 0) return undefined;
182+
return lines;
183+
}
184+
185+
/**
186+
* Resolves a ref to a (remote, branch) pair suitable for `git fetch <remote> <branch>`.
187+
* Handles:
188+
* - remote-tracking refs like "origin/main" or "origin/feature/foo"
189+
* - local branches with a configured upstream
190+
* Returns undefined for detached HEAD, local branches without upstream, or unknown refs.
191+
*/
192+
async function resolveFetchTarget(repository: Repository, ref: string, log: LogFn): Promise<FetchTarget | undefined> {
193+
let remotes: { name: string }[];
194+
try {
195+
remotes = await repository.getRemotes();
196+
} catch (e: any) {
197+
log(`Could not list remotes: ${e.message || e}`);
198+
return undefined;
199+
}
200+
if (remotes.length === 0) return undefined;
201+
202+
// Match against remote-tracking ref pattern: <remote>/<branch...>
203+
// Sort by name length descending so a remote named "origin/foo" would be matched
204+
// before "origin", although such names are unusual.
205+
const sorted = [...remotes].sort((a, b) => b.name.length - a.name.length);
206+
for (const r of sorted) {
207+
const prefix = r.name + '/';
208+
if (ref.startsWith(prefix)) {
209+
const branch = ref.substring(prefix.length);
210+
if (branch.length > 0) {
211+
return { remote: r.name, branch, estimatedDepth: 0 };
212+
}
213+
}
214+
}
215+
216+
// Try as local branch with upstream
217+
try {
218+
const branch = await repository.getBranch(ref);
219+
if (branch.upstream && branch.upstream.remote && branch.upstream.name) {
220+
return { remote: branch.upstream.remote, branch: branch.upstream.name, estimatedDepth: 0 };
221+
}
222+
} catch (e: any) {
223+
// not a branch, or no upstream; ignore
224+
}
225+
226+
// Last resort: speculatively assume the branch exists on a remote.
227+
// A failed fetch is handled gracefully (caught and logged).
228+
if (sorted.length > 0) {
229+
return { remote: sorted[sorted.length - 1].name, branch: ref, estimatedDepth: 0 };
230+
}
231+
return undefined;
232+
}
233+
234+
function sameTarget(a: FetchTarget, b: FetchTarget | undefined): boolean {
235+
return !!b && a.remote === b.remote && a.branch === b.branch;
236+
}
237+
238+
/**
239+
* Estimates the current shallow depth for each fetch target and stores it
240+
* on the target's `estimatedDepth` field. Also probes HEAD.
241+
* Best-effort: targets default to 0 on failure.
242+
* Returns the estimated HEAD depth.
243+
*/
244+
async function estimateTargetDepths(
245+
repository: Repository,
246+
boundary: string[],
247+
targets: FetchTarget[],
248+
log: LogFn,
249+
): Promise<number> {
250+
// Also probe HEAD to get the best estimate for the head-side target.
251+
const headDepth = await countCommitsToBoundary(repository, 'HEAD', boundary, log) ?? 0;
252+
for (const t of targets) {
253+
const refDepth = await countCommitsToBoundary(
254+
repository, `refs/remotes/${t.remote}/${t.branch}`, boundary, log) ?? 0;
255+
// For the head-side target, the remote-tracking ref may not exist yet
256+
// (e.g. local branch). Use HEAD depth as a better proxy in that case.
257+
t.estimatedDepth = Math.max(refDepth, refDepth === 0 ? headDepth : 0);
258+
}
259+
return headDepth;
260+
}
261+
262+
async function countCommitsToBoundary(repository: Repository, ref: string, boundary: string[], log: LogFn): Promise<number | undefined> {
263+
const args = ['rev-list', '--count', '--first-parent', ref, ...boundary.map(s => '^' + s)];
264+
try {
265+
const result = await repository.exec(args);
266+
const n = parseInt(result.stdout.trim(), 10);
267+
if (!isNaN(n)) return n;
268+
} catch (e: any) {
269+
log(`Could not count commits for ${ref}: ${e.message || e}`);
270+
}
271+
return undefined;
272+
}
273+
274+
function buildSchedule(startDepth: number): number[] {
275+
const schedule: number[] = [];
276+
let depth = Math.max(DEPTH_FLOOR, startDepth * 2);
277+
for (let i = 0; i < DEPTH_STEPS; i++) {
278+
depth = Math.min(depth, DEPTH_CAP);
279+
schedule.push(depth);
280+
if (depth >= DEPTH_CAP) break;
281+
depth *= 2;
282+
}
283+
return schedule;
284+
}

src/git/git.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,13 @@ export class Git {
531531
child.stdin!.end(options.input, 'utf8');
532532
}
533533

534+
const startTime = Date.now();
534535
const bufferResult = await exec(child, options.cancellationToken);
536+
const elapsedMs = Date.now() - startTime;
537+
538+
if (options.log !== false) {
539+
this.log(` [${elapsedMs}ms]\n`);
540+
}
535541

536542
if (options.log !== false && bufferResult.stderr.length > 0) {
537543
this.log(`${bufferResult.stderr}\n`);

0 commit comments

Comments
 (0)