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).
|
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 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.
|
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. |
|
| `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. |
|
| `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). |
|
| `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. |
|
| `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). |
|
| `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). |
|
||||||
| `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. |
|
| `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", () => {
|
describe("worktreesDir setting", () => {
|
||||||
it("round-trips worktreesDir via updateSettings and project serialization", async () => {
|
it("round-trips worktreesDir via updateSettings and project serialization", async () => {
|
||||||
await harness.store().updateSettings({ worktreesDir: "~/.fn-worktrees/{repo}" });
|
await harness.store().updateSettings({ worktreesDir: "~/.fn-worktrees/{repo}" });
|
||||||
|
|||||||
@@ -295,6 +295,11 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
|||||||
defaultNodeId: undefined,
|
defaultNodeId: undefined,
|
||||||
secretsEnv: undefined,
|
secretsEnv: undefined,
|
||||||
worktreeInitCommand: 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,
|
testCommand: undefined,
|
||||||
buildCommand: undefined,
|
buildCommand: undefined,
|
||||||
recycleWorktrees: false,
|
recycleWorktrees: false,
|
||||||
|
|||||||
@@ -3778,6 +3778,13 @@ export interface ProjectSettings {
|
|||||||
/** Shell command to run inside each new worktree immediately after creation.
|
/** 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`). */
|
* Useful for project-specific setup (e.g. `pnpm install --frozen-lockfile`, `cp .env.local .env`). */
|
||||||
worktreeInitCommand?: string;
|
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") */
|
/** Custom test command for the project (e.g. "pnpm test") */
|
||||||
testCommand?: string;
|
testCommand?: string;
|
||||||
/** Custom build command for the project (e.g. "pnpm build") */
|
/** 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 { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub";
|
||||||
import { PromptsSection } from "../components/settings/sections/PromptsSection";
|
import { PromptsSection } from "../components/settings/sections/PromptsSection";
|
||||||
import { SecretsSection } from "../components/settings/sections/SecretsSection";
|
import { SecretsSection } from "../components/settings/sections/SecretsSection";
|
||||||
|
import { WorktreesSection } from "../components/settings/sections/WorktreesSection";
|
||||||
import type { SettingsFormState } from "../components/settings/sections/context";
|
import type { SettingsFormState } from "../components/settings/sections/context";
|
||||||
|
|
||||||
vi.mock("../components/AgentPromptsManager", () => ({
|
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", () => {
|
describe("PromptsSection", () => {
|
||||||
it("renders the title and mounts AgentPromptsManager", () => {
|
it("renders the title and mounts AgentPromptsManager", () => {
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -365,6 +365,18 @@ function normalizeExperimentalFeaturesForSave(features?: Record<string, boolean>
|
|||||||
return normalized;
|
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";
|
type LegacySectionId = "pi-extensions";
|
||||||
export type SectionId = SettingsSection["id"] | LegacySectionId;
|
export type SectionId = SettingsSection["id"] | LegacySectionId;
|
||||||
|
|
||||||
@@ -709,6 +721,7 @@ export function SettingsModal({
|
|||||||
recycleWorktrees: false,
|
recycleWorktrees: false,
|
||||||
executorAllowSiblingBranchRename: false,
|
executorAllowSiblingBranchRename: false,
|
||||||
worktreeNaming: "random",
|
worktreeNaming: "random",
|
||||||
|
worktreeCopyFiles: [],
|
||||||
worktreesDir: "",
|
worktreesDir: "",
|
||||||
worktrunk: {
|
worktrunk: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -767,6 +780,7 @@ export function SettingsModal({
|
|||||||
const [researchLimitError, setResearchLimitError] = useState<string | null>(null);
|
const [researchLimitError, setResearchLimitError] = useState<string | null>(null);
|
||||||
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
|
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
|
||||||
const [worktreesDirPickerOpen, setWorktreesDirPickerOpen] = useState(false);
|
const [worktreesDirPickerOpen, setWorktreesDirPickerOpen] = useState(false);
|
||||||
|
const [worktreeCopyFilePickerIndex, setWorktreeCopyFilePickerIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
entries: overlapPathPickerEntries,
|
entries: overlapPathPickerEntries,
|
||||||
@@ -786,6 +800,15 @@ export function SettingsModal({
|
|||||||
refresh: refreshWorktreesDirPicker,
|
refresh: refreshWorktreesDirPicker,
|
||||||
} = useWorkspaceFileBrowser("project", worktreesDirPickerOpen, projectId);
|
} = 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 { nodes } = useNodes();
|
||||||
const experimentalFeatures = form.experimentalFeatures ?? {};
|
const experimentalFeatures = form.experimentalFeatures ?? {};
|
||||||
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
|
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
|
||||||
@@ -966,6 +989,7 @@ export function SettingsModal({
|
|||||||
mergeIntegrationWorktree: normalizeMergeIntegrationWorktreeMode(s.mergeIntegrationWorktree),
|
mergeIntegrationWorktree: normalizeMergeIntegrationWorktreeMode(s.mergeIntegrationWorktree),
|
||||||
mergeAdvanceAutoSync: normalizeMergeAdvanceAutoSyncMode(s.mergeAdvanceAutoSync),
|
mergeAdvanceAutoSync: normalizeMergeAdvanceAutoSyncMode(s.mergeAdvanceAutoSync),
|
||||||
maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(s),
|
maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(s),
|
||||||
|
worktreeCopyFiles: Array.isArray(s.worktreeCopyFiles) ? s.worktreeCopyFiles : [],
|
||||||
};
|
};
|
||||||
setForm(normalizedSettings);
|
setForm(normalizedSettings);
|
||||||
setInitialValues(normalizedSettings); // Store initial values to detect explicit clears
|
setInitialValues(normalizedSettings); // Store initial values to detect explicit clears
|
||||||
@@ -2271,6 +2295,73 @@ export function SettingsModal({
|
|||||||
}
|
}
|
||||||
}, [closeWorktreesDirPicker]);
|
}, [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) => {
|
const handleOverlapIgnorePathChange = useCallback((index: number, value: string) => {
|
||||||
setForm((f) => {
|
setForm((f) => {
|
||||||
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
|
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
|
||||||
@@ -2334,6 +2425,7 @@ export function SettingsModal({
|
|||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const normalizedWorktreeCopyFiles = normalizeWorktreeCopyFilesForSave(form.worktreeCopyFiles);
|
||||||
const payload = {
|
const payload = {
|
||||||
...form,
|
...form,
|
||||||
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
|
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
|
||||||
@@ -2348,6 +2440,9 @@ export function SettingsModal({
|
|||||||
githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined,
|
githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined,
|
||||||
githubAuthToken: form.githubAuthToken?.trim() || undefined,
|
githubAuthToken: form.githubAuthToken?.trim() || undefined,
|
||||||
overlapIgnorePaths: (form.overlapIgnorePaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0),
|
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),
|
experimentalFeatures: normalizeExperimentalFeaturesForSave(form.experimentalFeatures),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2740,6 +2835,10 @@ export function SettingsModal({
|
|||||||
worktrunkInstall={worktrunkInstall}
|
worktrunkInstall={worktrunkInstall}
|
||||||
worktrunkInstallVerified={worktrunkInstallVerified}
|
worktrunkInstallVerified={worktrunkInstallVerified}
|
||||||
onOpenWorktreesDirPicker={openWorktreesDirPicker}
|
onOpenWorktreesDirPicker={openWorktreesDirPicker}
|
||||||
|
onWorktreeCopyFileChange={handleWorktreeCopyFileChange}
|
||||||
|
onRemoveWorktreeCopyFile={handleRemoveWorktreeCopyFile}
|
||||||
|
onAddWorktreeCopyFile={handleAddWorktreeCopyFile}
|
||||||
|
onOpenWorktreeCopyFilePicker={openWorktreeCopyFilePicker}
|
||||||
onOpenApprovals={onOpenApprovals}
|
onOpenApprovals={onOpenApprovals}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -3263,6 +3362,53 @@ export function SettingsModal({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Import Confirmation Dialog */}
|
||||||
{importDialogOpen && importPreview && (
|
{importDialogOpen && importPreview && (
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ const defaultSettings = {
|
|||||||
ephemeralAgentsEnabled: true,
|
ephemeralAgentsEnabled: true,
|
||||||
executorAllowSiblingBranchRename: false,
|
executorAllowSiblingBranchRename: false,
|
||||||
worktreeNaming: "random",
|
worktreeNaming: "random",
|
||||||
|
worktreeCopyFiles: [],
|
||||||
worktreesDir: "",
|
worktreesDir: "",
|
||||||
worktrunk: {
|
worktrunk: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -3092,6 +3093,96 @@ describe("SettingsModal", () => {
|
|||||||
expect(payload.worktreesDir).toBe("~/.fn-worktrees/{repo}");
|
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();
|
it("allows clearing maxWorktrees without leaving a stuck zero", async () => { renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ export interface WorktreesSectionProps extends SectionBaseProps {
|
|||||||
worktrunkInstall: ReturnType<typeof useWorktrunkInstallStatus>;
|
worktrunkInstall: ReturnType<typeof useWorktrunkInstallStatus>;
|
||||||
worktrunkInstallVerified: boolean;
|
worktrunkInstallVerified: boolean;
|
||||||
onOpenWorktreesDirPicker: () => void;
|
onOpenWorktreesDirPicker: () => void;
|
||||||
|
onWorktreeCopyFileChange: (index: number, value: string) => void;
|
||||||
|
onRemoveWorktreeCopyFile: (index: number) => void;
|
||||||
|
onAddWorktreeCopyFile: () => void;
|
||||||
|
onOpenWorktreeCopyFilePicker: (index: number) => void;
|
||||||
onOpenApprovals?: (approvalId?: string) => 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 { t } = useTranslation("app");
|
||||||
|
const worktreeCopyFileRows = (form.worktreeCopyFiles?.length ?? 0) > 0 ? form.worktreeCopyFiles ?? [] : [""];
|
||||||
return (<>
|
return (<>
|
||||||
{scopeBanner}
|
{scopeBanner}
|
||||||
<h4 className="settings-section-heading">{t("settings.worktrees.worktrees", "Worktrees")}</h4>
|
<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>
|
<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>
|
<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>
|
||||||
|
<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">
|
<div className="form-group">
|
||||||
<label htmlFor="executorAllowSiblingBranchRename" className="checkbox-label">
|
<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>
|
<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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { execSync } from "node:child_process";
|
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 { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
@@ -471,6 +471,29 @@ describe("acquireTaskWorktree", () => {
|
|||||||
expect(cleanupOrder).toBeLessThan(initOrder);
|
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 () => {
|
it("invokes desktop artifact cleanup once for pooled acquisition", async () => {
|
||||||
const runConfiguredCommand = vi.fn();
|
const runConfiguredCommand = vi.fn();
|
||||||
|
|
||||||
@@ -494,6 +517,50 @@ describe("acquireTaskWorktree", () => {
|
|||||||
expect(runConfiguredCommand).not.toHaveBeenCalled();
|
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 () => {
|
it("FN-4834: logs worktree init stderr in task log outcome", async () => {
|
||||||
const runConfiguredCommand = vi.fn().mockResolvedValue({
|
const runConfiguredCommand = vi.fn().mockResolvedValue({
|
||||||
exitCode: 1,
|
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-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 -> { filename, fingerprint, reason: "fingerprint-match"|"directory-missing" }
|
||||||
* - secret:env-cleanup-skipped -> { filename, reason: "fingerprint-mismatch"|"file-missing"|"no-record"|"disabled"|"stat-failed", checkError? }
|
* - 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 =
|
export type FilesystemMutationType =
|
||||||
| "file:write"
|
| "file:write"
|
||||||
@@ -751,6 +755,8 @@ export type FilesystemMutationType =
|
|||||||
| "binary:install-success"
|
| "binary:install-success"
|
||||||
| "binary:install-failed"
|
| "binary:install-failed"
|
||||||
| "binary:install-denied"
|
| "binary:install-denied"
|
||||||
|
| "worktree:copy-file"
|
||||||
|
| "worktree:copy-file-skipped"
|
||||||
| (typeof SECRET_MUTATION_TYPES)[number];
|
| (typeof SECRET_MUTATION_TYPES)[number];
|
||||||
|
|
||||||
export type SandboxMutationType = "sandbox:prepare" | "sandbox:run" | "sandbox:failure" | "sandbox:fallback";
|
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 { writeSecretsEnvFile } from "./secrets-env-writer.js";
|
||||||
import { removeDesktopBuildArtifacts } from "./worktree-desktop-artifacts.js";
|
import { removeDesktopBuildArtifacts } from "./worktree-desktop-artifacts.js";
|
||||||
import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js";
|
import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js";
|
||||||
|
import { copyConfiguredWorktreeFiles, type WorktreeCopyFileResult } from "./worktree-copy-files.js";
|
||||||
import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js";
|
import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js";
|
||||||
import { resolveIntegrationBranch } from "./integration-branch.js";
|
import { resolveIntegrationBranch } from "./integration-branch.js";
|
||||||
import { activeSessionRegistry, type ActiveSessionRegistry } from "./active-session-registry.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) => {
|
const emitRepoRootReturnGuardAudit = async (guardedPath: string, source: string) => {
|
||||||
await audit?.git({
|
await audit?.git({
|
||||||
type: "worktree:incomplete-detected",
|
type: "worktree:incomplete-detected",
|
||||||
target: guardedPath,
|
target: guardedPath,
|
||||||
metadata: {
|
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 store.logEntry(task.id, `Removed desktop build artifacts from worktree: ${cleanup.removed.join(", ")}`, undefined, runContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await copyConfiguredFilesForPreparedWorktree(source);
|
||||||
|
|
||||||
if (runInitCommand && settings.worktreeInitCommand && runConfiguredCommand) {
|
if (runInitCommand && settings.worktreeInitCommand && runConfiguredCommand) {
|
||||||
const initStartedAt = Date.now();
|
const initStartedAt = Date.now();
|
||||||
let initResult: InitCommandResult | undefined;
|
let initResult: InitCommandResult | undefined;
|
||||||
@@ -516,6 +545,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
|
|||||||
if (cleanup.removed.length > 0) {
|
if (cleanup.removed.length > 0) {
|
||||||
await store.logEntry(task.id, `Removed desktop build artifacts from worktree: ${cleanup.removed.join(", ")}`, undefined, runContext);
|
await store.logEntry(task.id, `Removed desktop build artifacts from worktree: ${cleanup.removed.join(", ")}`, undefined, runContext);
|
||||||
}
|
}
|
||||||
|
await copyConfiguredFilesForPreparedWorktree("pool");
|
||||||
await maybeWarnForeignTaskStartPoint({
|
await maybeWarnForeignTaskStartPoint({
|
||||||
baseBranch,
|
baseBranch,
|
||||||
rootDir,
|
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