Skip to content

Commit 0764025

Browse files
authored
Merge pull request #5411 from cardstack/fix-checkpoint-manager-flaky-head
fix: stop background git maintenance racing the checkpoint commit loop
2 parents 8dc8ebe + 3030d14 commit 0764025

1 file changed

Lines changed: 102 additions & 5 deletions

File tree

packages/boxel-cli/src/lib/checkpoint-manager.ts

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { spawn } from 'child_process';
1+
import { spawn, spawnSync } from 'child_process';
22
import * as fs from 'fs/promises';
3+
import { readFileSync } from 'fs';
34
import * as path from 'path';
45
import { isProtectedFile } from './realm-sync-base.ts';
56

@@ -612,11 +613,30 @@ export class CheckpointManager {
612613
);
613614
}
614615

616+
// Ephemeral checkpoint repos never benefit from git's background
617+
// housekeeping. Left enabled, a commit forks a detached
618+
// `git maintenance run --auto` that races the next command's ref/object
619+
// reads on the same repo, surfacing intermittently as
620+
// "fatal: could not parse HEAD" partway through a rapid commit loop. Passing
621+
// these per invocation (rather than writing them at init) applies them to
622+
// every repo however it is initialized and overrides any inherited
623+
// global/system git config.
624+
private static readonly gitConfigArgs = [
625+
'-c',
626+
'gc.auto=0',
627+
'-c',
628+
'maintenance.auto=false',
629+
];
630+
615631
private git(...args: string[]): Promise<string> {
616632
return new Promise((resolve, reject) => {
617-
const child = spawn('git', args, {
618-
cwd: this.gitDir,
619-
});
633+
const child = spawn(
634+
'git',
635+
[...CheckpointManager.gitConfigArgs, ...args],
636+
{
637+
cwd: this.gitDir,
638+
},
639+
);
620640

621641
let stdout = '';
622642
let stderr = '';
@@ -632,11 +652,88 @@ export class CheckpointManager {
632652

633653
child.on('close', (code) => {
634654
if (code !== 0 && !args.includes('status')) {
635-
reject(new Error(`git ${args.join(' ')} failed: ${stderr}`));
655+
reject(
656+
new Error(
657+
`git ${args.join(' ')} failed: ${stderr.trim()}` +
658+
this.describeHeadState(),
659+
),
660+
);
636661
return;
637662
}
638663
resolve(stdout);
639664
});
640665
});
641666
}
667+
668+
// Best-effort snapshot of what HEAD points to and whether that commit object
669+
// exists, to make a "could not parse HEAD" recurrence diagnosable from the CI
670+
// log alone. Only ever runs after a git command has already failed, so it
671+
// must never throw. `object=absent` means the store does not hold the commit
672+
// HEAD names — the crux of that failure; `object=present` points at a
673+
// ref/index problem instead; `object=unknown` if the existence check itself
674+
// could not run.
675+
private describeHeadState(): string {
676+
try {
677+
const gitInternalDir = path.join(this.gitDir, '.git');
678+
const head = readFileSync(
679+
path.join(gitInternalDir, 'HEAD'),
680+
'utf-8',
681+
).trim();
682+
let oid = head;
683+
if (head.startsWith('ref:')) {
684+
const ref = head.slice('ref:'.length).trim();
685+
oid = this.resolveRef(gitInternalDir, ref) ?? '(unresolved)';
686+
}
687+
let object = 'n/a';
688+
if (/^[0-9a-f]{40}$/.test(oid)) {
689+
object = this.objectExists(oid);
690+
}
691+
return ` [head-state: HEAD=${JSON.stringify(head)} oid=${oid} object=${object}]`;
692+
} catch (err) {
693+
return ` [head-state unavailable: ${(err as Error).message}]`;
694+
}
695+
}
696+
697+
// `git cat-file -e` is authoritative across both loose and packed storage,
698+
// unlike a bare loose-object file check — a commit may live in a pack rather
699+
// than as a loose object. Kept synchronous and bounded so the failure path
700+
// stays best-effort: a spawn error or timeout reads as "unknown" rather than
701+
// throwing.
702+
private objectExists(oid: string): 'present' | 'absent' | 'unknown' {
703+
try {
704+
const res = spawnSync(
705+
'git',
706+
[...CheckpointManager.gitConfigArgs, 'cat-file', '-e', oid],
707+
{ cwd: this.gitDir, timeout: 5000 },
708+
);
709+
if (res.error) {
710+
return 'unknown';
711+
}
712+
return res.status === 0 ? 'present' : 'absent';
713+
} catch {
714+
return 'unknown';
715+
}
716+
}
717+
718+
private resolveRef(gitInternalDir: string, ref: string): string | null {
719+
try {
720+
return readFileSync(path.join(gitInternalDir, ref), 'utf-8').trim();
721+
} catch {
722+
// Loose ref absent — fall back to packed-refs.
723+
try {
724+
const packed = readFileSync(
725+
path.join(gitInternalDir, 'packed-refs'),
726+
'utf-8',
727+
);
728+
for (const line of packed.split('\n')) {
729+
if (line.endsWith(` ${ref}`)) {
730+
return line.slice(0, 40);
731+
}
732+
}
733+
} catch {
734+
// No packed-refs file; nothing more to try.
735+
}
736+
return null;
737+
}
738+
}
642739
}

0 commit comments

Comments
 (0)