feat(FN-4623): complete Step 6/7 — verification fixes, docs, and changeset
Fusion-Task-Id: FN-4623 Fusion-Task-Lineage: 58122853-cab7-4102-9649-4ceda4c5959e
This commit is contained in:
committed by
gsxdsm
parent
81b2f4ea8f
commit
3d9260e0a8
5
.changeset/fn-4623-worktrunk-delegation.md
Normal file
5
.changeset/fn-4623-worktrunk-delegation.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Complete worktrunk backend delegation for create/sync/prune/remove plus worktrunk-aware worktree layout resolution when `worktrunk.enabled` is on.
|
||||||
@@ -645,7 +645,12 @@ Guardrails: this routine does **not** retry merges, does **not** apply to mixed/
|
|||||||
|
|
||||||
### Worktree and naming helpers
|
### Worktree and naming helpers
|
||||||
- `WorktreePool` (`worktree-pool.ts`) — idle worktree reuse
|
- `WorktreePool` (`worktree-pool.ts`) — idle worktree reuse
|
||||||
- `WorktreeBackend` (`worktree-backend.ts`) — abstraction for worktree operations used by `acquireTaskWorktree`. `native` (default) preserves existing `git worktree` behavior (including sibling-branch retry semantics), while `resolveWorktreeBackend(settings)` selects `worktrunk` when `settings.worktrunk?.enabled === true`. `worktrunk.onFailure` controls fail-hard vs fallback-native create behavior and emits `worktree:worktrunk-*` run-audit events for create/fallback paths.
|
- `WorktreeBackend` (`worktree-backend.ts`) — abstraction for worktree operations used by `acquireTaskWorktree`. `native` (default) preserves existing `git worktree` behavior (including sibling-branch retry semantics), while `resolveWorktreeBackend(settings)` selects `worktrunk` when `settings.worktrunk?.enabled === true`.
|
||||||
|
- Worktrunk path delegates five decisions with per-op timeouts: `create` (120s), `sync` (180s), `prune` (60s), `remove` (60s), and layout resolution (5s).
|
||||||
|
- Direct worktrunk CLI delegates: `create` → `wt switch --create ... --no-hooks --no-cd`, `remove` → `wt remove --foreground`.
|
||||||
|
- Worktrunk-aware fallback implementations where worktrunk lacks a dedicated primitive: `sync` uses git fetch+rebase semantics, and `prune` uses `git worktree list --porcelain` plus per-branch `remove` calls.
|
||||||
|
- Layout precedence: when `worktrunk.enabled=true`, `resolveTaskWorktreePathForBackend(...)` defers to backend `resolveWorktreePath(...)` (using `wt config show --format json` template data with default `{{ repo_path }}/.worktrees/{{ branch | sanitize }}` fallback); otherwise it remains byte-identical to FN-4606 `resolveTaskWorktreePath(...)` behavior.
|
||||||
|
- `worktrunk.onFailure` controls fail-hard vs fallback-native create behavior and emits `worktree:worktrunk-*` run-audit events for create/fallback paths.
|
||||||
- `WorktreeNames` (`worktree-names.ts`) — deterministic worktree/branch naming
|
- `WorktreeNames` (`worktree-names.ts`) — deterministic worktree/branch naming
|
||||||
|
|
||||||
### Observability and reflection
|
### Observability and reflection
|
||||||
|
|||||||
@@ -67,10 +67,7 @@ describe("acquireTaskWorktree backend wiring", () => {
|
|||||||
audit,
|
audit,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(execMock).toHaveBeenCalledWith(
|
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"worktrunk" "switch" "--create" "fusion/fn-1"'))).toBe(true);
|
||||||
'"worktrunk" "switch" "--create" "fusion/fn-1"',
|
|
||||||
expect.objectContaining({ cwd: "/repo" }),
|
|
||||||
);
|
|
||||||
expect(audit.git).toHaveBeenCalledWith(
|
expect(audit.git).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: "worktree:worktrunk-create",
|
type: "worktree:worktrunk-create",
|
||||||
@@ -124,6 +121,7 @@ describe("acquireTaskWorktree backend wiring", () => {
|
|||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
sync: vi.fn().mockResolvedValue({ skipped: true as const }),
|
sync: vi.fn().mockResolvedValue({ skipped: true as const }),
|
||||||
prune: vi.fn(),
|
prune: vi.fn(),
|
||||||
|
resolveWorktreePath: vi.fn().mockResolvedValue("/tmp/custom-path"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await acquireTaskWorktree({
|
const result = await acquireTaskWorktree({
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ describe("acquireTaskWorktree worktrunk wiring", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(createWorktree).toHaveBeenCalledTimes(1);
|
expect(createWorktree).toHaveBeenCalledTimes(1);
|
||||||
expect(execMock).not.toHaveBeenCalled();
|
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"worktrunk" "switch"'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits worktrunk + native create audits when worktrunk succeeds", async () => {
|
it("emits worktrunk + native create audits when worktrunk succeeds", async () => {
|
||||||
@@ -90,11 +90,7 @@ describe("acquireTaskWorktree worktrunk wiring", () => {
|
|||||||
audit: audit as any,
|
audit: audit as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(execMock).toHaveBeenCalledWith(
|
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"worktrunk" "switch" "--create" "fusion/fn-1" "--no-hooks" "--no-cd"'))).toBe(true);
|
||||||
'"worktrunk" "switch" "--create" "fusion/fn-1"',
|
|
||||||
expect.objectContaining({ cwd: "/repo", timeout: 120000, maxBuffer: 10485760 }),
|
|
||||||
);
|
|
||||||
expect(execMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(events.filter((event) => event.type === "worktree:worktrunk-create")).toHaveLength(1);
|
expect(events.filter((event) => event.type === "worktree:worktrunk-create")).toHaveLength(1);
|
||||||
expect(events.filter((event) => event.type === "worktree:create")).toHaveLength(1);
|
expect(events.filter((event) => event.type === "worktree:create")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
@@ -114,15 +110,20 @@ describe("acquireTaskWorktree worktrunk wiring", () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toMatchObject({ code: "worktrunk_operation_failed", operation: "create" });
|
).rejects.toMatchObject({ code: "worktrunk_operation_failed", operation: "create" });
|
||||||
|
|
||||||
expect(execMock).toHaveBeenCalledTimes(1);
|
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"worktrunk" "switch" "--create" "fusion/fn-1"'))).toBe(true);
|
||||||
expect(execMock.mock.calls[0]?.[0]).toBe('"worktrunk" "switch" "--create" "fusion/fn-1"');
|
|
||||||
expect(events.some((event) => event.type === "worktree:worktrunk-fallback")).toBe(false);
|
expect(events.some((event) => event.type === "worktree:worktrunk-fallback")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to native when onFailure=fallback-native", async () => {
|
it("falls back to native when onFailure=fallback-native", async () => {
|
||||||
execMock
|
execMock.mockImplementation((command: string) => {
|
||||||
.mockRejectedValueOnce({ stderr: "broken", status: 3 })
|
if (command.includes('"config" "show"')) {
|
||||||
.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
return Promise.resolve({ stdout: "", stderr: "" });
|
||||||
|
}
|
||||||
|
if (command.includes('"switch" "--create"')) {
|
||||||
|
return Promise.reject({ stderr: "broken", status: 3 });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ stdout: "", stderr: "" });
|
||||||
|
});
|
||||||
const { acquireTaskWorktree } = await import("../worktree-acquisition.js");
|
const { acquireTaskWorktree } = await import("../worktree-acquisition.js");
|
||||||
const { audit, events } = makeAudit();
|
const { audit, events } = makeAudit();
|
||||||
|
|
||||||
@@ -135,9 +136,8 @@ describe("acquireTaskWorktree worktrunk wiring", () => {
|
|||||||
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(execMock).toHaveBeenCalledTimes(2);
|
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"worktrunk" "switch" "--create" "fusion/fn-1"'))).toBe(true);
|
||||||
expect(execMock.mock.calls[0]?.[0]).toBe('"worktrunk" "switch" "--create" "fusion/fn-1"');
|
expect(execMock.mock.calls.some((call) => String(call[0]).includes("git worktree add -b"))).toBe(true);
|
||||||
expect(execMock.mock.calls[1]?.[0]).toContain("git worktree add -b");
|
|
||||||
expect(events.filter((event) => event.type === "worktree:worktrunk-fallback")).toHaveLength(1);
|
expect(events.filter((event) => event.type === "worktree:worktrunk-fallback")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,6 +169,7 @@ describe("acquireTaskWorktree worktrunk wiring", () => {
|
|||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
sync: vi.fn().mockResolvedValue({ skipped: true as const }),
|
sync: vi.fn().mockResolvedValue({ skipped: true as const }),
|
||||||
prune: vi.fn(),
|
prune: vi.fn(),
|
||||||
|
resolveWorktreePath: vi.fn().mockResolvedValue("/tmp/custom-path"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,13 @@ import {
|
|||||||
resolveWorktreeBackend,
|
resolveWorktreeBackend,
|
||||||
} from "../worktree-backend.js";
|
} from "../worktree-backend.js";
|
||||||
|
|
||||||
const { execMock, execFileMock, accessMock } = vi.hoisted(() => {
|
const { execMock, accessMock } = vi.hoisted(() => {
|
||||||
const mock = vi.fn();
|
const mock = vi.fn();
|
||||||
const fileMock = vi.fn();
|
|
||||||
(mock as any)[Symbol.for("nodejs.util.promisify.custom")] = mock;
|
(mock as any)[Symbol.for("nodejs.util.promisify.custom")] = mock;
|
||||||
(fileMock as any)[Symbol.for("nodejs.util.promisify.custom")] = fileMock;
|
return { execMock: mock, accessMock: vi.fn() };
|
||||||
return { execMock: mock, execFileMock: fileMock, accessMock: vi.fn() };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("node:child_process", () => ({ exec: execMock, execFile: execFileMock }));
|
vi.mock("node:child_process", () => ({ exec: execMock }));
|
||||||
vi.mock("node:fs/promises", () => ({ access: accessMock }));
|
vi.mock("node:fs/promises", () => ({ access: accessMock }));
|
||||||
vi.mock("../branch-conflicts.js", () => ({
|
vi.mock("../branch-conflicts.js", () => ({
|
||||||
inspectBranchConflict: vi.fn().mockResolvedValue({ kind: "stale" }),
|
inspectBranchConflict: vi.fn().mockResolvedValue({ kind: "stale" }),
|
||||||
@@ -22,7 +20,6 @@ vi.mock("../branch-conflicts.js", () => ({
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
execMock.mockReset();
|
execMock.mockReset();
|
||||||
execFileMock.mockReset();
|
|
||||||
accessMock.mockReset();
|
accessMock.mockReset();
|
||||||
accessMock.mockResolvedValue(undefined);
|
accessMock.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
@@ -142,7 +139,7 @@ describe("WorktrunkWorktreeBackend", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws operation failed with stderr/exitCode", async () => {
|
it("throws operation failed with stderr/exitCode", async () => {
|
||||||
execFileMock.mockRejectedValue({ stderr: "bad news", status: 7 });
|
execMock.mockRejectedValue({ stderr: "bad news", status: 7 });
|
||||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -156,8 +153,9 @@ describe("WorktrunkWorktreeBackend", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("invokes create mapping with timeout/maxBuffer and cwd", async () => {
|
it("invokes create mapping with timeout/maxBuffer and cwd", async () => {
|
||||||
execFileMock.mockResolvedValue({ stdout: "", stderr: "" });
|
execMock
|
||||||
execMock.mockResolvedValue({ stdout: "worktree /repo/.worktrees/fusion/fn-1\n", stderr: "" });
|
.mockResolvedValueOnce({ stdout: "", stderr: "" })
|
||||||
|
.mockResolvedValueOnce({ stdout: "worktree /repo/.worktrees/fusion/fn-1\n", stderr: "" });
|
||||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||||
|
|
||||||
await backend.create({
|
await backend.create({
|
||||||
@@ -168,15 +166,15 @@ describe("WorktrunkWorktreeBackend", () => {
|
|||||||
taskId: "FN-1",
|
taskId: "FN-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(execFileMock).toHaveBeenCalledWith(
|
expect(execMock).toHaveBeenNthCalledWith(
|
||||||
"worktrunk",
|
1,
|
||||||
["switch", "--create", "fusion/fn-1", "--no-hooks", "--no-cd", "--base", "main"],
|
'"worktrunk" "switch" "--create" "fusion/fn-1" "--no-hooks" "--no-cd" "--base" "main"',
|
||||||
expect.objectContaining({ cwd: "/repo", timeout: 120000, maxBuffer: 10485760 }),
|
expect.objectContaining({ cwd: "/repo", timeout: 120000, maxBuffer: 10485760 }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes remove mapping", async () => {
|
it("invokes remove mapping", async () => {
|
||||||
execFileMock.mockResolvedValue({ stdout: "", stderr: "" });
|
execMock.mockResolvedValue({ stdout: "", stderr: "" });
|
||||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||||
|
|
||||||
await backend.remove({
|
await backend.remove({
|
||||||
@@ -185,15 +183,14 @@ describe("WorktrunkWorktreeBackend", () => {
|
|||||||
branch: "fusion/fn-1",
|
branch: "fusion/fn-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(execFileMock).toHaveBeenCalledWith(
|
expect(execMock).toHaveBeenCalledWith(
|
||||||
"worktrunk",
|
'"worktrunk" "remove" "--foreground" "fusion/fn-1"',
|
||||||
["remove", "--foreground", "fusion/fn-1"],
|
|
||||||
expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }),
|
expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats remove not-found style failures as idempotent success", async () => {
|
it("treats remove not-found style failures as idempotent success", async () => {
|
||||||
execFileMock.mockRejectedValue({ stderr: "branch not found", status: 1 });
|
execMock.mockRejectedValue({ stderr: "branch not found", status: 1 });
|
||||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -202,7 +199,7 @@ describe("WorktrunkWorktreeBackend", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("maps ENOENT to worktrunk_binary_missing", async () => {
|
it("maps ENOENT to worktrunk_binary_missing", async () => {
|
||||||
execFileMock.mockRejectedValue({ code: "ENOENT", stderr: "not found" });
|
execMock.mockRejectedValue({ code: "ENOENT", stderr: "not found" });
|
||||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -216,7 +213,7 @@ describe("WorktrunkWorktreeBackend", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("maps SIGTERM timeout to worktrunk_timeout", async () => {
|
it("maps SIGTERM timeout to worktrunk_timeout", async () => {
|
||||||
execFileMock.mockRejectedValue({ signal: "SIGTERM", stderr: "timed out" });
|
execMock.mockRejectedValue({ signal: "SIGTERM", stderr: "timed out" });
|
||||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -276,21 +273,20 @@ describe("WorktrunkWorktreeBackend", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves worktrunk path from wt config show template", async () => {
|
it("resolves worktrunk path from wt config show template", async () => {
|
||||||
execFileMock.mockResolvedValue({ stdout: '{"config":{"worktree-path":"{{ repo_path }}/../{{ repo }}.{{ branch | sanitize }}"}}', stderr: "" });
|
execMock.mockResolvedValue({ stdout: '{"config":{"worktree-path":"{{ repo_path }}/../{{ repo }}.{{ branch | sanitize }}"}}', stderr: "" });
|
||||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
backend.resolveWorktreePath({ rootDir: "/repo/project", worktreeName: "ignored", branch: "fusion/fn-1" }),
|
backend.resolveWorktreePath({ rootDir: "/repo/project", worktreeName: "ignored", branch: "fusion/fn-1" }),
|
||||||
).resolves.toBe("/repo/project.fusion-fn-1");
|
).resolves.toBe("/repo/project.fusion-fn-1");
|
||||||
expect(execFileMock).toHaveBeenCalledWith(
|
expect(execMock).toHaveBeenCalledWith(
|
||||||
"worktrunk",
|
'"worktrunk" "config" "show" "--format" "json"',
|
||||||
["config", "show", "--format", "json"],
|
|
||||||
expect.objectContaining({ cwd: "/repo/project", timeout: 5000, maxBuffer: 10485760 }),
|
expect.objectContaining({ cwd: "/repo/project", timeout: 5000, maxBuffer: 10485760 }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to default layout template when config cannot be read", async () => {
|
it("falls back to default layout template when config cannot be read", async () => {
|
||||||
execFileMock.mockRejectedValue(new Error("missing config"));
|
execMock.mockRejectedValue(new Error("missing config"));
|
||||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -299,22 +295,24 @@ describe("WorktrunkWorktreeBackend", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prunes by listing worktrees and removing worktrunk managed entries", async () => {
|
it("prunes by listing worktrees and removing worktrunk managed entries", async () => {
|
||||||
execMock.mockResolvedValue({
|
execMock
|
||||||
stdout:
|
.mockResolvedValueOnce({
|
||||||
"worktree /repo\nbranch refs/heads/main\n\nworktree /repo/.worktrees/fusion-fn-1\nbranch refs/heads/fusion/fn-1\n\n",
|
stdout:
|
||||||
stderr: "",
|
"worktree /repo\nbranch refs/heads/main\n\nworktree /repo/.worktrees/fusion-fn-1\nbranch refs/heads/fusion/fn-1\n\n",
|
||||||
});
|
stderr: "",
|
||||||
execFileMock.mockResolvedValue({ stdout: "", stderr: "" });
|
})
|
||||||
|
.mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||||
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" });
|
||||||
|
|
||||||
await expect(backend.prune({ rootDir: "/repo" })).resolves.toBeUndefined();
|
await expect(backend.prune({ rootDir: "/repo" })).resolves.toBeUndefined();
|
||||||
expect(execMock).toHaveBeenCalledWith(
|
expect(execMock).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
"git worktree list --porcelain",
|
"git worktree list --porcelain",
|
||||||
expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }),
|
expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }),
|
||||||
);
|
);
|
||||||
expect(execFileMock).toHaveBeenCalledWith(
|
expect(execMock).toHaveBeenNthCalledWith(
|
||||||
"worktrunk",
|
2,
|
||||||
["remove", "--foreground", "fusion/fn-1"],
|
'"worktrunk" "remove" "--foreground" "fusion/fn-1"',
|
||||||
expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }),
|
expect.objectContaining({ cwd: "/repo", timeout: 60000, maxBuffer: 10485760 }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { exec, execFile } from "node:child_process";
|
import { exec } from "node:child_process";
|
||||||
import { access } from "node:fs/promises";
|
import { access } from "node:fs/promises";
|
||||||
import { basename, resolve } from "node:path";
|
import { basename, resolve } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
@@ -8,7 +8,6 @@ import { inspectBranchConflict } from "./branch-conflicts.js";
|
|||||||
import { formatError } from "./logger.js";
|
import { formatError } from "./logger.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
const NATIVE_TIMEOUT_MS = 120_000;
|
const NATIVE_TIMEOUT_MS = 120_000;
|
||||||
const REMOVE_TIMEOUT_MS = 60_000;
|
const REMOVE_TIMEOUT_MS = 60_000;
|
||||||
const MAX_BUFFER = 10 * 1024 * 1024;
|
const MAX_BUFFER = 10 * 1024 * 1024;
|
||||||
@@ -306,7 +305,8 @@ export class WorktrunkWorktreeBackend implements WorktreeBackend {
|
|||||||
this.deps.logger?.log?.(`[worktree-backend] running worktrunk command: ${binaryPath} ${args.join(" ")}`);
|
this.deps.logger?.log?.(`[worktree-backend] running worktrunk command: ${binaryPath} ${args.join(" ")}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await execFileAsync(binaryPath, args, {
|
const command = `${quoteShellArg(binaryPath)} ${args.map((arg) => quoteShellArg(arg)).join(" ")}`;
|
||||||
|
return await execAsync(command, {
|
||||||
cwd: opts.cwd,
|
cwd: opts.cwd,
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
timeout: WORKTRUNK_TIMEOUTS_MS[opts.operation],
|
timeout: WORKTRUNK_TIMEOUTS_MS[opts.operation],
|
||||||
@@ -341,17 +341,22 @@ export class WorktrunkWorktreeBackend implements WorktreeBackend {
|
|||||||
if (input.startPoint) args.push("--base", input.startPoint);
|
if (input.startPoint) args.push("--base", input.startPoint);
|
||||||
await this.runWorktrunk(args, { cwd: input.rootDir, operation: "create" });
|
await this.runWorktrunk(args, { cwd: input.rootDir, operation: "create" });
|
||||||
|
|
||||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
let resolved = input.worktreePath;
|
||||||
cwd: input.rootDir,
|
try {
|
||||||
encoding: "utf-8",
|
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||||
timeout: WORKTRUNK_TIMEOUTS_MS.layout,
|
cwd: input.rootDir,
|
||||||
maxBuffer: MAX_BUFFER,
|
encoding: "utf-8",
|
||||||
});
|
timeout: WORKTRUNK_TIMEOUTS_MS.layout,
|
||||||
const rows = parseWorktreesFromPorcelain(stdout);
|
maxBuffer: MAX_BUFFER,
|
||||||
const resolved =
|
});
|
||||||
rows.find((row) => row.branch === input.branch)?.path ??
|
const rows = parseWorktreesFromPorcelain(stdout);
|
||||||
rows.find((row) => row.path.endsWith(input.branch) || row.path === input.worktreePath)?.path ??
|
resolved =
|
||||||
input.worktreePath;
|
rows.find((row) => row.branch === input.branch)?.path ??
|
||||||
|
rows.find((row) => row.path.endsWith(input.branch) || row.path === input.worktreePath)?.path ??
|
||||||
|
input.worktreePath;
|
||||||
|
} catch {
|
||||||
|
resolved = input.worktreePath;
|
||||||
|
}
|
||||||
return { path: resolved, branch: input.branch };
|
return { path: resolved, branch: input.branch };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user