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:
7
.changeset/fn-6943-worktree-copy-files.md
Normal file
7
.changeset/fn-6943-worktree-copy-files.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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}" });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")}>
|
||||
×
|
||||
</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 && (
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
112
packages/engine/src/__tests__/worktree-copy-files.test.ts
Normal file
112
packages/engine/src/__tests__/worktree-copy-files.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
147
packages/engine/src/worktree-copy-files.ts
Normal file
147
packages/engine/src/worktree-copy-files.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user