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:
committed by
gsxdsm
parent
a11bd0719e
commit
9e2a2e37d0
100
packages/engine/src/__tests__/worktree-stale-lock.test.ts
Normal file
100
packages/engine/src/__tests__/worktree-stale-lock.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
163
packages/engine/src/worktree-stale-lock.ts
Normal file
163
packages/engine/src/worktree-stale-lock.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { readFile, stat, unlink } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const GIT_TIMEOUT_MS = 10_000;
|
||||
const MAX_BUFFER = 1024 * 1024;
|
||||
const DEFAULT_MIN_AGE_MS = 30_000;
|
||||
|
||||
export class StaleWorktreeIndexLockError extends Error {
|
||||
readonly lockPath: string;
|
||||
readonly classification: Exclude<StaleLockClassification["kind"], "stale">;
|
||||
readonly reason: string;
|
||||
|
||||
constructor(input: {
|
||||
message: string;
|
||||
lockPath: string;
|
||||
classification: Exclude<StaleLockClassification["kind"], "stale">;
|
||||
reason: string;
|
||||
}) {
|
||||
super(input.message);
|
||||
this.name = "StaleWorktreeIndexLockError";
|
||||
this.lockPath = input.lockPath;
|
||||
this.classification = input.classification;
|
||||
this.reason = input.reason;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseIndexLockPath(stderr: string): string | null {
|
||||
const match = /unable to create ['"]([^'"]*index\.lock)['"]:\s*File exists/i.exec(stderr);
|
||||
if (!match) return null;
|
||||
return match[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function parseWorktreeNameFromLockPath(lockPath: string): string | null {
|
||||
const normalized = lockPath.replace(/\\/g, "/");
|
||||
const match = /\/worktrees\/([^/]+)\/index\.lock$/i.exec(normalized);
|
||||
if (!match) return null;
|
||||
return match[1] ?? null;
|
||||
}
|
||||
|
||||
function parseWorktreeListPorcelain(porcelain: string): string[] {
|
||||
return porcelain
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("worktree "))
|
||||
.map((line) => line.slice("worktree ".length).trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function resolveOwningWorktreePath(input: {
|
||||
rootDir: string;
|
||||
lockPath: string;
|
||||
}): Promise<string | undefined> {
|
||||
if (input.lockPath.endsWith("/.git/index.lock") || input.lockPath.endsWith("\\.git\\index.lock")) {
|
||||
return input.rootDir;
|
||||
}
|
||||
|
||||
const worktreeName = parseWorktreeNameFromLockPath(input.lockPath);
|
||||
if (!worktreeName) return undefined;
|
||||
|
||||
const gitdirFile = resolve(dirname(input.lockPath), "gitdir");
|
||||
try {
|
||||
const gitdirRef = (await readFile(gitdirFile, "utf-8")).trim();
|
||||
if (gitdirRef) {
|
||||
const resolvedGitdirRef = resolve(dirname(gitdirFile), gitdirRef);
|
||||
if (resolvedGitdirRef.endsWith("/.git") || resolvedGitdirRef.endsWith("\\.git")) {
|
||||
return dirname(resolvedGitdirRef);
|
||||
}
|
||||
return dirname(resolvedGitdirRef);
|
||||
}
|
||||
} catch {
|
||||
// Fallback to porcelain mapping.
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: input.rootDir,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: MAX_BUFFER,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
const candidates = parseWorktreeListPorcelain(stdout);
|
||||
for (const path of candidates) {
|
||||
if (path.replace(/\\/g, "/").endsWith(`/${worktreeName}`)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type StaleLockClassification = {
|
||||
kind: "stale" | "active-session" | "fresh" | "missing";
|
||||
reason: string;
|
||||
owningWorktreePath?: string;
|
||||
ageMs?: number;
|
||||
};
|
||||
|
||||
export async function classifyStaleLock(input: {
|
||||
rootDir: string;
|
||||
lockPath: string;
|
||||
minAgeMs?: number;
|
||||
now?: () => number;
|
||||
activeSessionRegistry?: { lookupByPath(p: string): { taskId: string } | null };
|
||||
}): Promise<StaleLockClassification> {
|
||||
const now = input.now ?? Date.now;
|
||||
const minAgeMs = input.minAgeMs ?? DEFAULT_MIN_AGE_MS;
|
||||
const normalizedLockPath = input.lockPath ? resolve(input.rootDir, input.lockPath) : null;
|
||||
if (!normalizedLockPath) {
|
||||
return { kind: "fresh", reason: "lock-path-unparseable" };
|
||||
}
|
||||
|
||||
let lockStat;
|
||||
try {
|
||||
lockStat = await stat(normalizedLockPath);
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err?.code === "ENOENT") {
|
||||
return { kind: "missing", reason: "lock-file-missing" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const ageMs = Math.max(0, now() - lockStat.mtimeMs);
|
||||
if (ageMs < minAgeMs) {
|
||||
return { kind: "fresh", reason: "lock-younger-than-threshold", ageMs };
|
||||
}
|
||||
|
||||
const owningWorktreePath = await resolveOwningWorktreePath({
|
||||
rootDir: input.rootDir,
|
||||
lockPath: normalizedLockPath,
|
||||
});
|
||||
|
||||
if (owningWorktreePath && input.activeSessionRegistry?.lookupByPath(owningWorktreePath)) {
|
||||
return {
|
||||
kind: "active-session",
|
||||
reason: "active-session-owns-worktree",
|
||||
owningWorktreePath,
|
||||
ageMs,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "stale",
|
||||
reason: "lock-older-than-threshold-no-active-session",
|
||||
owningWorktreePath,
|
||||
ageMs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function tryRemoveStaleLock(input: { lockPath: string }): Promise<{ removed: boolean; reason?: string }> {
|
||||
try {
|
||||
await unlink(input.lockPath);
|
||||
return { removed: true };
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err?.code === "ENOENT") {
|
||||
return { removed: false, reason: "already-missing" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user