fix(git-sync): gate stale-lock removal by mtime + cover preflight self-heals (#119 F1-F4)

clearStaleGitLocks() removed a hardcoded list of 9 git lock files
UNCONDITIONALLY — despite its name it never checked staleness. In a
multi-replica TTL-lapse window (a documented engine limitation), replica B's
preflight could rm a LIVE index.lock held by replica A mid `git add`/`commit`,
turning a safe fail-fast ("index.lock: File exists") into concurrent
index/ref writes and corruption.

F1: gate each removal by file age. A live git op is bounded by
GIT_EXEC_TIMEOUT_MS (120s), after which git is killed and the lock's mtime
freezes — so a lock older than 3× that (STALE_LOCK_MIN_AGE_MS = 360s) provably
has no live holder and is a genuine crash-leftover. Fresh locks (mtime within
the window) are preserved; missing locks are a no-op. Strictly safer than the
prior unconditional rm (the gate can only prevent removals, never add them).

F4: move clearStaleGitLocks (doc + body) below isMergeInProgress so the
mid-merge jsdoc re-attaches to its real owner; soften the doc (it clears a
fixed list of the engine's own index/ref locks, not "any *.lock").

F2: cycle.test.ts pins the preflight order
ensureRepo < clearStaleGitLocks < ensureMainBranch < ensureBranch (a refactor
that moved clearStaleGitLocks before ensureRepo — deleting ensureRepo's own
transient lock — would now fail the test).

F3: git.test.ts covers ensureMainBranch's HEAD-fallback branch (both main and
docmost gone → main recreated from HEAD) and the no-commit no-op.

Also: the D3-N3 test now backdates the planted lock's mtime so it is stale (and
still removed), plus a new test asserts a FRESH lock is PRESERVED (the
corruption-safety property — this would fail against the old code). cycle.ts's
preflight comment softened to match the mtime gating.

git-sync vitest: 711 passed | 1 expected-fail (+4 new tests). tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-07-03 08:19:04 +03:00
parent d218b3a39e
commit 3f7d96e09d
4 changed files with 153 additions and 30 deletions
+76 -3
View File
@@ -1,5 +1,5 @@
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, rm, writeFile, utimes } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
@@ -149,12 +149,18 @@ describe('VaultGit (integration; temp repo)', () => {
// Simulate an interrupted git op: a stale index.lock left behind. Git now
// refuses index-touching operations.
await writeFile(join(vault, '.git', 'index.lock'), '');
const lockPath = join(vault, '.git', 'index.lock');
await writeFile(lockPath, '');
// Backdate the lock beyond the staleness threshold so it is treated as a
// genuine crash-leftover (a live/fresh lock is preserved — see the test
// below); mtime gating only removes locks older than the threshold.
const anHourAgo = Date.now() / 1000 - 3600;
await utimes(lockPath, anHourAgo, anHourAgo);
await expect(
execFileAsync('git', ['add', '-A'], { cwd: vault }),
).rejects.toThrow(/index\.lock/);
// The preflight clears it (the daemon is the vault's sole writer, so it is stale).
// The preflight clears it (stale by mtime, no live git process holds it).
await git.clearStaleGitLocks();
// The lock is gone and git ops succeed again.
@@ -166,6 +172,26 @@ describe('VaultGit (integration; temp repo)', () => {
await expect(git.clearStaleGitLocks()).resolves.toBeUndefined();
});
it('clearStaleGitLocks PRESERVES a fresh index.lock (a concurrent replica may hold it) (bug D3-N3 / F1)', async () => {
if (!available) return;
const vault = await freshDir();
const git = new VaultGit(vault);
await git.ensureRepo();
// A FRESH lock (current mtime) — as a concurrently-running replica would
// hold mid `git add`/`commit` during the multi-replica TTL-lapse window.
// Removing it would corrupt the index/refs, so it MUST be preserved.
const lockPath = join(vault, '.git', 'index.lock');
await writeFile(lockPath, '');
await git.clearStaleGitLocks();
// Still there: a fresh (possibly live) lock is never deleted.
await expect(
execFileAsync('git', ['add', '-A'], { cwd: vault }),
).rejects.toThrow(/index\.lock/);
});
it('ensureMainBranch restores a deleted main from the docmost mirror (bug D3-N1)', async () => {
if (!available) return;
const vault = await freshDir();
@@ -192,6 +218,53 @@ describe('VaultGit (integration; temp repo)', () => {
await expect(git.ensureMainBranch()).resolves.toBeUndefined();
});
it('ensureMainBranch restores a deleted main from HEAD when docmost is gone too (bug D3-N1)', async () => {
if (!available) return;
const vault = await freshDir();
const git = new VaultGit(vault);
await git.ensureRepo();
// The reachable HEAD commit that main should be restored to.
const { stdout: headSha } = await execFileAsync(
'git',
['rev-parse', 'HEAD'],
{ cwd: vault },
);
const expectedSha = headSha.trim();
// Ref damage: BOTH main AND docmost are gone, only HEAD/a commit is
// reachable. Detach HEAD (so we can delete main), then delete both branches.
await execFileAsync('git', ['checkout', '--detach', 'HEAD'], { cwd: vault });
await git.ensureBranch('docmost', 'main');
await execFileAsync('git', ['branch', '-D', 'main', 'docmost'], {
cwd: vault,
});
expect(await git.branchExists('main')).toBe(false);
expect(await git.branchExists('docmost')).toBe(false);
// The preflight re-creates main from the HEAD commit.
await git.ensureMainBranch();
expect(await git.branchExists('main')).toBe(true);
const { stdout: restored } = await execFileAsync(
'git',
['rev-parse', 'main'],
{ cwd: vault },
);
expect(restored.trim()).toBe(expectedSha);
});
it('ensureMainBranch is a no-op on a repo with no commit at all (bug D3-N1)', async () => {
if (!available) return;
const vault = await freshDir();
// A bare `git init` — no commit, so no branch/HEAD to restore from.
await execFileAsync('git', ['init'], { cwd: vault });
const git = new VaultGit(vault);
// Nothing to do (ensureRepo's fresh-init path owns this case); must not throw.
await expect(git.ensureMainBranch()).resolves.toBeUndefined();
expect(await git.branchExists('main')).toBe(false);
});
it('ensureRepo neutralizes correctness-affecting LOCAL config', async () => {
if (!available) return;
const vault = await freshDir();