From a10eeab10c50bdfec6aa869c8ade709aa68b87ad Mon Sep 17 00:00:00 2001 From: masashiono0611 Date: Sun, 19 Jul 2026 01:58:43 +0900 Subject: [PATCH] fix(sync): dry-run no-write contract, unborn-HEAD recovery, no-gpg-sign (#2964) Codex review on the initial self-heal patch (b1671ee) found 3 real gaps: - P1: the self-heal ran even under --dry-run, mutating the filesystem during what's documented as a preview-only command. Gated the whole self-heal (both discoverGitRoot and the headCommit read) on !opts.dryRun, same as the existing !opts.sourceId ownership check. - P2: if `git init` succeeded but the process died before the baseline commit landed, the next run's discoverGitRoot would succeed (`.git` exists) and skip recovery entirely, permanently wedging on "No commits in repo" forever. Added the same self-heal at the `git rev-parse HEAD` catch site, sharing a new createSyncBaselineCommit helper with the discoverGitRoot catch. - P2: the baseline commit inherited the operator's global commit.gpgSign, which can block headless cron/launchd runs on an unavailable signing agent/pinentry. Added --no-gpg-sign. Two new tests cover dry-run no-mutation and unborn-HEAD recovery. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NCVEvUVm15bFuKqskWgfNS --- src/commands/sync.ts | 68 ++++++++++++++++++++++------------ test/sync-git-autoinit.test.ts | 36 ++++++++++++++++++ 2 files changed, 81 insertions(+), 23 deletions(-) diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 4737501e8..d73319a81 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -943,6 +943,32 @@ export function discoverGitRoot(inputPath: string): string { } } +/** + * #2964: snapshot the CURRENT on-disk state of a gbrain-owned brain dir as + * a baseline commit — used both right after a self-healing `git init` (no + * `.git` at all) and to recover a repo left with `.git` but zero commits + * (an interrupted prior self-heal, or a `git init` from some other source + * that never got a first commit). Respects `.gitignore` (written first) so + * future incremental syncs diff against what's actually here rather than + * an empty tree — an empty initial commit would make every existing file + * look "added" again on the next sync, even though the full-sync pass that + * follows already imported them from disk directly. + * + * `--no-gpg-sign` + explicit `-c user.name/user.email`: this runs from a + * headless nightly cron/launchd invocation, which has no reason to have + * git signing/identity configured, and must not block on an unavailable + * signing agent or pinentry prompt. + */ +function createSyncBaselineCommit(repoPath: string, engineKind?: 'pglite' | 'postgres'): void { + manageGitignore(repoPath, engineKind); + git(repoPath, ['add', '-A']); + git( + repoPath, + ['commit', '--quiet', '--allow-empty', '--no-gpg-sign', '-m', 'gbrain: initial commit (auto-init by sync)'], + ['user.name=gbrain', 'user.email=gbrain@localhost'], + ); +} + /** * #774 NAV-1 TOCTOU: true only if filePath realpath-resolves inside gitRoot. * Guards symlink escape at the per-file level (a committed symlink whose @@ -1636,34 +1662,18 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise { + const { performSync } = await import('../src/commands/sync.ts'); + await expect( + performSync(engine, { + repoPath: dir, + dryRun: true, + noPull: true, + noEmbed: true, + full: true, + }), + ).rejects.toThrow(/git repository/i); + // The whole point of --dry-run is "preview only" — it must never git-init + // or commit on our behalf, even though we could self-heal. + expect(existsSync(join(dir, '.git'))).toBe(false); + }); + + test('unborn-HEAD recovery: a bare `git init` with zero commits (interrupted prior self-heal) still completes', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + const { execSync } = await import('child_process'); + // Simulate a self-heal that ran `git init` but died before the baseline + // commit landed (process killed, disk full, etc.) — `.git` exists so + // discoverGitRoot succeeds, but `git rev-parse HEAD` still fails. + execSync('git init -q', { cwd: dir }); + + const result = await performSync(engine, { + repoPath: dir, + noPull: true, + noEmbed: true, + full: true, + }); + + expect(result.status).toBe('first_sync'); + expect(result.added).toBe(2); + expect(execSync('git rev-parse HEAD', { cwd: dir }).toString().trim()).not.toBe(''); + }); });