OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-sync commits / a324e94

pull: fsck + --refetch fallback before wipe
Fetch claims success as long as refs update, even when the local object graph is missing objects those refs transitively reference. Plain retry is a no-op (refs already match remote), so the repo stays broken forever — gc fails with "missing object X", serving git requests from this repo returns partial data. Before today's fix, the only recovery was to wipe the bare repo and reclone from scratch. New tier between fetch and wipe: 1. Normal fetch (+refs/*:refs/*, 3-attempt retry) — unchanged 2. NEW: on success, run `git fsck --full --no-dangling`. If it reports missing/unreadable objects: → `git fetch --refetch ...` (git 2.41+ primitive that forces redownload of all objects regardless of what we already have locally) Re-fsck after. If still broken, escalate. 3. Final fallback: wipe + init + fetch. Adds two helpers: _fsckMissingObjects, _gitRefetch. Emits 'refetch' lifecycle event so observers can see the tier engage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a324e94c1867944958a73ad3c9297b3cde9bb20c
by plankatron <fleeky@gmail.com> · 2026-04-22T00:59:37.000Z

Changed files (1) +77 -5

index.js +77 -5
……@@ -251,15 +251,39 @@ module.exports = class HyperSync extends ReadyResource {
251251 }
252252 }
253253
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
254280 if (synced) {
255281 result.synced.push(name);
256282 this.emit('pull', remoteName, name, 'complete');
257283 } 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) {
263287 fs.rmSync(repoPath, { recursive: true, force: true });
264288 try {
265289 await this._exec('git', ['init', '--bare', repoPath]);
……@@ -284,6 +308,54 @@ module.exports = class HyperSync extends ReadyResource {
284308
285309 // ── Helpers for pull recovery ─────────────────────────────
286310
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
287359 _cleanTmpPacks (packDir) {
288360 try {
289361 for (const f of fs.readdirSync(packDir)) {