feat(FN-4830): complete Step 1 — add stale lock helper

Fusion-Task-Id: FN-4830
Fusion-Task-Lineage: d9b8ad72-669f-488e-8e85-be2dce9b8341
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 20:06:23 -07:00
committed by gsxdsm
parent a11bd0719e
commit 9e2a2e37d0
2 changed files with 263 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
import { mkdtemp, mkdir, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { classifyStaleLock, parseIndexLockPath, tryRemoveStaleLock } from "../worktree-stale-lock.js";
const { execMock } = vi.hoisted(() => {
const mock = vi.fn();
(mock as any)[Symbol.for("nodejs.util.promisify.custom")] = mock;
return { execMock: mock };
});
vi.mock("node:child_process", () => ({ exec: execMock }));
describe("worktree-stale-lock", () => {
beforeEach(() => {
execMock.mockReset();
});
it("parses worktree index.lock and main .git/index.lock errors", () => {
expect(
parseIndexLockPath("fatal: unable to create '/repo/.git/worktrees/fresh-oak/index.lock': File exists."),
).toBe("/repo/.git/worktrees/fresh-oak/index.lock");
expect(parseIndexLockPath("fatal: unable to create '.git/index.lock': File exists.")).toBe(".git/index.lock");
});
it("classifies young locks as fresh", async () => {
const root = await mkdtemp(resolve(tmpdir(), "fn-4830-"));
const lockPath = resolve(root, ".git/worktrees/fresh-oak/index.lock");
await mkdir(resolve(root, ".git/worktrees/fresh-oak"), { recursive: true });
await writeFile(lockPath, "lock", "utf-8");
const result = await classifyStaleLock({ rootDir: root, lockPath, minAgeMs: 30_000, now: () => Date.now() });
expect(result.kind).toBe("fresh");
await rm(root, { recursive: true, force: true });
});
it("classifies active-session match", async () => {
const root = await mkdtemp(resolve(tmpdir(), "fn-4830-"));
const worktreePath = resolve(root, ".worktrees/fresh-oak");
const lockDir = resolve(root, ".git/worktrees/fresh-oak");
const lockPath = resolve(lockDir, "index.lock");
await mkdir(lockDir, { recursive: true });
await mkdir(worktreePath, { recursive: true });
await writeFile(resolve(lockDir, "gitdir"), `${worktreePath}/.git\n`, "utf-8");
await writeFile(lockPath, "lock", "utf-8");
const old = new Date(Date.now() - 60_000);
await utimes(lockPath, old, old);
const result = await classifyStaleLock({
rootDir: root,
lockPath,
activeSessionRegistry: {
lookupByPath: (p) => (p === worktreePath ? ({ taskId: "FN-1" } as const) : null),
},
});
expect(result.kind).toBe("active-session");
expect(result.owningWorktreePath).toBe(worktreePath);
await rm(root, { recursive: true, force: true });
});
it("classifies missing lock file", async () => {
const root = await mkdtemp(resolve(tmpdir(), "fn-4830-"));
const lockPath = resolve(root, ".git/worktrees/fresh-oak/index.lock");
const result = await classifyStaleLock({ rootDir: root, lockPath });
expect(result).toMatchObject({ kind: "missing" });
await rm(root, { recursive: true, force: true });
});
it("classifies stale when old and no owner", async () => {
const root = await mkdtemp(resolve(tmpdir(), "fn-4830-"));
const lockPath = resolve(root, ".git/worktrees/fresh-oak/index.lock");
await mkdir(resolve(root, ".git/worktrees/fresh-oak"), { recursive: true });
await writeFile(lockPath, "lock", "utf-8");
const old = new Date(Date.now() - 60_000);
await utimes(lockPath, old, old);
execMock.mockResolvedValue({ stdout: "worktree /repo/.worktrees/other\n\n", stderr: "" });
const result = await classifyStaleLock({ rootDir: root, lockPath, minAgeMs: 30_000 });
expect(result.kind).toBe("stale");
await rm(root, { recursive: true, force: true });
});
it("tryRemoveStaleLock handles ENOENT and successful delete", async () => {
const root = await mkdtemp(resolve(tmpdir(), "fn-4830-"));
const lockPath = resolve(root, "index.lock");
await writeFile(lockPath, "lock", "utf-8");
await expect(tryRemoveStaleLock({ lockPath })).resolves.toEqual({ removed: true });
await expect(tryRemoveStaleLock({ lockPath })).resolves.toEqual({ removed: false, reason: "already-missing" });
await rm(root, { recursive: true, force: true });
});
});