| … | … | @@ -251,15 +251,39 @@ module.exports = class HyperSync extends ReadyResource { |
| 251 | 251 | } |
| 252 | 252 | } |
| 253 | 253 | |
| 254 | if (synced) { |
| 255 | // fsck — even if fetch claimed success, the object graph might |
| 256 | // be broken (refs point at objects that aren't in any local |
| 257 | // pack, because previous failed fetches left stale refs or |
| 258 | // the force-refspec updated refs without their full closure). |
| 259 | // Plain `git fetch` is a no-op in this state (refs match |
| 260 | // remote), so corruption stays forever unless we escalate. |
| 261 | // Check fsck, and if broken, try `git fetch --refetch` — |
| 262 | // git's own primitive for "redownload everything". |
| 263 | const fsckBroken = await this._fsckMissingObjects(repoPath); |
| 264 | if (fsckBroken) { |
| 265 | this.emit('pull', remoteName, name, 'refetch'); |
| 266 | try { |
| 267 | this._cleanTmpPacks(packDir); |
| 268 | await this._gitRefetch(repoPath, fetchUrl); |
| 269 | // Re-check after refetch. If still broken, fall through |
| 270 | // to force-reclone below. |
| 271 | const stillBroken = await this._fsckMissingObjects(repoPath); |
| 272 | if (stillBroken) synced = false; |
| 273 | } catch (err) { |
| 274 | synced = false; |
| 275 | lastErr = err; |
| 276 | } |
| 277 | } |
| 278 | } |
| 279 | |
| 254 | 280 | if (synced) { |
| 255 | 281 | result.synced.push(name); |
| 256 | 282 | this.emit('pull', remoteName, name, 'complete'); |
| 257 | 283 | } else { |
| 258 | | // One last resort: if we arrived stuck or got stuck, wipe |
| 259 | | // and try one more fresh clone. This catches repos where the |
| 260 | | // on-disk state is wedged in a way git fetch can't recover |
| 261 | | // from. |
| 262 | | if (!wasStuck && this._isStuck(repoPath, packDir)) { |
| 284 | // Last resort — wipe + fresh clone. Catches repos where the |
| 285 | // on-disk state is wedged in a way --refetch can't fix either. |
| 286 | if (this._isStuck(repoPath, packDir) || !wasStuck) { |
| 263 | 287 | fs.rmSync(repoPath, { recursive: true, force: true }); |
| 264 | 288 | try { |
| 265 | 289 | await this._exec('git', ['init', '--bare', repoPath]); |
| … | … | @@ -284,6 +308,54 @@ module.exports = class HyperSync extends ReadyResource { |
| 284 | 308 | |
| 285 | 309 | // ── Helpers for pull recovery ───────────────────────────── |
| 286 | 310 | |
| 311 | /** |
| 312 | * Run `git fsck --full --no-dangling` and detect missing objects |
| 313 | * or "could not read" errors. Returns true if the object graph is |
| 314 | * broken. False means healthy (or fsck couldn't run — treated as |
| 315 | * healthy so we don't trigger refetch on every pull). |
| 316 | */ |
| 317 | _fsckMissingObjects (repoPath) { |
| 318 | return new Promise((resolve) => { |
| 319 | const proc = spawn('git', ['-C', repoPath, 'fsck', '--full', '--no-dangling'], { |
| 320 | stdio: ['ignore', 'pipe', 'pipe'] |
| 321 | }); |
| 322 | const out = []; |
| 323 | const err = []; |
| 324 | proc.stdout.on('data', (d) => out.push(d)); |
| 325 | proc.stderr.on('data', (d) => err.push(d)); |
| 326 | proc.on('exit', (code) => { |
| 327 | const combined = (Buffer.concat(out).toString() + Buffer.concat(err).toString()).toLowerCase(); |
| 328 | // git fsck uses 'missing', 'could not read', and 'broken link' |
| 329 | // for the cases we care about. Exit code != 0 is another |
| 330 | // signal but not always reliable (some warnings return 0). |
| 331 | const broken = /missing (blob|commit|tree|tag)|could not read|broken link/.test(combined); |
| 332 | resolve(broken); |
| 333 | }); |
| 334 | proc.on('error', () => resolve(false)); |
| 335 | }); |
| 336 | } |
| 337 | |
| 338 | /** |
| 339 | * `git fetch --refetch` — git's own primitive (>=2.41) for |
| 340 | * "redownload everything regardless of what I already have." |
| 341 | * Heavy but surgical. Only called when fsck detected missing |
| 342 | * objects and plain fetch would be a no-op. |
| 343 | */ |
| 344 | _gitRefetch (repoPath, fetchUrl) { |
| 345 | return new Promise((resolve, reject) => { |
| 346 | const proc = spawn('git', [ |
| 347 | '-C', repoPath, 'fetch', '--refetch', fetchUrl, '+refs/*:refs/*', '--no-tags' |
| 348 | ], { stdio: ['ignore', 'pipe', 'pipe'] }); |
| 349 | const errChunks = []; |
| 350 | proc.stderr.on('data', (d) => errChunks.push(d)); |
| 351 | proc.on('exit', (code) => { |
| 352 | if (code === 0) resolve(); |
| 353 | else reject(new Error('git fetch --refetch exit ' + code + ': ' + Buffer.concat(errChunks).toString('utf-8').trim())); |
| 354 | }); |
| 355 | proc.on('error', reject); |
| 356 | }); |
| 357 | } |
| 358 | |
| 287 | 359 | _cleanTmpPacks (packDir) { |
| 288 | 360 | try { |
| 289 | 361 | for (const f of fs.readdirSync(packDir)) { |