chore(FN-4428): import dependency content from fusion/fn-4410
Squash-imported the working tree of fusion/fn-4410 as a single commit so this branch carries the dep's content without inheriting its individual commits. If the dep is later squash-merged to main, this commit's patch-id should match the merge and rebase cleanly. Fusion-Task-Id: FN-4428 Fusion-Task-Lineage: ea174f50-49ac-4105-b6cf-db3bab0984fd
This commit is contained in:
@@ -101,7 +101,7 @@ describe("branch-conflicts", () => {
|
||||
expect(result).toEqual({ kind: "stale-resolved" });
|
||||
});
|
||||
|
||||
it("returns a typed live conflict with stranded commits", async () => {
|
||||
it("returns reclaimable for same-task live conflicts with stranded commits", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
if (command === "git worktree prune") return Buffer.from("");
|
||||
@@ -117,6 +117,10 @@ describe("branch-conflicts", () => {
|
||||
if (command.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-4068'")) {
|
||||
return Buffer.from("aaa111\tPreserve prior fix\nbbb222\tAdd regression coverage\n");
|
||||
}
|
||||
if (command.includes("git log --format=%H%x00%s%x00%b 'main..fusion/fn-4068'")) {
|
||||
return Buffer.from("aaa111\u0000feat(FN-4068): preserve\u0000Fusion-Task-Id: FN-4068\u0000" +
|
||||
"bbb222\u0000fix(FN-4068): regression\u0000Fusion-Task-Id: FN-4068\u0000");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
@@ -128,22 +132,57 @@ describe("branch-conflicts", () => {
|
||||
startPoint: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("live");
|
||||
if (result.kind !== "live") {
|
||||
throw new Error("expected live conflict");
|
||||
expect(result.kind).toBe("reclaimable");
|
||||
if (result.kind !== "reclaimable") {
|
||||
throw new Error("expected reclaimable conflict");
|
||||
}
|
||||
expect(result.error).toBeInstanceOf(BranchConflictError);
|
||||
expect(result.error).toMatchObject({
|
||||
branchName: "fusion/fn-4068",
|
||||
conflictingWorktreePath: "/tmp/existing-wt",
|
||||
existingTipSha: "abc123def456",
|
||||
startPoint: "main",
|
||||
expect(result).toMatchObject({
|
||||
livePath: "/tmp/existing-wt",
|
||||
tipSha: "abc123def456",
|
||||
taskAttributedCommitCount: 2,
|
||||
});
|
||||
expect(result.error.strandedCommits).toEqual([
|
||||
expect(result.strandedCommits).toEqual([
|
||||
{ sha: "aaa111", subject: "Preserve prior fix" },
|
||||
{ sha: "bbb222", subject: "Add regression coverage" },
|
||||
]);
|
||||
expect(result.error.message).toContain("2 stranded commits since main");
|
||||
});
|
||||
|
||||
it("returns live-foreign with BranchConflictError for cross-task collisions", async () => {
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
const command = typeof cmd === "string" ? cmd : cmd[0];
|
||||
if (command === "git worktree prune") return Buffer.from("");
|
||||
if (command === "git worktree list --porcelain") {
|
||||
return Buffer.from(["worktree /tmp/existing-wt", "HEAD 2222222", "branch refs/heads/fusion/fn-4068", ""].join("\n"));
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'refs/heads/fusion/fn-4068^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (command.includes("git rev-parse --verify 'fusion/fn-4068^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (command.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-4068'")) {
|
||||
return Buffer.from("aaa111\tPreserve prior fix\n");
|
||||
}
|
||||
if (command.includes("git log --format=%H%x00%s%x00%b 'main..fusion/fn-4068'")) {
|
||||
return Buffer.from("aaa111\u0000feat(FN-9999): foreign\u0000Fusion-Task-Id: FN-9999\u0000");
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
const result = await inspectBranchConflict({
|
||||
repoDir: "/tmp/repo",
|
||||
branchName: "fusion/fn-4068",
|
||||
conflictingWorktreePath: "/tmp/existing-wt",
|
||||
requestingTaskId: "FN-4068",
|
||||
startPoint: "main",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("live-foreign");
|
||||
if (result.kind !== "live-foreign") {
|
||||
throw new Error("expected live-foreign conflict");
|
||||
}
|
||||
expect(result.error).toBeInstanceOf(BranchConflictError);
|
||||
expect(result.error.message).toContain("Run branch recovery");
|
||||
});
|
||||
|
||||
it("assertCleanBranchAtBase passes when no foreign task commits exist", async () => {
|
||||
|
||||
@@ -843,9 +843,11 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
});
|
||||
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValue({
|
||||
kind: "live",
|
||||
kind: "live-foreign",
|
||||
livePath: "/tmp/test/.worktrees/green-sage",
|
||||
error: conflictError,
|
||||
});
|
||||
vi.spyOn(executor as any, "cleanupConflictingWorktree").mockResolvedValue(false);
|
||||
|
||||
await (executor as any).handleBranchConflict(makeTask(), conflictError);
|
||||
await (executor as any).handleBranchConflict(makeTask(), conflictError);
|
||||
@@ -1196,14 +1198,12 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({ ...makeTask(), executionStartBranch: "fusion/fn-049" });
|
||||
|
||||
// Should log that we're trying a new path
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-050",
|
||||
expect.stringContaining("Conflicting worktree in use by active task, trying new path"),
|
||||
expect.any(String),
|
||||
expect.stringContaining("Removed foreign conflicting worktree and retrying"),
|
||||
"/tmp/test/.worktrees/green-sage",
|
||||
);
|
||||
// Should generate a new name
|
||||
expect(mockedGenerateWorktreeName).toHaveBeenCalledTimes(2);
|
||||
expect(mockedGenerateWorktreeName).toHaveBeenCalled();
|
||||
|
||||
const worktreeAddCalls = mockedExecSync.mock.calls
|
||||
.map((call) => String(call[0]))
|
||||
@@ -1215,13 +1215,7 @@ describe("TaskExecutor worktree recovery", () => {
|
||||
command.endsWith('"fusion/fn-049"'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
worktreeAddCalls.some(
|
||||
(command) =>
|
||||
command.includes('git worktree add -b "fusion/fn-050-2"') &&
|
||||
command.endsWith('"fusion/fn-050"'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("removes stale branch and retries when branch exists without worktree", async () => {
|
||||
@@ -1805,7 +1799,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
||||
);
|
||||
|
||||
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockResolvedValue("fusion/fn-064");
|
||||
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockResolvedValue({ branch: "fusion/fn-064", worktreePath: "/tmp/test/.worktrees/idle-wt", reclaimed: false });
|
||||
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
@@ -1828,7 +1822,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
"/tmp/test/.worktrees/idle-wt",
|
||||
"fusion/fn-064",
|
||||
"fusion/fn-063",
|
||||
{ allowSiblingBranchRename: false, repoDir: "/tmp/test" },
|
||||
{ allowSiblingBranchRename: false, repoDir: "/tmp/test", requestingTaskId: "FN-064" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1839,7 +1833,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
||||
);
|
||||
|
||||
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockResolvedValue("fusion/fn-065");
|
||||
const prepareSpy = vi.spyOn(pool, "prepareForTask").mockResolvedValue({ branch: "fusion/fn-065", worktreePath: "/tmp/test/.worktrees/idle-wt", reclaimed: false });
|
||||
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
@@ -1861,7 +1855,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
"/tmp/test/.worktrees/idle-wt",
|
||||
"fusion/fn-065",
|
||||
undefined,
|
||||
{ allowSiblingBranchRename: false, repoDir: "/tmp/test" },
|
||||
{ allowSiblingBranchRename: false, repoDir: "/tmp/test", requestingTaskId: "FN-065" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1873,7 +1867,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
|
||||
);
|
||||
|
||||
// Pool returns a suffixed branch name due to conflict
|
||||
vi.spyOn(pool, "prepareForTask").mockResolvedValue("fusion/fn-066-2");
|
||||
vi.spyOn(pool, "prepareForTask").mockResolvedValue({ branch: "fusion/fn-066-2", worktreePath: "/tmp/test/.worktrees/idle-wt", reclaimed: false });
|
||||
|
||||
const store = createMockStore();
|
||||
store.getSettings.mockResolvedValue({
|
||||
@@ -2170,8 +2164,6 @@ describe("TaskExecutor worktree pool integration", () => {
|
||||
"FN-020",
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
branch: "fusion/fn-020",
|
||||
worktree: "/tmp/test/.worktrees/existing-fn-020",
|
||||
paused: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -54,6 +54,7 @@ vi.mock("../worktree-pool.js", () => ({
|
||||
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
|
||||
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
||||
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
|
||||
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const { selfHealingLoggerMock } = vi.hoisted(() => ({
|
||||
@@ -74,13 +75,15 @@ import type { TaskStore, Settings, Task, AgentStore, Agent, NotificationProvider
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { scanOrphanedBranches } from "../worktree-pool.js";
|
||||
import { isUsableTaskWorktree, scanOrphanedBranches } from "../worktree-pool.js";
|
||||
import * as branchConflictModule from "../branch-conflicts.js";
|
||||
import { createLogger } from "../logger.js";
|
||||
import { NotificationService } from "../notification/notification-service.js";
|
||||
|
||||
const mockedExecSync = vi.mocked(execSync);
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedScanOrphanedBranches = vi.mocked(scanOrphanedBranches);
|
||||
const mockedIsUsableTaskWorktree = vi.mocked(isUsableTaskWorktree);
|
||||
const mockedCreateLogger = vi.mocked(createLogger);
|
||||
|
||||
type MockLogger = {
|
||||
@@ -5670,3 +5673,60 @@ describe("maintenance cycle concurrency", () => {
|
||||
await expect((manager as any).runMaintenance()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => {
|
||||
let store: TaskStore & EventEmitter;
|
||||
let manager: SelfHealingManager;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false } as any),
|
||||
});
|
||||
manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
mockedIsUsableTaskWorktree.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("reclaims stranded same-task branch conflicts", async () => {
|
||||
(store.listTasks as any)
|
||||
.mockResolvedValueOnce([{ id: "FN-500", checkedOutBy: null, branch: "fusion/fn-500", worktree: "/tmp/fn-500", lineageId: "lin-1" }])
|
||||
.mockResolvedValueOnce([]);
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
|
||||
kind: "reclaimable",
|
||||
livePath: "/tmp/fn-500",
|
||||
tipSha: "abc123def456",
|
||||
taskAttributedCommitCount: 2,
|
||||
strandedCommits: [{ sha: "abc123", subject: "work" }],
|
||||
} as any);
|
||||
|
||||
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
|
||||
expect(recovered).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-500", { worktree: "/tmp/fn-500", branch: "fusion/fn-500" });
|
||||
});
|
||||
|
||||
it("skips checked out tasks", async () => {
|
||||
(store.listTasks as any)
|
||||
.mockResolvedValueOnce([{ id: "FN-501", checkedOutBy: "agent-1", branch: "fusion/fn-501", worktree: "/tmp/fn-501" }])
|
||||
.mockResolvedValueOnce([]);
|
||||
const inspectSpy = vi.spyOn(branchConflictModule, "inspectBranchConflict");
|
||||
|
||||
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
|
||||
expect(recovered).toBe(0);
|
||||
expect(inspectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("escalates unrecoverable reclaim failures to in-review failed", async () => {
|
||||
(store.listTasks as any)
|
||||
.mockResolvedValueOnce([{ id: "FN-502", checkedOutBy: null, branch: "fusion/fn-502", worktree: "/tmp/fn-502" }])
|
||||
.mockResolvedValueOnce([]);
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const recovered = await manager.reclaimSelfOwnedBranchConflicts();
|
||||
expect(recovered).toBe(0);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-502", expect.objectContaining({
|
||||
status: "failed",
|
||||
paused: true,
|
||||
pausedReason: "branch-conflict-unrecoverable",
|
||||
}));
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-502", "in-review");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("acquireTaskWorktree", () => {
|
||||
});
|
||||
|
||||
it("acquires from pool when enabled", async () => {
|
||||
const prepareForTask = vi.fn().mockResolvedValue("fusion/fn-1");
|
||||
const prepareForTask = vi.fn().mockResolvedValue({ branch: "fusion/fn-1", worktreePath: "/tmp/pooled", reclaimed: false });
|
||||
const result = await acquireTaskWorktree({
|
||||
task,
|
||||
rootDir: process.cwd(),
|
||||
@@ -54,7 +54,12 @@ describe("acquireTaskWorktree", () => {
|
||||
createWorktree: vi.fn(),
|
||||
});
|
||||
expect(result.source).toBe("pool");
|
||||
expect(prepareForTask).toHaveBeenCalled();
|
||||
expect(prepareForTask).toHaveBeenCalledWith(
|
||||
"/tmp/pooled",
|
||||
"fusion/fn-1",
|
||||
undefined,
|
||||
expect.objectContaining({ requestingTaskId: "FN-1" }),
|
||||
);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { worktree: "/tmp/pooled", branch: "fusion/fn-1" });
|
||||
});
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
scanOrphanedBranches,
|
||||
} from "../worktree-pool.js";
|
||||
import { BranchConflictError } from "../branch-conflicts.js";
|
||||
import * as branchConflictModule from "../branch-conflicts.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
|
||||
import type { Task, Column } from "@fusion/core";
|
||||
@@ -184,7 +185,7 @@ describe("WorktreePool", () => {
|
||||
describe("prepareForTask", () => {
|
||||
it("returns the original branch name on success", async () => {
|
||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042");
|
||||
expect(result).toBe("fusion/fn-042");
|
||||
expect(result).toMatchObject({ branch: "fusion/fn-042", reclaimed: false, worktreePath: "/tmp/wt" });
|
||||
});
|
||||
|
||||
it("cleans dirty working tree before checkout", async () => {
|
||||
@@ -232,7 +233,7 @@ describe("WorktreePool", () => {
|
||||
});
|
||||
|
||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-001");
|
||||
expect(result).toBe("fusion/fn-001");
|
||||
expect(result).toMatchObject({ branch: "fusion/fn-001", reclaimed: false, worktreePath: "/tmp/wt" });
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[worktree-pool] git checkout -- . failed (may be clean): nothing to checkout"),
|
||||
@@ -255,13 +256,13 @@ describe("WorktreePool", () => {
|
||||
|
||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042");
|
||||
|
||||
expect(result).toBe("fusion/fn-042");
|
||||
expect(result).toMatchObject({ branch: "fusion/fn-042", reclaimed: false, worktreePath: "/tmp/wt" });
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[worktree-pool] git checkout -- . failed (may be clean): working tree already clean"),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws a typed branch conflict when the canonical branch is already live elsewhere by default", async () => {
|
||||
it("returns reclaimed result when branch is already live elsewhere for the same task", async () => {
|
||||
mockedExistsSync.mockImplementation((p) => {
|
||||
if (p === "/other/wt") return true;
|
||||
return true;
|
||||
@@ -293,9 +294,57 @@ describe("WorktreePool", () => {
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await expect(
|
||||
pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, { repoDir: "/tmp/repo" })
|
||||
).rejects.toBeInstanceOf(BranchConflictError);
|
||||
vi.spyOn(branchConflictModule, "inspectBranchConflict").mockResolvedValueOnce({
|
||||
kind: "reclaimable",
|
||||
livePath: "/other/wt",
|
||||
tipSha: "abc123def456",
|
||||
taskAttributedCommitCount: 1,
|
||||
strandedCommits: [{ sha: "aaa111", subject: "Preserve prior fix" }],
|
||||
});
|
||||
|
||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042", "main", {
|
||||
repoDir: "/tmp/repo",
|
||||
requestingTaskId: "FN-042",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
branch: "fusion/fn-042",
|
||||
worktreePath: "/other/wt",
|
||||
reclaimed: true,
|
||||
existingTipSha: "abc123def456",
|
||||
strandedCommitCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws BranchConflictError for cross-task live-foreign conflicts", async () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr === 'git checkout -B "fusion/fn-042" main') {
|
||||
const err: any = new Error("branch conflict");
|
||||
err.stderr = Buffer.from("fatal: 'fusion/fn-042' is already used by worktree at '/other/wt'");
|
||||
throw err;
|
||||
}
|
||||
if (cmdStr === "git worktree list --porcelain") {
|
||||
return Buffer.from(["worktree /other/wt", "HEAD 1111111", "branch refs/heads/fusion/fn-042", ""].join("\n"));
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042'")) {
|
||||
return Buffer.from("aaa111\tForeign fix\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --format=%H%x00%s%x00%b 'main..fusion/fn-042'")) {
|
||||
return Buffer.from("aaa111\tfeat(FN-999): foreign\x1fFusion-Task-Id: FN-999\n");
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await expect(pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, {
|
||||
repoDir: "/tmp/repo",
|
||||
requestingTaskId: "FN-042",
|
||||
})).rejects.toBeInstanceOf(BranchConflictError);
|
||||
|
||||
const checkoutCalls = mockedExecSync.mock.calls
|
||||
.map((c) => c[0])
|
||||
@@ -310,33 +359,20 @@ describe("WorktreePool", () => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr === 'git checkout -B "fusion/fn-042" fusion/fn-041') {
|
||||
const err: any = new Error("branch conflict");
|
||||
err.stderr = Buffer.from(
|
||||
"fatal: 'fusion/fn-042' is already used by worktree at '/other/wt'"
|
||||
);
|
||||
err.stderr = Buffer.from("fatal: 'fusion/fn-042' is already used by worktree at '/other/wt'");
|
||||
throw err;
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'fusion/fn-041..fusion/fn-042'")) {
|
||||
return Buffer.from("aaa111\tPreserve prior fix\n");
|
||||
if (cmdStr === "git worktree list --porcelain") {
|
||||
return Buffer.from(["worktree /other/wt", "HEAD 1111111", "branch refs/heads/fusion/fn-042", ""].join("\n"));
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) return Buffer.from("abc123def456\n");
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'fusion/fn-041..fusion/fn-042'")) return Buffer.from("aaa111\tPreserve prior fix\n");
|
||||
if (cmdStr.includes("git log --format=%H%x00%s%x00%b 'fusion/fn-041..fusion/fn-042'")) return Buffer.from("aaa111\u001ffeat(FN-999): foreign\u001fFusion-Task-Id: FN-999\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await pool.prepareForTask(
|
||||
"/tmp/wt",
|
||||
"fusion/fn-042",
|
||||
"fusion/fn-041",
|
||||
{ allowSiblingBranchRename: true, repoDir: "/tmp/repo" },
|
||||
);
|
||||
expect(result).toBe("fusion/fn-042-2");
|
||||
|
||||
const checkoutCalls = mockedExecSync.mock.calls
|
||||
.map((c) => c[0])
|
||||
.filter((c) => typeof c === "string" && c.includes("checkout -B"));
|
||||
expect(checkoutCalls).toContain('git checkout -B "fusion/fn-042-2" fusion/fn-042');
|
||||
expect(checkoutCalls).not.toContain('git checkout -B "fusion/fn-042-2" fusion/fn-041');
|
||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042", "fusion/fn-041", { allowSiblingBranchRename: true, repoDir: "/tmp/repo" });
|
||||
expect(result.branch).toBe("fusion/fn-042-2");
|
||||
});
|
||||
|
||||
it("increments suffix when lower suffixes are also in use in legacy rename mode", async () => {
|
||||
@@ -344,41 +380,22 @@ describe("WorktreePool", () => {
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.startsWith('git checkout -B "fusion/fn-042" ') ||
|
||||
cmdStr.startsWith('git checkout -B "fusion/fn-042-2" ')) {
|
||||
if (cmdStr.startsWith('git checkout -B "fusion/fn-042" ') || cmdStr.startsWith('git checkout -B "fusion/fn-042-2" ')) {
|
||||
const err: any = new Error("branch conflict");
|
||||
err.stderr = Buffer.from(
|
||||
`fatal: 'x' is already used by worktree at '/other/wt'`
|
||||
);
|
||||
err.stderr = Buffer.from("fatal: 'x' is already used by worktree at '/other/wt'");
|
||||
throw err;
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042'")) {
|
||||
return Buffer.from("aaa111\tPreserve prior fix\n");
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-2^{commit}'")) {
|
||||
return Buffer.from("bbb222ccc333\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-2'")) {
|
||||
return Buffer.from("bbb222\tFirst sibling\n");
|
||||
}
|
||||
if (cmdStr === "git worktree list --porcelain") return Buffer.from(["worktree /other/wt", "HEAD 1111111", "branch refs/heads/fusion/fn-042", ""].join("\n"));
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) return Buffer.from("abc123def456\n");
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042'")) return Buffer.from("aaa111\tPreserve prior fix\n");
|
||||
if (cmdStr.includes("git log --format=%H%x00%s%x00%b 'main..fusion/fn-042'")) return Buffer.from("aaa111\u001ffeat(FN-999): foreign\u001fFusion-Task-Id: FN-999\n");
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-2^{commit}'")) return Buffer.from("bbb222ccc333\n");
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-2'")) return Buffer.from("bbb222\tFirst sibling\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await pool.prepareForTask(
|
||||
"/tmp/wt",
|
||||
"fusion/fn-042",
|
||||
undefined,
|
||||
{ allowSiblingBranchRename: true, repoDir: "/tmp/repo" },
|
||||
);
|
||||
expect(result).toBe("fusion/fn-042-3");
|
||||
|
||||
const checkoutCalls = mockedExecSync.mock.calls
|
||||
.map((c) => c[0])
|
||||
.filter((c) => typeof c === "string" && c.includes("checkout -B"));
|
||||
expect(checkoutCalls).toContain('git checkout -B "fusion/fn-042-3" fusion/fn-042');
|
||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, { allowSiblingBranchRename: true, repoDir: "/tmp/repo" });
|
||||
expect(result.branch).toBe("fusion/fn-042-3");
|
||||
});
|
||||
|
||||
it("falls back to git worktree prune when conflicting worktree no longer exists on disk", async () => {
|
||||
@@ -406,7 +423,7 @@ describe("WorktreePool", () => {
|
||||
});
|
||||
|
||||
const result = await pool.prepareForTask("/tmp/wt", "fusion/fn-042");
|
||||
expect(result).toBe("fusion/fn-042");
|
||||
expect(result.branch).toBe("fusion/fn-042");
|
||||
|
||||
const cmds = mockedExecSync.mock.calls.map((c) => c[0]);
|
||||
expect(cmds).toContain("git worktree prune");
|
||||
@@ -429,52 +446,21 @@ describe("WorktreePool", () => {
|
||||
|
||||
it("throws when all suffixed names are exhausted in legacy rename mode", async () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("checkout -B")) {
|
||||
const err: any = new Error("branch conflict");
|
||||
err.stderr = Buffer.from(
|
||||
`fatal: 'x' is already used by worktree at '/other/wt'`
|
||||
);
|
||||
err.stderr = Buffer.from("fatal: 'x' is already used by worktree at '/other/wt'");
|
||||
throw err;
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) {
|
||||
return Buffer.from("abc123def456\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042'")) {
|
||||
return Buffer.from("aaa111\tPreserve prior fix\n");
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-2^{commit}'")) {
|
||||
return Buffer.from("bbb222ccc333\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-2'")) {
|
||||
return Buffer.from("bbb222\tFirst sibling\n");
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-3^{commit}'")) {
|
||||
return Buffer.from("ccc333ddd444\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-3'")) {
|
||||
return Buffer.from("ccc333\tSecond sibling\n");
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-4^{commit}'")) {
|
||||
return Buffer.from("ddd444eee555\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-4'")) {
|
||||
return Buffer.from("ddd444\tThird sibling\n");
|
||||
}
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042-5^{commit}'")) {
|
||||
return Buffer.from("eee555fff666\n");
|
||||
}
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042-5'")) {
|
||||
return Buffer.from("eee555\tFourth sibling\n");
|
||||
}
|
||||
if (cmdStr === "git worktree list --porcelain") return Buffer.from(["worktree /other/wt", "HEAD 1111111", "branch refs/heads/fusion/fn-042", ""].join("\n"));
|
||||
if (cmdStr.includes("git rev-parse --verify 'fusion/fn-042^{commit}'")) return Buffer.from("abc123def456\n");
|
||||
if (cmdStr.includes("git log --reverse --format=%H%x09%s 'main..fusion/fn-042'")) return Buffer.from("aaa111\tPreserve prior fix\n");
|
||||
if (cmdStr.includes("git log --format=%H%x00%s%x00%b 'main..fusion/fn-042'")) return Buffer.from("aaa111\u001ffeat(FN-999): foreign\u001fFusion-Task-Id: FN-999\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await expect(
|
||||
pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, { allowSiblingBranchRename: true, repoDir: "/tmp/repo" })
|
||||
).rejects.toThrow(/suffixes -2 through -6 are all in use/);
|
||||
await expect(pool.prepareForTask("/tmp/wt", "fusion/fn-042", undefined, { allowSiblingBranchRename: true, repoDir: "/tmp/repo" })).rejects.toThrow(/suffixes -2 through -6 are all in use/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user