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
This commit is contained in:
gsxdsm
2026-06-25 02:41:03 -07:00
parent 0c53f46afb
commit f5e1b966ab
15 changed files with 765 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Add a Worktrees setting for copying repository files into new task worktrees.
category: feature
dev: Adds project setting `worktreeCopyFiles`; release with the standard changeset workflow, not manual versioning.

View File

@@ -277,6 +277,10 @@ Saved API keys are stored in settings but are masked in API responses and UI-loa
For the stored settings shape, see [`customProviders` in the Settings Reference](./settings-reference.md#customproviders). For the API behavior, including masked keys in responses, see [Architecture → Custom Provider endpoints](./architecture.md#custom-provider-endpoints).
## Worktree copy files
Open **Settings → Worktrees** to maintain **Files to copy into new worktrees**. Add editable rows for repository-root-relative files such as `.env`, use **Browse** to select a project file, remove rows you no longer want, then Save. Fusion trims blank rows and de-duplicates paths before persisting. During task startup, configured regular files are copied into fresh or pooled task worktrees before the worktree init command and task execution begin; existing/resumed worktrees are not overwritten. Missing files, directories, absolute paths, traversal entries, and unreadable sources are skipped with non-fatal diagnostics and without logging file contents. See [`worktreeCopyFiles` in the Settings Reference](./settings-reference.md#project-settings) for the stored setting shape.
## Planning Mode
Planning is a desktop/tablet left-sidebar main-content destination after **Command Center**. It opens the planning-session list and composer in the main content region; mobile continues to use the compact planning entry points. Planning Mode now includes branch controls on the summary screen before you create a task.

View File

@@ -397,6 +397,7 @@ Sandbox backend precedence is:
| `pushAfterMerge` | `boolean` | `false` | Auto-push to remote after successful direct merge. Includes pulling latest and AI conflict resolution. |
| `pushRemote` | `string` | `"origin"` | Git remote (and optional branch) to push to after merge. |
| `worktreeInitCommand` | `string` | `undefined` | Shell command run after task worktree creation and in temporary merge worktrees before merge/review verification. In standalone AI merge, this runs inside each fresh `fusion-ai-merge-*` clean-room worktree after `git worktree add`; when unset, Fusion infers a package-manager install from the lockfile and may skip only when the install marker matches. Useful for project-specific setup beyond package install (for example `pnpm install --frozen-lockfile`, `cp .env.local .env`, or codegen/bootstrap scripts). |
| `worktreeCopyFiles` | `string[]` | `[]` | Repository-root-relative regular files to copy into each newly assigned non-resume task worktree. Configure from Settings → Worktrees with editable rows or Browse (useful for `.env`-style files). Fusion copies these files after fresh creation or pooled-worktree preparation and before `worktreeInitCommand`, secrets-env materialization, and task execution. Blank/duplicate entries are ignored; absolute paths, `..` traversal, missing files, directories, and unreadable/non-regular sources are skipped as non-fatal task-log/audit diagnostics without logging file contents. Resume/existing worktrees are not overwritten. |
| `testCommand` | `string` | `undefined` | Merge-time test command (hard gate). When unset, Fusion auto-detects from lockfile. |
| `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). |
| `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. |

View File

@@ -40,6 +40,30 @@ describe("TaskStore", () => {
});
});
describe("worktreeCopyFiles setting", () => {
it("round-trips populated copy-file paths via getSettings and project serialization", async () => {
await harness.store().updateSettings({ worktreeCopyFiles: [".env", "config/local.env", "packages/api/.env.test"] });
const settings = await harness.store().getSettings();
expect(settings.worktreeCopyFiles).toEqual([".env", "config/local.env", "packages/api/.env.test"]);
const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
const config = JSON.parse(configRaw);
expect(config.settings.worktreeCopyFiles).toEqual([".env", "config/local.env", "packages/api/.env.test"]);
});
it("persists cleared copy-file paths without dropping the project key", async () => {
await harness.store().updateSettings({ worktreeCopyFiles: [".env"] });
await harness.store().updateSettings({ worktreeCopyFiles: [] });
const settings = await harness.store().getSettings();
expect(settings.worktreeCopyFiles).toEqual([]);
const { project } = await harness.store().getSettingsByScope();
expect(project.worktreeCopyFiles).toEqual([]);
});
});
describe("worktreesDir setting", () => {
it("round-trips worktreesDir via updateSettings and project serialization", async () => {
await harness.store().updateSettings({ worktreesDir: "~/.fn-worktrees/{repo}" });

View File

@@ -295,6 +295,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
defaultNodeId: undefined,
secretsEnv: undefined,
worktreeInitCommand: undefined,
/*
FNXC:WorktreeCopyFiles 2026-06-24-00:00:
The safe default is an empty allowlist so new worktrees never copy potentially sensitive repository files until the project owner explicitly configures root-relative regular-file paths.
*/
worktreeCopyFiles: [],
testCommand: undefined,
buildCommand: undefined,
recycleWorktrees: false,

View File

@@ -3778,6 +3778,13 @@ export interface ProjectSettings {
/** Shell command to run inside each new worktree immediately after creation.
* Useful for project-specific setup (e.g. `pnpm install --frozen-lockfile`, `cp .env.local .env`). */
worktreeInitCommand?: string;
/**
* Repository-root-relative regular files copied into newly assigned non-resume task worktrees.
*
* FNXC:WorktreeCopyFiles 2026-06-24-00:00:
* Operators need `.env`-style repo files available before worktree init commands run without embedding shell copy commands in setup. Entries stay root-relative, copy only regular files, and apply only when Fusion prepares a fresh or pooled assignment so resume worktrees keep their existing on-disk state.
*/
worktreeCopyFiles?: string[];
/** Custom test command for the project (e.g. "pnpm test") */
testCommand?: string;
/** Custom build command for the project (e.g. "pnpm build") */

View File

@@ -19,6 +19,7 @@ import { ExperimentalSection } from "../components/settings/sections/Experimenta
import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub";
import { PromptsSection } from "../components/settings/sections/PromptsSection";
import { SecretsSection } from "../components/settings/sections/SecretsSection";
import { WorktreesSection } from "../components/settings/sections/WorktreesSection";
import type { SettingsFormState } from "../components/settings/sections/context";
vi.mock("../components/AgentPromptsManager", () => ({
@@ -120,6 +121,71 @@ describe("SecretsSection", () => {
});
});
describe("WorktreesSection", () => {
const worktrunkInstall = {
status: "installed",
version: "1.0.0",
installPath: "/tmp/worktrunk",
requesting: false,
requestInstall: vi.fn(),
} as never;
it("renders editable copy-file rows with add, browse, and remove controls", () => {
const onChange = vi.fn();
const onBrowse = vi.fn();
const onRemove = vi.fn();
const onAdd = vi.fn();
render(
<WorktreesSection
scopeBanner={null}
form={{ recycleWorktrees: false, worktreeCopyFiles: [".env"] } as SettingsFormState}
setForm={vi.fn()}
gitRemotes={[]}
worktrunkInstall={worktrunkInstall}
worktrunkInstallVerified={true}
onOpenWorktreesDirPicker={vi.fn()}
onWorktreeCopyFileChange={onChange}
onRemoveWorktreeCopyFile={onRemove}
onAddWorktreeCopyFile={onAdd}
onOpenWorktreeCopyFilePicker={onBrowse}
/>,
);
expect(screen.getByText("Files to copy into new worktrees")).toBeInTheDocument();
const input = screen.getByLabelText("File to copy into new worktrees") as HTMLInputElement;
expect(input.value).toBe(".env");
fireEvent.change(input, { target: { value: "config/local.env" } });
expect(onChange).toHaveBeenCalledWith(0, "config/local.env");
fireEvent.click(screen.getByRole("button", { name: "Browse file to copy into new worktrees" }));
expect(onBrowse).toHaveBeenCalledWith(0);
fireEvent.click(screen.getByRole("button", { name: "Remove copied worktree file" }));
expect(onRemove).toHaveBeenCalledWith(0);
fireEvent.click(screen.getByRole("button", { name: "Add file" }));
expect(onAdd).toHaveBeenCalledTimes(1);
});
it("keeps an empty copy-file row reachable when the setting is undefined", () => {
render(
<WorktreesSection
scopeBanner={null}
form={{ recycleWorktrees: false, worktreeCopyFiles: undefined } as SettingsFormState}
setForm={vi.fn()}
gitRemotes={[]}
worktrunkInstall={worktrunkInstall}
worktrunkInstallVerified={true}
onOpenWorktreesDirPicker={vi.fn()}
onWorktreeCopyFileChange={vi.fn()}
onRemoveWorktreeCopyFile={vi.fn()}
onAddWorktreeCopyFile={vi.fn()}
onOpenWorktreeCopyFilePicker={vi.fn()}
/>,
);
expect(screen.getByLabelText("File to copy into new worktrees")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Browse file to copy into new worktrees" })).toBeInTheDocument();
});
});
describe("PromptsSection", () => {
it("renders the title and mounts AgentPromptsManager", () => {
render(

View File

@@ -365,6 +365,18 @@ function normalizeExperimentalFeaturesForSave(features?: Record<string, boolean>
return normalized;
}
function normalizeWorktreeCopyFilesForSave(paths?: string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const rawPath of paths ?? []) {
const path = rawPath.trim();
if (!path || seen.has(path)) continue;
seen.add(path);
normalized.push(path);
}
return normalized;
}
type LegacySectionId = "pi-extensions";
export type SectionId = SettingsSection["id"] | LegacySectionId;
@@ -709,6 +721,7 @@ export function SettingsModal({
recycleWorktrees: false,
executorAllowSiblingBranchRename: false,
worktreeNaming: "random",
worktreeCopyFiles: [],
worktreesDir: "",
worktrunk: {
enabled: false,
@@ -767,6 +780,7 @@ export function SettingsModal({
const [researchLimitError, setResearchLimitError] = useState<string | null>(null);
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
const [worktreesDirPickerOpen, setWorktreesDirPickerOpen] = useState(false);
const [worktreeCopyFilePickerIndex, setWorktreeCopyFilePickerIndex] = useState<number | null>(null);
const {
entries: overlapPathPickerEntries,
@@ -786,6 +800,15 @@ export function SettingsModal({
refresh: refreshWorktreesDirPicker,
} = useWorkspaceFileBrowser("project", worktreesDirPickerOpen, projectId);
const {
entries: worktreeCopyFilePickerEntries,
currentPath: worktreeCopyFilePickerCurrentPath,
setPath: setWorktreeCopyFilePickerPath,
loading: worktreeCopyFilePickerLoading,
error: worktreeCopyFilePickerError,
refresh: refreshWorktreeCopyFilePicker,
} = useWorkspaceFileBrowser("project", worktreeCopyFilePickerIndex !== null, projectId);
const { nodes } = useNodes();
const experimentalFeatures = form.experimentalFeatures ?? {};
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
@@ -966,6 +989,7 @@ export function SettingsModal({
mergeIntegrationWorktree: normalizeMergeIntegrationWorktreeMode(s.mergeIntegrationWorktree),
mergeAdvanceAutoSync: normalizeMergeAdvanceAutoSyncMode(s.mergeAdvanceAutoSync),
maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(s),
worktreeCopyFiles: Array.isArray(s.worktreeCopyFiles) ? s.worktreeCopyFiles : [],
};
setForm(normalizedSettings);
setInitialValues(normalizedSettings); // Store initial values to detect explicit clears
@@ -2271,6 +2295,73 @@ export function SettingsModal({
}
}, [closeWorktreesDirPicker]);
const openWorktreeCopyFilePicker = useCallback((index: number) => {
setWorktreeCopyFilePickerIndex(index);
setWorktreeCopyFilePickerPath(".");
}, [setWorktreeCopyFilePickerPath]);
const closeWorktreeCopyFilePicker = useCallback(() => {
setWorktreeCopyFilePickerIndex(null);
}, []);
const selectWorktreeCopyFile = useCallback((path: string) => {
if (worktreeCopyFilePickerIndex === null) return;
setForm((f) => {
const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0
? [...f.worktreeCopyFiles]
: [""];
currentPaths[worktreeCopyFilePickerIndex] = path;
return { ...f, worktreeCopyFiles: currentPaths };
});
closeWorktreeCopyFilePicker();
}, [worktreeCopyFilePickerIndex, closeWorktreeCopyFilePicker]);
const handleWorktreeCopyFilePickerOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
closeWorktreeCopyFilePicker();
}
}, [closeWorktreeCopyFilePicker]);
const handleWorktreeCopyFileChange = useCallback((index: number, value: string) => {
setForm((f) => {
const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0
? [...f.worktreeCopyFiles]
: [""];
currentPaths[index] = value;
return { ...f, worktreeCopyFiles: currentPaths };
});
}, []);
const handleRemoveWorktreeCopyFile = useCallback((index: number) => {
setForm((f) => {
const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0
? [...f.worktreeCopyFiles]
: [""];
const nextPaths = currentPaths.filter((_, i) => i !== index);
return { ...f, worktreeCopyFiles: nextPaths.length > 0 ? nextPaths : [] };
});
if (worktreeCopyFilePickerIndex === index) {
closeWorktreeCopyFilePicker();
return;
}
if (worktreeCopyFilePickerIndex !== null && worktreeCopyFilePickerIndex > index) {
setWorktreeCopyFilePickerIndex(worktreeCopyFilePickerIndex - 1);
}
}, [worktreeCopyFilePickerIndex, closeWorktreeCopyFilePicker]);
const handleAddWorktreeCopyFile = useCallback(() => {
setForm((f) => {
const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0
? f.worktreeCopyFiles
: [""];
return { ...f, worktreeCopyFiles: [...currentPaths, ""] };
});
}, []);
const handleOverlapIgnorePathChange = useCallback((index: number, value: string) => {
setForm((f) => {
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
@@ -2334,6 +2425,7 @@ export function SettingsModal({
setIsSaving(true);
try {
const normalizedWorktreeCopyFiles = normalizeWorktreeCopyFilesForSave(form.worktreeCopyFiles);
const payload = {
...form,
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
@@ -2348,6 +2440,9 @@ export function SettingsModal({
githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined,
githubAuthToken: form.githubAuthToken?.trim() || undefined,
overlapIgnorePaths: (form.overlapIgnorePaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0),
worktreeCopyFiles: normalizedWorktreeCopyFiles.length > 0 || initialScopedValues?.project?.worktreeCopyFiles !== undefined
? normalizedWorktreeCopyFiles
: undefined,
experimentalFeatures: normalizeExperimentalFeaturesForSave(form.experimentalFeatures),
};
@@ -2740,6 +2835,10 @@ export function SettingsModal({
worktrunkInstall={worktrunkInstall}
worktrunkInstallVerified={worktrunkInstallVerified}
onOpenWorktreesDirPicker={openWorktreesDirPicker}
onWorktreeCopyFileChange={handleWorktreeCopyFileChange}
onRemoveWorktreeCopyFile={handleRemoveWorktreeCopyFile}
onAddWorktreeCopyFile={handleAddWorktreeCopyFile}
onOpenWorktreeCopyFilePicker={openWorktreeCopyFilePicker}
onOpenApprovals={onOpenApprovals}
/>
);
@@ -3263,6 +3362,53 @@ export function SettingsModal({
</div>
</div>
)}
{worktreeCopyFilePickerIndex !== null && (
<div
className="modal-overlay open"
onClick={handleWorktreeCopyFilePickerOverlayClick}
role="dialog"
aria-modal="true"
aria-label={t("settings.worktrees.browseCopyFile", "Browse file to copy into new worktrees")}
>
<div className="modal modal-lg settings-overlap-path-picker-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>{t("settings.worktrees.selectCopyFile", "Select file to copy")}</h3>
<button className="modal-close" onClick={closeWorktreeCopyFilePicker} aria-label={t("actions.close", "Close")}>
&times;
</button>
</div>
<div className="modal-body settings-overlap-path-picker-body">
<p className="settings-overlap-path-picker-note">
{t("settings.worktrees.copyFilePickerNote", "Choose a repository file to copy into each newly assigned task worktree. Directories are not selected from this picker.")}
</p>
<FileBrowser
entries={worktreeCopyFilePickerEntries}
currentPath={worktreeCopyFilePickerCurrentPath}
onSelectFile={selectWorktreeCopyFile}
onNavigate={setWorktreeCopyFilePickerPath}
loading={worktreeCopyFilePickerLoading}
error={worktreeCopyFilePickerError}
onRetry={refreshWorktreeCopyFilePicker}
workspace="project"
projectId={projectId}
/>
</div>
<div className="modal-actions">
<div className="modal-actions-left">
<small>
{t("settings.fileBrowser.currentDirectory", "Current directory:")} <code>{worktreeCopyFilePickerCurrentPath === "." ? t("settings.fileBrowser.projectRoot", "(project root)") : worktreeCopyFilePickerCurrentPath}</code>
</small>
</div>
<div className="modal-actions-right">
<button className="btn btn-sm" onClick={closeWorktreeCopyFilePicker}>
{t("settings.actions.cancel", "Cancel")}
</button>
</div>
</div>
</div>
</div>
)}
{/* Import Confirmation Dialog */}
{importDialogOpen && importPreview && (

View File

@@ -215,6 +215,7 @@ const defaultSettings = {
ephemeralAgentsEnabled: true,
executorAllowSiblingBranchRename: false,
worktreeNaming: "random",
worktreeCopyFiles: [],
worktreesDir: "",
worktrunk: {
enabled: false,
@@ -3092,6 +3093,96 @@ describe("SettingsModal", () => {
expect(payload.worktreesDir).toBe("~/.fn-worktrees/{repo}");
});
it("adds, browses, de-duplicates, and saves worktree copy files", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
worktreeCopyFiles: [".env"],
});
mockFetchSettingsByScope.mockResolvedValue({
global: defaultSettings,
project: { worktreeCopyFiles: [".env"] },
});
renderModal({ initialSection: "worktrees" });
await waitForSettingsModalReady();
expect(screen.getByDisplayValue(".env")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Add file" }));
const inputs = screen.getAllByLabelText("File to copy into new worktrees") as HTMLInputElement[];
await userEvent.type(inputs[1], " .env ");
await userEvent.click(screen.getAllByRole("button", { name: "Browse file to copy into new worktrees" })[1]);
expect(await screen.findByRole("dialog", { name: "Browse file to copy into new worktrees" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Select README.md" }));
await userEvent.click(screen.getByRole("button", { name: "Add file" }));
const updatedInputs = screen.getAllByLabelText("File to copy into new worktrees") as HTMLInputElement[];
await userEvent.type(updatedInputs[2], " README.md ");
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled());
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.worktreeCopyFiles).toEqual([".env", "README.md"]);
});
it("clears worktree copy files to an empty persisted list", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
worktreeCopyFiles: [".env"],
});
mockFetchSettingsByScope.mockResolvedValue({
global: defaultSettings,
project: { worktreeCopyFiles: [".env"] },
});
renderModal({ initialSection: "worktrees" });
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: "Remove copied worktree file" }));
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled());
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.worktreeCopyFiles).toEqual([]);
});
it("exposes worktree copy file controls via the mobile Settings Section picker", async () => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
mockFetchSettings.mockResolvedValue({
...defaultSettings,
worktreeCopyFiles: [".env.local"],
});
mockFetchSettingsByScope.mockResolvedValue({
global: defaultSettings,
project: { worktreeCopyFiles: [".env.local"] },
});
renderModal();
await waitForSettingsModalReady();
const sectionPicker = screen.getByLabelText("Settings Section") as HTMLSelectElement;
expect(sectionPicker).toBeInTheDocument();
expect(sectionPicker.querySelector('option[value="worktrees"]')).toHaveTextContent("Worktrees");
await userEvent.selectOptions(sectionPicker, "worktrees");
expect(screen.getByText("Files to copy into new worktrees")).toBeInTheDocument();
expect(screen.getByDisplayValue(".env.local")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Browse file to copy into new worktrees" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add file" })).toBeInTheDocument();
});
it("allows clearing maxWorktrees without leaving a stuck zero", async () => { renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());

View File

@@ -9,10 +9,15 @@ export interface WorktreesSectionProps extends SectionBaseProps {
worktrunkInstall: ReturnType<typeof useWorktrunkInstallStatus>;
worktrunkInstallVerified: boolean;
onOpenWorktreesDirPicker: () => void;
onWorktreeCopyFileChange: (index: number, value: string) => void;
onRemoveWorktreeCopyFile: (index: number) => void;
onAddWorktreeCopyFile: () => void;
onOpenWorktreeCopyFilePicker: (index: number) => void;
onOpenApprovals?: (approvalId?: string) => void;
}
export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, worktrunkInstall, worktrunkInstallVerified, onOpenWorktreesDirPicker, onOpenApprovals, }: WorktreesSectionProps) {
export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, worktrunkInstall, worktrunkInstallVerified, onOpenWorktreesDirPicker, onWorktreeCopyFileChange, onRemoveWorktreeCopyFile, onAddWorktreeCopyFile, onOpenWorktreeCopyFilePicker, onOpenApprovals, }: WorktreesSectionProps) {
const { t } = useTranslation("app");
const worktreeCopyFileRows = (form.worktreeCopyFiles?.length ?? 0) > 0 ? form.worktreeCopyFiles ?? [] : [""];
return (<>
{scopeBanner}
<h4 className="settings-section-heading">{t("settings.worktrees.worktrees", "Worktrees")}</h4>
@@ -34,6 +39,50 @@ export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, workt
<input id="recycleWorktrees" type="checkbox" checked={form.recycleWorktrees} onChange={(e) => setForm((f) => ({ ...f, recycleWorktrees: e.target.checked }))}/>{t("settings.worktrees.recycleWorktrees", " Recycle worktrees ")}</label>
<small>{t("settings.worktrees.offByDefaultOptInWhenEnabledCompleted", "Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup")}</small>
</div>
<div className="form-group">
<label>{t("settings.worktrees.filesToCopyIntoNewWorktrees", "Files to copy into new worktrees")}</label>
{/*
FNXC:WorktreeCopyFiles 2026-06-24-00:00:
Users need a visible, editable allowlist for repository files such as `.env` that Fusion copies into freshly prepared task worktrees. The UI preserves blank rows while editing, but save normalization trims, removes blanks, and de-duplicates before persistence.
*/}
<div className="settings-overlap-ignore-list" data-testid="worktree-copy-files-list">
{worktreeCopyFileRows.map((path, index) => (
<div className="settings-overlap-ignore-row" key={index}>
<div className="settings-overlap-ignore-path-controls">
<input
id={`worktreeCopyFile-${index}`}
type="text"
className="input"
placeholder={t("settings.worktrees.copyFilePlaceholder", ".env")}
value={path}
onChange={(e) => onWorktreeCopyFileChange(index, e.target.value)}
aria-label={t("settings.worktrees.copyFilePathLabel", "File to copy into new worktrees")}
/>
<button
type="button"
className="btn btn-sm"
onClick={() => onOpenWorktreeCopyFilePicker(index)}
aria-label={t("settings.worktrees.browseCopyFile", "Browse file to copy into new worktrees")}
>
{t("settings.worktrees.browse", " Browse ")}
</button>
</div>
<button
type="button"
className="btn btn-sm"
onClick={() => onRemoveWorktreeCopyFile(index)}
aria-label={t("settings.worktrees.removeCopyFile", "Remove copied worktree file")}
>
{t("settings.worktrees.remove", "Remove")}
</button>
</div>
))}
</div>
<button type="button" className="btn btn-sm" onClick={onAddWorktreeCopyFile}>
{t("settings.worktrees.addCopyFile", "Add file")}
</button>
<small>{t("settings.worktrees.copyFilesHelp", "Optional. Repository-root-relative regular files are copied into fresh or pooled task worktrees before init commands run. Missing files or directories are skipped without exposing contents.")}</small>
</div>
<div className="form-group">
<label htmlFor="executorAllowSiblingBranchRename" className="checkbox-label">
<input id="executorAllowSiblingBranchRename" type="checkbox" checked={form.executorAllowSiblingBranchRename === true} onChange={(e) => setForm((f) => ({ ...f, executorAllowSiblingBranchRename: e.target.checked }))}/>{t("settings.worktrees.allowSilentSiblingBranchRenameDuringExecutorConflicts", " Allow silent sibling branch rename during executor conflicts ")}</label>

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { execSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
@@ -471,6 +471,29 @@ describe("acquireTaskWorktree", () => {
expect(cleanupOrder).toBeLessThan(initOrder);
});
it("copies configured files for fresh acquisition before init command", async () => {
const rootDir = track(mkdtempSync(join(tmpdir(), "fn-copy-fresh-root-")));
const worktreePath = track(mkdtempSync(join(tmpdir(), "fn-copy-fresh-worktree-")));
writeFileSync(join(rootDir, ".env"), "SECRET=redacted\n", "utf-8");
const runConfiguredCommand = vi.fn().mockImplementation(async () => {
expect(readFileSync(join(worktreePath, ".env"), "utf-8")).toBe("SECRET=redacted\n");
return { exitCode: 0, stderr: "", stdout: "" };
});
await acquireTaskWorktree({
task,
rootDir,
store,
settings: { worktreeCopyFiles: [".env"], worktreeInitCommand: "pnpm install" } as any,
createWorktree: vi.fn().mockResolvedValue({ path: worktreePath, branch: "fusion/fn-1" }),
runConfiguredCommand,
runInitCommand: true,
});
expect(readFileSync(join(worktreePath, ".env"), "utf-8")).toBe("SECRET=redacted\n");
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Copied configured worktree files into fresh worktree: .env", undefined, undefined);
});
it("invokes desktop artifact cleanup once for pooled acquisition", async () => {
const runConfiguredCommand = vi.fn();
@@ -494,6 +517,50 @@ describe("acquireTaskWorktree", () => {
expect(runConfiguredCommand).not.toHaveBeenCalled();
});
it("copies configured files into pooled worktrees after preparation", async () => {
const rootDir = track(mkdtempSync(join(tmpdir(), "fn-copy-pool-root-")));
const worktreePath = track(mkdtempSync(join(tmpdir(), "fn-copy-pool-worktree-")));
writeFileSync(join(rootDir, ".env"), "POOL=updated\n", "utf-8");
writeFileSync(join(worktreePath, ".env"), "POOL=old\n", "utf-8");
const result = await acquireTaskWorktree({
task,
rootDir,
store,
settings: { recycleWorktrees: true, worktreeCopyFiles: [".env"] } as any,
pool: {
acquire: (_taskId: string) => worktreePath,
prepareForTask: vi.fn().mockResolvedValue({ branch: "fusion/fn-1", worktreePath, reclaimed: false }),
release: vi.fn(),
} as any,
createWorktree: vi.fn(),
});
expect(result.source).toBe("pool");
expect(readFileSync(join(worktreePath, ".env"), "utf-8")).toBe("POOL=updated\n");
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Copied configured worktree files into pool worktree: .env", undefined, undefined);
});
it("does not copy configured files over resumed worktree state", async () => {
const rootDir = track(mkdtempSync(join(tmpdir(), "fn-copy-resume-root-")));
const worktreePath = track(mkdtempSync(join(tmpdir(), "fn-copy-resume-worktree-")));
writeFileSync(join(rootDir, ".env"), "ROOT=updated\n", "utf-8");
writeFileSync(join(worktreePath, ".env"), "RESUME=keep\n", "utf-8");
const result = await acquireTaskWorktree({
task: { ...task, worktree: worktreePath, branch: "fusion/fn-1" },
rootDir,
store,
settings: { worktreeCopyFiles: [".env"] } as any,
createWorktree: vi.fn(),
});
expect(result.source).toBe("existing");
expect(readFileSync(join(worktreePath, ".env"), "utf-8")).toBe("RESUME=keep\n");
expect(store.logEntry).not.toHaveBeenCalledWith("FN-1", expect.stringContaining("Copied configured worktree files"), undefined, undefined);
expect(existsSync(join(worktreePath, ".env"))).toBe(true);
});
it("FN-4834: logs worktree init stderr in task log outcome", async () => {
const runConfiguredCommand = vi.fn().mockResolvedValue({
exitCode: 1,

View File

@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { copyConfiguredWorktreeFiles } from "../worktree-copy-files.js";
const cleanupPaths: string[] = [];
function makeDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
cleanupPaths.push(dir);
return dir;
}
afterEach(() => {
for (const path of cleanupPaths.splice(0)) {
rmSync(path, { recursive: true, force: true });
}
});
describe("copyConfiguredWorktreeFiles", () => {
it("copies configured regular files to the same relative worktree path", async () => {
const rootDir = makeDir("fn-copy-root-");
const worktreePath = makeDir("fn-copy-worktree-");
mkdirSync(join(rootDir, "config"), { recursive: true });
writeFileSync(join(rootDir, ".env"), "SECRET=redacted\n", "utf-8");
writeFileSync(join(rootDir, "config", "local.env"), "LOCAL=1\n", "utf-8");
const results = await copyConfiguredWorktreeFiles({
rootDir,
worktreePath,
paths: [".env", "config/local.env"],
taskId: "FN-6943",
});
expect(results.map((result) => result.outcome)).toEqual(["copied", "copied"]);
expect(readFileSync(join(worktreePath, ".env"), "utf-8")).toBe("SECRET=redacted\n");
expect(readFileSync(join(worktreePath, "config", "local.env"), "utf-8")).toBe("LOCAL=1\n");
});
it("skips blank and duplicate entries without extra writes", async () => {
const rootDir = makeDir("fn-copy-root-");
const worktreePath = makeDir("fn-copy-worktree-");
writeFileSync(join(rootDir, ".env"), "first\n", "utf-8");
const results = await copyConfiguredWorktreeFiles({
rootDir,
worktreePath,
paths: ["", " .env ", ".env"],
taskId: "FN-6943",
});
expect(results.map((result) => result.reason ?? result.outcome)).toEqual(["blank", "copied", "duplicate"]);
expect(readFileSync(join(worktreePath, ".env"), "utf-8")).toBe("first\n");
});
it("rejects absolute and traversal paths before reading or writing", async () => {
const rootDir = makeDir("fn-copy-root-");
const worktreePath = makeDir("fn-copy-worktree-");
const logger = { warn: vi.fn() };
const results = await copyConfiguredWorktreeFiles({
rootDir,
worktreePath,
paths: ["/tmp/outside.env", "../outside.env", "nested/../../outside.env"],
taskId: "FN-6943",
logger,
});
expect(results.map((result) => result.reason)).toEqual(["absolute-path", "path-traversal", "path-traversal"]);
expect(existsSync(join(worktreePath, "outside.env"))).toBe(false);
expect(logger.warn).toHaveBeenCalledTimes(3);
});
it("skips missing sources and directories as non-fatal diagnostics", async () => {
const rootDir = makeDir("fn-copy-root-");
const worktreePath = makeDir("fn-copy-worktree-");
mkdirSync(join(rootDir, "config"), { recursive: true });
const results = await copyConfiguredWorktreeFiles({
rootDir,
worktreePath,
paths: ["missing.env", "config"],
taskId: "FN-6943",
});
expect(results.map((result) => result.reason)).toEqual(["missing", "non-regular"]);
expect(existsSync(join(worktreePath, "missing.env"))).toBe(false);
expect(existsSync(join(worktreePath, "config"))).toBe(false);
});
it("emits audit events without exposing file contents", async () => {
const rootDir = makeDir("fn-copy-root-");
const worktreePath = makeDir("fn-copy-worktree-");
writeFileSync(join(rootDir, ".env"), "SECRET=redacted\n", "utf-8");
const filesystem = vi.fn().mockResolvedValue(undefined);
await copyConfiguredWorktreeFiles({
rootDir,
worktreePath,
paths: [".env"],
taskId: "FN-6943",
audit: { filesystem },
});
expect(filesystem).toHaveBeenCalledWith(expect.objectContaining({
type: "worktree:copy-file",
target: "FN-6943",
metadata: expect.objectContaining({ path: ".env", outcome: "copied" }),
}));
expect(JSON.stringify(filesystem.mock.calls)).not.toContain("SECRET=redacted");
});
});

View File

@@ -736,6 +736,10 @@ export function assertNoSecretPlaintext(metadata?: Record<string, unknown>): voi
* - secret:env-write-skipped -> { filename, reason: "disabled"|"no-secrets"|"not-gitignored"|"skip-existing"|"invalid-filename"|"no-store"|"list-failed", overwritePolicy?, checkIgnoreError?, symlink? }
* - secret:env-cleanup -> { filename, fingerprint, reason: "fingerprint-match"|"directory-missing" }
* - secret:env-cleanup-skipped -> { filename, reason: "fingerprint-mismatch"|"file-missing"|"no-record"|"disabled"|"stat-failed", checkError? }
* - worktree:copy-file / worktree:copy-file-skipped -> { path, outcome, reason?, error? } (never file contents)
*
* FNXC:WorktreeCopyFiles 2026-06-24-00:00:
* Worktree copy-file audit events are filesystem-domain setup diagnostics and must carry only paths/reasons, never `.env` contents or copied file bytes.
*/
export type FilesystemMutationType =
| "file:write"
@@ -751,6 +755,8 @@ export type FilesystemMutationType =
| "binary:install-success"
| "binary:install-failed"
| "binary:install-denied"
| "worktree:copy-file"
| "worktree:copy-file-skipped"
| (typeof SECRET_MUTATION_TYPES)[number];
export type SandboxMutationType = "sandbox:prepare" | "sandbox:run" | "sandbox:failure" | "sandbox:fallback";

View File

@@ -36,6 +36,7 @@ import type { RunAuditor } from "./run-audit.js";
import { writeSecretsEnvFile } from "./secrets-env-writer.js";
import { removeDesktopBuildArtifacts } from "./worktree-desktop-artifacts.js";
import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js";
import { copyConfiguredWorktreeFiles, type WorktreeCopyFileResult } from "./worktree-copy-files.js";
import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { activeSessionRegistry, type ActiveSessionRegistry } from "./active-session-registry.js";
@@ -307,8 +308,34 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
}
};
const logConfiguredCopyFileResults = async (results: WorktreeCopyFileResult[], source: "fresh" | "pool") => {
if (results.length === 0) return;
const copied = results.filter((result) => result.outcome === "copied");
const skipped = results.filter((result) => result.outcome === "skipped" && result.reason !== "blank" && result.reason !== "duplicate");
if (copied.length > 0) {
await store.logEntry(task.id, `Copied configured worktree files into ${source} worktree: ${copied.map((result) => result.path).join(", ")}`, undefined, runContext);
}
for (const result of skipped) {
await store.logEntry(task.id, `Skipped configured worktree copy file ${result.path}: ${result.reason ?? "unknown"}`, undefined, runContext);
}
};
const copyConfiguredFilesForPreparedWorktree = async (source: "fresh" | "pool") => {
const preparedWorktreePath = worktreePath;
if (!preparedWorktreePath) return;
const results = await copyConfiguredWorktreeFiles({
rootDir,
worktreePath: preparedWorktreePath,
paths: settings.worktreeCopyFiles,
taskId: task.id,
logger,
audit,
});
await logConfiguredCopyFileResults(results, source);
};
const emitRepoRootReturnGuardAudit = async (guardedPath: string, source: string) => {
await audit?.git({
await audit?.git({
type: "worktree:incomplete-detected",
target: guardedPath,
metadata: {
@@ -355,6 +382,8 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
await store.logEntry(task.id, `Removed desktop build artifacts from worktree: ${cleanup.removed.join(", ")}`, undefined, runContext);
}
await copyConfiguredFilesForPreparedWorktree(source);
if (runInitCommand && settings.worktreeInitCommand && runConfiguredCommand) {
const initStartedAt = Date.now();
let initResult: InitCommandResult | undefined;
@@ -516,6 +545,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
if (cleanup.removed.length > 0) {
await store.logEntry(task.id, `Removed desktop build artifacts from worktree: ${cleanup.removed.join(", ")}`, undefined, runContext);
}
await copyConfiguredFilesForPreparedWorktree("pool");
await maybeWarnForeignTaskStartPoint({
baseBranch,
rootDir,

View File

@@ -0,0 +1,147 @@
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;
}