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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user