Files
fusion/packages/engine/src/worktree-copy-files.ts
gsxdsm f5e1b966ab FN-6943: add worktree file copy settings
Add configurable file-copy seeding for new task worktrees.

- Add the worktreeCopyFiles setting to project settings, schema validation, and dashboard settings UI.
- Copy selected files into acquired worktrees after checkout and record audit events for copied/skipped files.
- Document the new Worktrees setting and cover it with core, dashboard, and engine tests.

Files changed:
 .changeset/fn-6943-worktree-copy-files.md          |   7 +
 docs/dashboard-guide.md                            |   4 +
 docs/settings-reference.md                         |   1 +
 packages/core/src/__tests__/store-settings.test.ts |  24 ++++
 packages/core/src/settings-schema.ts               |   5 +
 packages/core/src/types.ts                         |   7 +
 .../app/__tests__/settings-sections.test.tsx       |  66 +++++++++
 .../dashboard/app/components/SettingsModal.tsx     | 146 ++++++++++++++++++++
 .../components/__tests__/SettingsModal.test.tsx    |  91 +++++++++++++
 .../settings/sections/WorktreesSection.tsx         |  51 ++++++-
 .../src/__tests__/worktree-acquisition.test.ts     |  69 +++++++++-
 .../src/__tests__/worktree-copy-files.test.ts      | 112 ++++++++++++++++
 packages/engine/src/run-audit.ts                   |   6 +
 packages/engine/src/worktree-acquisition.ts        |  32 ++++-
 packages/engine/src/worktree-copy-files.ts         | 147 +++++++++++++++++++++
 15 files changed, 765 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-6943

Fusion-Task-Lineage: fd1ded06-25f9-4496-8f50-6416446d2f29
2026-06-25 08:34:44 -07:00

148 lines
5.9 KiB
TypeScript

import { constants as fsConstants } from "node:fs";
import { access, copyFile, lstat, mkdir } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve } from "node:path";
import type { RunAuditor } from "./run-audit.js";
export type WorktreeCopyFileSkipReason =
| "blank"
| "duplicate"
| "absolute-path"
| "path-traversal"
| "missing"
| "non-regular"
| "unreadable"
| "copy-failed";
export interface WorktreeCopyFileResult {
path: string;
sourcePath?: string;
destinationPath?: string;
outcome: "copied" | "skipped";
reason?: WorktreeCopyFileSkipReason;
error?: string;
}
export interface CopyConfiguredWorktreeFilesOptions {
rootDir: string;
worktreePath: string;
paths?: readonly string[];
taskId?: string;
logger?: { log?: (message: string) => void; warn?: (message: string) => void };
audit?: Pick<RunAuditor, "filesystem">;
}
function isInsideRoot(rootDir: string, candidate: string): boolean {
const rel = relative(rootDir, candidate);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
function safeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function auditCopyResult(
audit: Pick<RunAuditor, "filesystem"> | undefined,
taskId: string | undefined,
result: WorktreeCopyFileResult,
): Promise<void> {
try {
await audit?.filesystem({
type: result.outcome === "copied" ? "worktree:copy-file" : "worktree:copy-file-skipped",
target: taskId ?? result.path,
metadata: {
path: result.path,
outcome: result.outcome,
reason: result.reason,
error: result.error,
},
});
} catch {
// Best-effort observability only; copy decisions must not fail acquisition.
}
}
/**
* FNXC:WorktreeCopyFiles 2026-06-24-00:00:
* Configured copy files may contain secrets, so the engine copies only root-relative regular files, never shells out, never logs contents, and treats missing/non-file/unreadable entries as non-fatal setup diagnostics for newly prepared worktrees.
*/
export async function copyConfiguredWorktreeFiles(options: CopyConfiguredWorktreeFilesOptions): Promise<WorktreeCopyFileResult[]> {
const { rootDir, worktreePath, paths = [], taskId, logger, audit } = options;
const root = resolve(rootDir);
const worktreeRoot = resolve(worktreePath);
const seen = new Set<string>();
const results: WorktreeCopyFileResult[] = [];
for (const rawPath of paths) {
const trimmed = typeof rawPath === "string" ? rawPath.trim() : "";
if (!trimmed) {
const result: WorktreeCopyFileResult = { path: trimmed, outcome: "skipped", reason: "blank" };
results.push(result);
await auditCopyResult(audit, taskId, result);
continue;
}
const normalizedKey = trimmed.replace(/\\/g, "/");
if (seen.has(normalizedKey)) {
const result: WorktreeCopyFileResult = { path: trimmed, outcome: "skipped", reason: "duplicate" };
results.push(result);
await auditCopyResult(audit, taskId, result);
continue;
}
seen.add(normalizedKey);
if (isAbsolute(trimmed)) {
const result: WorktreeCopyFileResult = { path: trimmed, outcome: "skipped", reason: "absolute-path" };
logger?.warn?.(`${taskId ?? "worktree"}: skipped configured worktree copy file ${trimmed}: absolute paths are not allowed`);
results.push(result);
await auditCopyResult(audit, taskId, result);
continue;
}
const sourcePath = resolve(root, trimmed);
const destinationPath = resolve(worktreeRoot, trimmed);
if (!isInsideRoot(root, sourcePath) || !isInsideRoot(worktreeRoot, destinationPath)) {
const result: WorktreeCopyFileResult = { path: trimmed, sourcePath, destinationPath, outcome: "skipped", reason: "path-traversal" };
logger?.warn?.(`${taskId ?? "worktree"}: skipped configured worktree copy file ${trimmed}: path escapes repository root`);
results.push(result);
await auditCopyResult(audit, taskId, result);
continue;
}
try {
const sourceStat = await lstat(sourcePath);
if (!sourceStat.isFile()) {
const result: WorktreeCopyFileResult = { path: trimmed, sourcePath, destinationPath, outcome: "skipped", reason: "non-regular" };
logger?.warn?.(`${taskId ?? "worktree"}: skipped configured worktree copy file ${trimmed}: source is not a regular file`);
results.push(result);
await auditCopyResult(audit, taskId, result);
continue;
}
await access(sourcePath, fsConstants.R_OK);
} catch (error) {
const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : "";
const reason: WorktreeCopyFileSkipReason = code === "ENOENT" ? "missing" : "unreadable";
const result: WorktreeCopyFileResult = { path: trimmed, sourcePath, destinationPath, outcome: "skipped", reason, error: safeError(error) };
logger?.warn?.(`${taskId ?? "worktree"}: skipped configured worktree copy file ${trimmed}: ${reason}`);
results.push(result);
await auditCopyResult(audit, taskId, result);
continue;
}
try {
await mkdir(dirname(destinationPath), { recursive: true });
await copyFile(sourcePath, destinationPath);
const result: WorktreeCopyFileResult = { path: trimmed, sourcePath, destinationPath, outcome: "copied" };
logger?.log?.(`${taskId ?? "worktree"}: copied configured worktree file ${trimmed}`);
results.push(result);
await auditCopyResult(audit, taskId, result);
} catch (error) {
const result: WorktreeCopyFileResult = { path: trimmed, sourcePath, destinationPath, outcome: "skipped", reason: "copy-failed", error: safeError(error) };
logger?.warn?.(`${taskId ?? "worktree"}: skipped configured worktree copy file ${trimmed}: copy failed`);
results.push(result);
await auditCopyResult(audit, taskId, result);
}
}
return results;
}