feat(FN-4830): complete Step 2 — backend stale-lock recovery
Fusion-Task-Id: FN-4830 Fusion-Task-Lineage: d9b8ad72-669f-488e-8e85-be2dce9b8341
This commit is contained in:
committed by
gsxdsm
parent
9e2a2e37d0
commit
753068482e
@@ -8,10 +8,17 @@ import {
|
||||
RemovalReason,
|
||||
} from "../worktree-backend.js";
|
||||
|
||||
const { execMock, accessMock, existsSyncMock } = vi.hoisted(() => {
|
||||
const { execMock, accessMock, existsSyncMock, parseIndexLockPathMock, classifyStaleLockMock, tryRemoveStaleLockMock } = vi.hoisted(() => {
|
||||
const mock = vi.fn();
|
||||
(mock as any)[Symbol.for("nodejs.util.promisify.custom")] = mock;
|
||||
return { execMock: mock, accessMock: vi.fn(), existsSyncMock: vi.fn() };
|
||||
return {
|
||||
execMock: mock,
|
||||
accessMock: vi.fn(),
|
||||
existsSyncMock: vi.fn(),
|
||||
parseIndexLockPathMock: vi.fn(),
|
||||
classifyStaleLockMock: vi.fn(),
|
||||
tryRemoveStaleLockMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({ exec: execMock }));
|
||||
@@ -20,6 +27,23 @@ vi.mock("node:fs/promises", () => ({ access: accessMock }));
|
||||
vi.mock("../branch-conflicts.js", () => ({
|
||||
inspectBranchConflict: vi.fn().mockResolvedValue({ kind: "stale" }),
|
||||
}));
|
||||
vi.mock("../worktree-stale-lock.js", () => ({
|
||||
StaleWorktreeIndexLockError: class StaleWorktreeIndexLockError extends Error {
|
||||
lockPath: string;
|
||||
classification: string;
|
||||
reason: string;
|
||||
constructor(input: { message: string; lockPath: string; classification: string; reason: string }) {
|
||||
super(input.message);
|
||||
this.name = "StaleWorktreeIndexLockError";
|
||||
this.lockPath = input.lockPath;
|
||||
this.classification = input.classification;
|
||||
this.reason = input.reason;
|
||||
}
|
||||
},
|
||||
parseIndexLockPath: parseIndexLockPathMock,
|
||||
classifyStaleLock: classifyStaleLockMock,
|
||||
tryRemoveStaleLock: tryRemoveStaleLockMock,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
execMock.mockReset();
|
||||
@@ -27,6 +51,12 @@ beforeEach(() => {
|
||||
existsSyncMock.mockReset();
|
||||
accessMock.mockResolvedValue(undefined);
|
||||
existsSyncMock.mockReturnValue(true);
|
||||
parseIndexLockPathMock.mockReset();
|
||||
classifyStaleLockMock.mockReset();
|
||||
tryRemoveStaleLockMock.mockReset();
|
||||
parseIndexLockPathMock.mockReturnValue(null);
|
||||
classifyStaleLockMock.mockResolvedValue({ kind: "fresh", reason: "fresh" });
|
||||
tryRemoveStaleLockMock.mockResolvedValue({ removed: true });
|
||||
});
|
||||
|
||||
describe("NativeWorktreeBackend", () => {
|
||||
@@ -115,6 +145,63 @@ describe("NativeWorktreeBackend", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves stale index.lock and retries create once", async () => {
|
||||
const audit = { git: vi.fn().mockResolvedValue(undefined) };
|
||||
parseIndexLockPathMock.mockReturnValue("/repo/.git/worktrees/fn-1/index.lock");
|
||||
classifyStaleLockMock.mockResolvedValue({ kind: "stale", reason: "old-lock", ageMs: 60000 });
|
||||
tryRemoveStaleLockMock.mockResolvedValue({ removed: true });
|
||||
execMock
|
||||
.mockRejectedValueOnce({ message: "fatal", stderr: "fatal: unable to create '/repo/.git/worktrees/fn-1/index.lock': File exists" })
|
||||
.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
|
||||
const result = await new NativeWorktreeBackend({ audit }).create({
|
||||
rootDir: "/repo",
|
||||
worktreePath: "/repo/.worktrees/fn-1",
|
||||
branch: "fusion/fn-1",
|
||||
taskId: "FN-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ path: "/repo/.worktrees/fn-1", branch: "fusion/fn-1" });
|
||||
expect(tryRemoveStaleLockMock).toHaveBeenCalledWith({ lockPath: "/repo/.git/worktrees/fn-1/index.lock" });
|
||||
expect(audit.git).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ type: "worktree:stale-lock-detected" }),
|
||||
);
|
||||
expect(audit.git).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ type: "worktree:stale-lock-recovered" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws StaleWorktreeIndexLockError when lock is non-stale", async () => {
|
||||
const audit = { git: vi.fn().mockResolvedValue(undefined) };
|
||||
parseIndexLockPathMock.mockReturnValue("/repo/.git/worktrees/fn-1/index.lock");
|
||||
classifyStaleLockMock.mockResolvedValue({ kind: "fresh", reason: "lock-younger-than-threshold", ageMs: 1000 });
|
||||
execMock.mockRejectedValueOnce({
|
||||
message: "fatal",
|
||||
stderr: "fatal: unable to create '/repo/.git/worktrees/fn-1/index.lock': File exists",
|
||||
});
|
||||
|
||||
await expect(
|
||||
new NativeWorktreeBackend({ audit }).create({
|
||||
rootDir: "/repo",
|
||||
worktreePath: "/repo/.worktrees/fn-1",
|
||||
branch: "fusion/fn-1",
|
||||
taskId: "FN-1",
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "StaleWorktreeIndexLockError" });
|
||||
|
||||
expect(tryRemoveStaleLockMock).not.toHaveBeenCalled();
|
||||
expect(audit.git).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ type: "worktree:stale-lock-detected" }),
|
||||
);
|
||||
expect(audit.git).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ type: "worktree:stale-lock-refused" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves native worktree path via configured worktreesDir", async () => {
|
||||
const backend = new NativeWorktreeBackend({ settings: { worktreesDir: "../{repo}.worktrees" } as any });
|
||||
await expect(
|
||||
|
||||
Reference in New Issue
Block a user