test(FN-4912): complete Step 9 — add env materialization verification coverage
Fusion-Task-Id: FN-4912 Fusion-Task-Lineage: 943d0651-052a-41b5-8069-4c60f4db1ba7
This commit is contained in:
committed by
gsxdsm
parent
ffd4d83c09
commit
48caf28148
@@ -0,0 +1,56 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { writeSecretsEnvFile } from "../../secrets-env-writer.js";
|
||||
import { reapOrphanWorktrees } from "../../worktree-pool.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
function tmpRepo(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "secrets-rel-"));
|
||||
dirs.push(root);
|
||||
execFileSync("git", ["init"], { cwd: root });
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("reliability interactions: secrets env materialization", () => {
|
||||
it("writer refuses non-ignored env path", async () => {
|
||||
const root = tmpRepo();
|
||||
const worktree = join(root, ".worktrees", "a");
|
||||
mkdirSync(worktree, { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: worktree });
|
||||
|
||||
const audit = { filesystem: vi.fn() };
|
||||
const result = await writeSecretsEnvFile({
|
||||
rootDir: root,
|
||||
worktreePath: worktree,
|
||||
taskId: "FN-1",
|
||||
settings: { secretsEnv: { enabled: true, filename: ".env", requireGitignored: true } },
|
||||
worktreeSource: "fresh",
|
||||
audit,
|
||||
secretsStore: { listEnvExportable: vi.fn().mockResolvedValue([{ id: "1", key: "A", exportKey: "ALPHA", scope: "project", plaintextValue: "v" }]) } as any,
|
||||
});
|
||||
|
||||
expect(result.reason).toBe("not-gitignored");
|
||||
expect(audit.filesystem).toHaveBeenCalledWith(expect.objectContaining({ type: "secret:env-write-skipped" }));
|
||||
});
|
||||
|
||||
it("orphan reap reclaims orphaned env artifacts", async () => {
|
||||
const root = tmpRepo();
|
||||
const worktreesDir = join(root, ".worktrees");
|
||||
const orphan = join(worktreesDir, "ghost");
|
||||
mkdirSync(orphan, { recursive: true });
|
||||
writeFileSync(join(orphan, ".env"), "A=1\n");
|
||||
writeFileSync(join(orphan, ".fusion-secrets-env.fingerprint"), "abc\n.env\n");
|
||||
|
||||
const removed = await reapOrphanWorktrees(root);
|
||||
expect(removed).toBe(1);
|
||||
expect(existsSync(orphan)).toBe(false);
|
||||
});
|
||||
});
|
||||
192
packages/engine/src/__tests__/secrets-env-writer.test.ts
Normal file
192
packages/engine/src/__tests__/secrets-env-writer.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { cleanupSecretsEnvFile, writeSecretsEnvFile } from "../secrets-env-writer.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
|
||||
function tmpWorktree(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "secrets-env-"));
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("secrets-env-writer", () => {
|
||||
it("skips silently when disabled", async () => {
|
||||
const filesystem = vi.fn();
|
||||
const result = await writeSecretsEnvFile({
|
||||
rootDir: process.cwd(),
|
||||
worktreePath: tmpWorktree(),
|
||||
taskId: "FN-1",
|
||||
settings: { secretsEnv: { enabled: false } },
|
||||
worktreeSource: "fresh",
|
||||
audit: { filesystem },
|
||||
});
|
||||
expect(result).toEqual({ outcome: "skipped", filename: ".env", reason: "disabled" });
|
||||
expect(filesystem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when no store", async () => {
|
||||
const filesystem = vi.fn();
|
||||
const result = await writeSecretsEnvFile({
|
||||
rootDir: process.cwd(),
|
||||
worktreePath: tmpWorktree(),
|
||||
taskId: "FN-1",
|
||||
settings: { secretsEnv: { enabled: true } },
|
||||
worktreeSource: "fresh",
|
||||
audit: { filesystem },
|
||||
execFileImpl: ((_f: string, _a: string[], _o: any, cb: any) => cb(null)) as any,
|
||||
});
|
||||
expect(result.reason).toBe("no-store");
|
||||
expect(filesystem).toHaveBeenCalledWith(expect.objectContaining({ type: "secret:env-write-skipped" }));
|
||||
});
|
||||
|
||||
it("writes managed env and sidecar without plaintext in audit/logs", async () => {
|
||||
const dir = tmpWorktree();
|
||||
const filesystem = vi.fn();
|
||||
const log = vi.fn();
|
||||
const warn = vi.fn();
|
||||
const secretValue = "SUPER_SECRET_VALUE";
|
||||
|
||||
const result = await writeSecretsEnvFile({
|
||||
rootDir: process.cwd(),
|
||||
worktreePath: dir,
|
||||
taskId: "FN-1",
|
||||
settings: { secretsEnv: { enabled: true, requireGitignored: false } },
|
||||
worktreeSource: "fresh",
|
||||
audit: { filesystem },
|
||||
logger: { log, warn },
|
||||
secretsStore: {
|
||||
listEnvExportable: vi.fn().mockResolvedValue([
|
||||
{ id: "1", key: "A", exportKey: "ALPHA", scope: "project", plaintextValue: secretValue },
|
||||
{ id: "2", key: "B", exportKey: "BETA", scope: "global", plaintextValue: "x" },
|
||||
]),
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe("written");
|
||||
const env = readFileSync(join(dir, ".env"), "utf8");
|
||||
expect(env).toContain("ALPHA=");
|
||||
expect(env).toContain("BETA=");
|
||||
const sidecar = readFileSync(join(dir, ".fusion-secrets-env.fingerprint"), "utf8");
|
||||
expect(sidecar).toContain(".env");
|
||||
if (process.platform !== "win32") {
|
||||
expect(statSync(join(dir, ".env")).mode & 0o777).toBe(0o600);
|
||||
expect(statSync(join(dir, ".fusion-secrets-env.fingerprint")).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
|
||||
const outputBlob = JSON.stringify({ calls: filesystem.mock.calls, logs: log.mock.calls, warns: warn.mock.calls });
|
||||
expect(outputBlob).not.toContain(secretValue);
|
||||
});
|
||||
|
||||
it("merge is idempotent", async () => {
|
||||
const dir = tmpWorktree();
|
||||
writeFileSync(join(dir, ".env"), "EXISTING=1\n");
|
||||
const secretsStore = {
|
||||
listEnvExportable: vi.fn().mockResolvedValue([{ id: "1", key: "A", exportKey: "ALPHA", scope: "project", plaintextValue: "v" }]),
|
||||
} as any;
|
||||
|
||||
await writeSecretsEnvFile({
|
||||
rootDir: process.cwd(),
|
||||
worktreePath: dir,
|
||||
taskId: "FN-1",
|
||||
settings: { secretsEnv: { enabled: true, requireGitignored: false, overwritePolicy: "merge" } },
|
||||
worktreeSource: "fresh",
|
||||
secretsStore,
|
||||
});
|
||||
const once = readFileSync(join(dir, ".env"), "utf8");
|
||||
|
||||
await writeSecretsEnvFile({
|
||||
rootDir: process.cwd(),
|
||||
worktreePath: dir,
|
||||
taskId: "FN-1",
|
||||
settings: { secretsEnv: { enabled: true, requireGitignored: false, overwritePolicy: "merge" } },
|
||||
worktreeSource: "fresh",
|
||||
secretsStore,
|
||||
});
|
||||
const twice = readFileSync(join(dir, ".env"), "utf8");
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
|
||||
it("rejects invalid filename and symlink", async () => {
|
||||
const dir = tmpWorktree();
|
||||
const filesystem = vi.fn();
|
||||
const a = await writeSecretsEnvFile({
|
||||
rootDir: process.cwd(),
|
||||
worktreePath: dir,
|
||||
taskId: "FN-1",
|
||||
settings: { secretsEnv: { enabled: true, filename: "../x" } },
|
||||
worktreeSource: "fresh",
|
||||
audit: { filesystem },
|
||||
secretsStore: { listEnvExportable: vi.fn() } as any,
|
||||
});
|
||||
expect(a.reason).toBe("invalid-filename");
|
||||
|
||||
writeFileSync(join(dir, "real.env"), "SAFE=1\n");
|
||||
symlinkSync(join(dir, "real.env"), join(dir, ".env"));
|
||||
const b = await writeSecretsEnvFile({
|
||||
rootDir: process.cwd(),
|
||||
worktreePath: dir,
|
||||
taskId: "FN-1",
|
||||
settings: { secretsEnv: { enabled: true, requireGitignored: false } },
|
||||
worktreeSource: "fresh",
|
||||
audit: { filesystem },
|
||||
secretsStore: { listEnvExportable: vi.fn() } as any,
|
||||
});
|
||||
expect(b.reason).toBe("invalid-filename");
|
||||
});
|
||||
|
||||
it("cleanup removes only fingerprint-matching env", async () => {
|
||||
const dir = tmpWorktree();
|
||||
const filesystem = vi.fn();
|
||||
const secretsStore = {
|
||||
listEnvExportable: vi.fn().mockResolvedValue([{ id: "1", key: "A", exportKey: "ALPHA", scope: "project", plaintextValue: "v" }]),
|
||||
} as any;
|
||||
|
||||
await writeSecretsEnvFile({
|
||||
rootDir: process.cwd(),
|
||||
worktreePath: dir,
|
||||
taskId: "FN-1",
|
||||
settings: { secretsEnv: { enabled: true, requireGitignored: false } },
|
||||
worktreeSource: "fresh",
|
||||
secretsStore,
|
||||
});
|
||||
|
||||
const cleaned = await cleanupSecretsEnvFile({
|
||||
worktreePath: dir,
|
||||
taskId: "FN-1",
|
||||
expectedFingerprint: null,
|
||||
filename: ".env",
|
||||
audit: { filesystem },
|
||||
});
|
||||
expect(cleaned.outcome).toBe("cleaned");
|
||||
expect(existsSync(join(dir, ".env"))).toBe(false);
|
||||
expect(existsSync(join(dir, ".fusion-secrets-env.fingerprint"))).toBe(false);
|
||||
|
||||
await writeSecretsEnvFile({
|
||||
rootDir: process.cwd(),
|
||||
worktreePath: dir,
|
||||
taskId: "FN-1",
|
||||
settings: { secretsEnv: { enabled: true, requireGitignored: false } },
|
||||
worktreeSource: "fresh",
|
||||
secretsStore,
|
||||
});
|
||||
writeFileSync(join(dir, ".env"), "MUTATED=1\n");
|
||||
const skipped = await cleanupSecretsEnvFile({
|
||||
worktreePath: dir,
|
||||
taskId: "FN-1",
|
||||
expectedFingerprint: null,
|
||||
filename: ".env",
|
||||
audit: { filesystem },
|
||||
});
|
||||
expect(skipped.reason).toBe("fingerprint-mismatch");
|
||||
expect(existsSync(join(dir, ".env"))).toBe(true);
|
||||
expect(existsSync(join(dir, ".fusion-secrets-env.fingerprint"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { writeSecretsEnvFile } = vi.hoisted(() => ({ writeSecretsEnvFile: vi.fn() }));
|
||||
|
||||
vi.mock("../secrets-env-writer.js", () => ({
|
||||
writeSecretsEnvFile,
|
||||
}));
|
||||
|
||||
vi.mock("../worktree-pool.js", async () => {
|
||||
const actual = await vi.importActual<any>("../worktree-pool.js");
|
||||
return {
|
||||
...actual,
|
||||
classifyTaskWorktree: vi.fn().mockResolvedValue({ ok: true }),
|
||||
isInsideWorktreesDir: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../worktree-db-hydrate.js", () => ({
|
||||
hydrateWorktreeDb: vi.fn().mockResolvedValue({ degraded: false, tasksCopied: 0, documentsCopied: 0 }),
|
||||
}));
|
||||
|
||||
import { acquireTaskWorktree } from "../worktree-acquisition.js";
|
||||
|
||||
describe("worktree-acquisition secrets env hook", () => {
|
||||
const task = { id: "FN-1", title: "t", description: "d", branch: null, worktree: null } as any;
|
||||
let store: any;
|
||||
|
||||
beforeEach(() => {
|
||||
writeSecretsEnvFile.mockReset().mockResolvedValue({ outcome: "skipped", filename: ".env", reason: "disabled" });
|
||||
store = { updateTask: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined) };
|
||||
});
|
||||
|
||||
it("calls writer on pool", async () => {
|
||||
await acquireTaskWorktree({
|
||||
task,
|
||||
rootDir: process.cwd(),
|
||||
store,
|
||||
settings: { recycleWorktrees: true, secretsEnv: { enabled: true } } as any,
|
||||
pool: {
|
||||
acquire: () => "/tmp/pool",
|
||||
prepareForTask: vi.fn().mockResolvedValue({ branch: "fusion/fn-1", worktreePath: "/tmp/pool", reclaimed: false }),
|
||||
release: vi.fn(),
|
||||
} as any,
|
||||
createWorktree: vi.fn(),
|
||||
secretsStore: undefined,
|
||||
});
|
||||
expect(writeSecretsEnvFile).toHaveBeenCalledWith(expect.objectContaining({ worktreeSource: "pool", secretsStore: undefined }));
|
||||
});
|
||||
|
||||
it("calls writer on fresh", async () => {
|
||||
await acquireTaskWorktree({
|
||||
task,
|
||||
rootDir: process.cwd(),
|
||||
store,
|
||||
settings: { secretsEnv: { enabled: true } } as any,
|
||||
createWorktree: vi.fn().mockResolvedValue({ path: "/tmp/fresh", branch: "fusion/fn-1" }),
|
||||
secretsStore: undefined,
|
||||
});
|
||||
expect(writeSecretsEnvFile).toHaveBeenCalledWith(expect.objectContaining({ worktreeSource: "fresh" }));
|
||||
});
|
||||
|
||||
it("does not call writer for existing resume", async () => {
|
||||
await acquireTaskWorktree({
|
||||
task: { ...task, branch: "fusion/fn-1", worktree: process.cwd() },
|
||||
rootDir: process.cwd(),
|
||||
store,
|
||||
settings: { secretsEnv: { enabled: true } } as any,
|
||||
createWorktree: vi.fn(),
|
||||
});
|
||||
expect(writeSecretsEnvFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("isolates writer failures", async () => {
|
||||
writeSecretsEnvFile.mockRejectedValueOnce(new Error("boom"));
|
||||
await expect(acquireTaskWorktree({
|
||||
task,
|
||||
rootDir: process.cwd(),
|
||||
store,
|
||||
settings: { secretsEnv: { enabled: true } } as any,
|
||||
createWorktree: vi.fn().mockResolvedValue({ path: "/tmp/fresh", branch: "fusion/fn-1" }),
|
||||
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
})).resolves.toMatchObject({ source: "fresh" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const cleanupSecretsEnvFile = vi.fn();
|
||||
|
||||
vi.mock("../secrets-env-writer.js", () => ({
|
||||
cleanupSecretsEnvFile,
|
||||
}));
|
||||
|
||||
const dirs: string[] = [];
|
||||
function tmpRoot(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "pool-cleanup-"));
|
||||
dirs.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
cleanupSecretsEnvFile.mockReset().mockResolvedValue({ outcome: "cleaned", reason: "fingerprint-match" });
|
||||
await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("worktree-pool secrets cleanup hooks", () => {
|
||||
it("reapOrphanWorktrees invokes cleanup before removal", async () => {
|
||||
cleanupSecretsEnvFile.mockResolvedValue({ outcome: "cleaned", reason: "fingerprint-match" });
|
||||
const root = tmpRoot();
|
||||
const worktrees = join(root, ".worktrees");
|
||||
const orphan = join(worktrees, "orphan-1");
|
||||
mkdirSync(orphan, { recursive: true });
|
||||
writeFileSync(join(orphan, ".env"), "A=1\n");
|
||||
|
||||
const mod = await import("../worktree-pool.js");
|
||||
const removed = await mod.reapOrphanWorktrees(root);
|
||||
|
||||
expect(removed).toBe(1);
|
||||
expect(cleanupSecretsEnvFile).toHaveBeenCalledWith(expect.objectContaining({
|
||||
worktreePath: orphan,
|
||||
taskId: "orphan:orphan-1",
|
||||
}));
|
||||
expect(existsSync(orphan)).toBe(false);
|
||||
});
|
||||
|
||||
it("cleanup failures do not block orphan removal", async () => {
|
||||
cleanupSecretsEnvFile.mockRejectedValueOnce(new Error("cleanup failed"));
|
||||
const root = tmpRoot();
|
||||
const orphan = join(root, ".worktrees", "orphan-2");
|
||||
mkdirSync(orphan, { recursive: true });
|
||||
|
||||
const mod = await import("../worktree-pool.js");
|
||||
const removed = await mod.reapOrphanWorktrees(root);
|
||||
|
||||
expect(removed).toBe(1);
|
||||
expect(existsSync(orphan)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -802,6 +802,7 @@ export class HeartbeatMonitor {
|
||||
private selfImproveService?: SelfImproveServiceLike;
|
||||
private approvalRequestStore?: ApprovalRequestStore;
|
||||
private snapshotManager?: AutoClaimSnapshotManager;
|
||||
private secretsStore?: Pick<import("@fusion/core").SecretsStore, "listEnvExportable">;
|
||||
|
||||
private trackedAgents: Map<string, TrackedAgent> = new Map();
|
||||
private agentStartLocks: Map<string, Promise<unknown>> = new Map();
|
||||
@@ -831,6 +832,7 @@ export class HeartbeatMonitor {
|
||||
this.reflectionService = options.reflectionService;
|
||||
this.selfImproveService = options.selfImproveService;
|
||||
this.snapshotManager = options.snapshotManager ?? (this.taskStore ? new AutoClaimSnapshotManager({ taskStore: this.taskStore }) : undefined);
|
||||
this.secretsStore = options.secretsStore;
|
||||
}
|
||||
|
||||
getChatStore(): ChatStore | undefined {
|
||||
@@ -2398,7 +2400,7 @@ export class HeartbeatMonitor {
|
||||
audit,
|
||||
runContext,
|
||||
runInitCommand: false,
|
||||
secretsStore: this.options.secretsStore,
|
||||
secretsStore: this.secretsStore,
|
||||
});
|
||||
sessionCwd = acquisition.worktreePath;
|
||||
} catch (worktreeErr) {
|
||||
|
||||
@@ -309,7 +309,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
||||
settings,
|
||||
reason: RemovalReason.PoolPrune,
|
||||
taskId: task.id,
|
||||
audit,
|
||||
audit: undefined,
|
||||
});
|
||||
} catch (removeErr) {
|
||||
logger?.warn(`${task.id}: failed to remove unusable pooled worktree ${worktreePath}: ${formatError(removeErr)}`);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { existsSync, lstatSync, readdirSync, rmSync, realpathSync } from "node:fs";
|
||||
import { basename, join, relative, resolve, isAbsolute } from "node:path";
|
||||
import type { Column, Settings, TaskStore, WorktrunkSettings } from "@fusion/core";
|
||||
import type { Column, SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core";
|
||||
import { assertCleanBranchAtBase, inspectBranchConflict } from "./branch-conflicts.js";
|
||||
import { worktreePoolLog } from "./logger.js";
|
||||
import { isInsideConfiguredWorktreesDir, resolveWorktreesDir } from "./worktree-paths.js";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
resolveWorktreeBackend as resolveWorktreeBackendViaSettings,
|
||||
} from "./worktree-backend.js";
|
||||
import { cleanupSecretsEnvFile } from "./secrets-env-writer.js";
|
||||
import type { RunAuditor } from "./run-audit.js";
|
||||
|
||||
export {
|
||||
NativeWorktreeBackend,
|
||||
@@ -263,11 +264,18 @@ export class PoolDoubleLeaseError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorktreePoolOptions {
|
||||
auditFactory?: (taskId: string) => Pick<RunAuditor, "filesystem">;
|
||||
secretsStore?: Pick<SecretsStore, "listEnvExportable">;
|
||||
}
|
||||
|
||||
export class WorktreePool {
|
||||
private idle = new Set<string>();
|
||||
private leased = new Map<string, string>();
|
||||
private invariantViolationHandler?: (violation: PoolInvariantViolation) => void;
|
||||
|
||||
constructor(_options: WorktreePoolOptions = {}) {}
|
||||
|
||||
/**
|
||||
* Acquire an idle worktree from the pool.
|
||||
*
|
||||
@@ -655,6 +663,22 @@ export async function cleanupOrphanedWorktrees(
|
||||
for (const worktreePath of candidates) {
|
||||
try {
|
||||
if (registeredWorktrees.has(resolve(worktreePath))) {
|
||||
const orphanTaskId = `orphan:${basename(worktreePath)}`;
|
||||
try {
|
||||
await cleanupSecretsEnvFile({
|
||||
worktreePath,
|
||||
taskId: orphanTaskId,
|
||||
expectedFingerprint: null,
|
||||
filename: ".env",
|
||||
audit: undefined,
|
||||
logger: worktreePoolLog,
|
||||
});
|
||||
} catch (error) {
|
||||
worktreePoolLog.warn(
|
||||
`secrets-env cleanup failed for registered orphan ${worktreePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await removeWorktreeViaBackend({
|
||||
rootDir,
|
||||
worktreePath,
|
||||
|
||||
Reference in New Issue
Block a user