From d218b3a39eef251fd068fb4399ec9b8b48cd51fa Mon Sep 17 00:00:00 2001 From: agent_qa Date: Fri, 3 Jul 2026 07:34:17 +0300 Subject: [PATCH] fix(git-sync): self-heal a missing 'main' branch that wedged a space (D3-N1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref-store damage (a deleted refs/heads/main, an interrupted ref update) can leave an existing vault repo without a 'main' branch. The cycle's ensureBranch('docmost','main') + checkout then throw every poll ("pathspec 'main' did not match"), wedging the space forever with no self-heal — ensureRepo only creates branches on a FRESH git init (found via web-test corruption charter, reproduced deterministically). Add VaultGit.ensureMainBranch() and call it in the cycle preflight (after clearStaleGitLocks, before the branch setup): if 'main' is missing, re-create it from the 'docmost' mirror branch (they track each other) else from HEAD. Same wedge-forever family as D3-N3. Verified on the stand: deleting refs/heads/main now self-heals (main restored, the edit reaches the vault, 0 pathspec errors) — was wedged forever. Unit test (real temp repo: delete main -> ensureMainBranch restores it from docmost). git-sync suite green (708). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/git-sync/src/engine/cycle.ts | 6 +++++ packages/git-sync/src/engine/git.ts | 23 ++++++++++++++++ packages/git-sync/test/cycle.test.ts | 1 + packages/git-sync/test/git.test.ts | 26 +++++++++++++++++++ .../git-sync/test/redteam-push-cycle.test.ts | 2 ++ 5 files changed, 58 insertions(+) diff --git a/packages/git-sync/src/engine/cycle.ts b/packages/git-sync/src/engine/cycle.ts index 68a039f3..c60068fc 100644 --- a/packages/git-sync/src/engine/cycle.ts +++ b/packages/git-sync/src/engine/cycle.ts @@ -125,6 +125,12 @@ export async function runCycle(deps: RunCycleDeps): Promise { // it before the merge check + any checkout/diff below. await vault.clearStaleGitLocks(); + // 1c. RESTORE a missing `main` branch (bug D3-N1). Ref-store damage can leave an + // existing repo without `main`; the ensureBranch("docmost","main") + checkout + // below would then throw every cycle ("pathspec 'main' did not match"), + // wedging the space forever. Re-create it from `docmost`/HEAD before use. + await vault.ensureMainBranch(); + // 2. RECOVER from a vault left mid-merge by a PRIOR cycle (SPEC §9 wedge fix). // A leftover merge used to WEDGE THE WHOLE SPACE: this check returned // `skipped: "merge-in-progress"` so EVERY later cycle skipped the entire diff --git a/packages/git-sync/src/engine/git.ts b/packages/git-sync/src/engine/git.ts index df5477fc..105dc661 100644 --- a/packages/git-sync/src/engine/git.ts +++ b/packages/git-sync/src/engine/git.ts @@ -298,6 +298,29 @@ export class VaultGit { await this.run(["branch", name, fromBranch]); } + /** + * Re-create a MISSING `main` branch (bug D3-N1). Ref-store damage (a deleted + * `refs/heads/main`, a bad ref update) can leave an existing repo without + * `main`. Every cycle then throws (`ensureBranch("docmost","main")` / + * `checkout main` -> "pathspec 'main' did not match"), wedging the space + * FOREVER with no self-heal — `ensureRepo` only creates branches on a FRESH + * `git init`. Restore `main` in the preflight from the best available source: + * the `docmost` mirror branch if present (they track each other), else the + * current `HEAD` commit. If the repo has no commit at all, ensureRepo's + * fresh-init path owns it — nothing to do here. + */ + async ensureMainBranch(): Promise { + if (await this.branchExists(DEFAULT_BRANCH)) return; + if (await this.branchExists("docmost")) { + await this.run(["branch", DEFAULT_BRANCH, "docmost"]); + return; + } + const head = await this.runRaw(["rev-parse", "--verify", "--quiet", "HEAD"]); + if (head.code === 0 && head.stdout.trim().length > 0) { + await this.run(["branch", DEFAULT_BRANCH, head.stdout.trim()]); + } + } + /** Name of the currently checked-out branch. */ async currentBranch(): Promise { return this.run(["rev-parse", "--abbrev-ref", "HEAD"]); diff --git a/packages/git-sync/test/cycle.test.ts b/packages/git-sync/test/cycle.test.ts index de7e91cb..5706905a 100644 --- a/packages/git-sync/test/cycle.test.ts +++ b/packages/git-sync/test/cycle.test.ts @@ -17,6 +17,7 @@ function fakeVault(overrides: Record = {}) { assertGitAvailable: rec("assertGitAvailable"), ensureRepo: rec("ensureRepo"), clearStaleGitLocks: rec("clearStaleGitLocks"), + ensureMainBranch: rec("ensureMainBranch"), isMergeInProgress: vi.fn(async () => false), ensureBranch: rec("ensureBranch"), checkout: rec("checkout"), diff --git a/packages/git-sync/test/git.test.ts b/packages/git-sync/test/git.test.ts index cbeaec51..e4b7b918 100644 --- a/packages/git-sync/test/git.test.ts +++ b/packages/git-sync/test/git.test.ts @@ -166,6 +166,32 @@ describe('VaultGit (integration; temp repo)', () => { await expect(git.clearStaleGitLocks()).resolves.toBeUndefined(); }); + it('ensureMainBranch restores a deleted main from the docmost mirror (bug D3-N1)', async () => { + if (!available) return; + const vault = await freshDir(); + const git = new VaultGit(vault); + await git.ensureRepo(); + await git.ensureBranch('docmost', 'main'); + + // Ref damage: delete refs/heads/main (git refuses to delete the current + // branch, so move HEAD to docmost first — simulating a lost main ref). + await execFileAsync('git', ['symbolic-ref', 'HEAD', 'refs/heads/docmost'], { + cwd: vault, + }); + await execFileAsync('git', ['branch', '-D', 'main'], { cwd: vault }); + await expect( + execFileAsync('git', ['rev-parse', '--verify', 'main'], { cwd: vault }), + ).rejects.toThrow(); + + // The preflight re-creates main (from docmost). + await git.ensureMainBranch(); + await expect( + execFileAsync('git', ['rev-parse', '--verify', 'main'], { cwd: vault }), + ).resolves.toBeDefined(); + // Idempotent when main already exists. + await expect(git.ensureMainBranch()).resolves.toBeUndefined(); + }); + it('ensureRepo neutralizes correctness-affecting LOCAL config', async () => { if (!available) return; const vault = await freshDir(); diff --git a/packages/git-sync/test/redteam-push-cycle.test.ts b/packages/git-sync/test/redteam-push-cycle.test.ts index b7758b60..c8b0bfe2 100644 --- a/packages/git-sync/test/redteam-push-cycle.test.ts +++ b/packages/git-sync/test/redteam-push-cycle.test.ts @@ -41,6 +41,7 @@ function makePushGit(opts: { assertGitAvailable: vi.fn(async () => {}), ensureRepo: vi.fn(async () => {}), clearStaleGitLocks: vi.fn(async () => {}), + ensureMainBranch: vi.fn(async () => {}), isMergeInProgress: vi.fn(async () => false), // NO merge in progress checkout: vi.fn(async () => {}), stageAll: vi.fn(async () => {}), @@ -344,6 +345,7 @@ function fakeVault(overrides: Record = {}) { assertGitAvailable: rec('assertGitAvailable'), ensureRepo: rec('ensureRepo'), clearStaleGitLocks: rec('clearStaleGitLocks'), + ensureMainBranch: rec('ensureMainBranch'), isMergeInProgress: vi.fn(async () => false), ensureBranch: rec('ensureBranch'), checkout: rec('checkout'),