diff --git a/.changeset/fn-6943-worktree-copy-files.md b/.changeset/fn-6943-worktree-copy-files.md
new file mode 100644
index 0000000000..3cc70ddd3f
--- /dev/null
+++ b/.changeset/fn-6943-worktree-copy-files.md
@@ -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.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 3bc881dd87..39d2a91006 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -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.
diff --git a/docs/settings-reference.md b/docs/settings-reference.md
index 28f96089b7..e33d26d115 100644
--- a/docs/settings-reference.md
+++ b/docs/settings-reference.md
@@ -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. |
diff --git a/packages/core/src/__tests__/store-settings.test.ts b/packages/core/src/__tests__/store-settings.test.ts
index 4464dce2c7..b4090ab48f 100644
--- a/packages/core/src/__tests__/store-settings.test.ts
+++ b/packages/core/src/__tests__/store-settings.test.ts
@@ -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}" });
diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts
index a296a22bdd..adb2ecb701 100644
--- a/packages/core/src/settings-schema.ts
+++ b/packages/core/src/settings-schema.ts
@@ -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,
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index c90f437082..957af522d5 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -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") */
diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx
index 70b8bd10ab..2b33fcedd5 100644
--- a/packages/dashboard/app/__tests__/settings-sections.test.tsx
+++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx
index 28d6d01ed3..dc3ed05ef2 100644
--- a/packages/dashboard/app/components/SettingsModal.tsx
+++ b/packages/dashboard/app/components/SettingsModal.tsx
@@ -365,6 +365,18 @@ function normalizeExperimentalFeaturesForSave(features?: Record
return normalized;
}
+function normalizeWorktreeCopyFilesForSave(paths?: string[]): string[] {
+ const seen = new Set();
+ 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(null);
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState(null);
const [worktreesDirPickerOpen, setWorktreesDirPickerOpen] = useState(false);
+ const [worktreeCopyFilePickerIndex, setWorktreeCopyFilePickerIndex] = useState(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) => {
+ 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({
)}
+
+ {worktreeCopyFilePickerIndex !== null && (
+
+
event.stopPropagation()}>
+
+
{t("settings.worktrees.selectCopyFile", "Select file to copy")}
+
+
+
+
+ {t("settings.worktrees.copyFilePickerNote", "Choose a repository file to copy into each newly assigned task worktree. Directories are not selected from this picker.")}
+
@@ -34,6 +39,50 @@ export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, workt
setForm((f) => ({ ...f, recycleWorktrees: e.target.checked }))}/>{t("settings.worktrees.recycleWorktrees", " Recycle worktrees ")}
{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")}
+
+
+ {/*
+ 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.
+ */}
+
+ {worktreeCopyFileRows.map((path, index) => (
+
+
+ onWorktreeCopyFileChange(index, e.target.value)}
+ aria-label={t("settings.worktrees.copyFilePathLabel", "File to copy into new worktrees")}
+ />
+
+
+
+
+ ))}
+
+
+ {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.")}
+