|
| 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 | +} |
0 commit comments