fix(git-sync): self-heal a missing 'main' branch that wedged a space (D3-N1)

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 07:34:17 +03:00
parent f0778cb85a
commit d218b3a39e
5 changed files with 58 additions and 0 deletions
+6
View File
@@ -125,6 +125,12 @@ export async function runCycle(deps: RunCycleDeps): Promise<RunCycleResult> {
// 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
+23
View File
@@ -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<void> {
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<string> {
return this.run(["rev-parse", "--abbrev-ref", "HEAD"]);
+1
View File
@@ -17,6 +17,7 @@ function fakeVault(overrides: Record<string, any> = {}) {
assertGitAvailable: rec("assertGitAvailable"),
ensureRepo: rec("ensureRepo"),
clearStaleGitLocks: rec("clearStaleGitLocks"),
ensureMainBranch: rec("ensureMainBranch"),
isMergeInProgress: vi.fn(async () => false),
ensureBranch: rec("ensureBranch"),
checkout: rec("checkout"),
+26
View File
@@ -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();
@@ -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<string, any> = {}) {
assertGitAvailable: rec('assertGitAvailable'),
ensureRepo: rec('ensureRepo'),
clearStaleGitLocks: rec('clearStaleGitLocks'),
ensureMainBranch: rec('ensureMainBranch'),
isMergeInProgress: vi.fn(async () => false),
ensureBranch: rec('ensureBranch'),
checkout: rec('checkout'),