1- import { spawn } from 'child_process' ;
1+ import { spawn , spawnSync } from 'child_process' ;
22import * as fs from 'fs/promises' ;
3+ import { readFileSync } from 'fs' ;
34import * as path from 'path' ;
45import { 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 - 9 a - 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