Skip to content

Commit 9f9a3a2

Browse files
authored
feat(worktree): copy ignored files into new worktrees (#156)
- add a project setting for gitignore-style worktree copy patterns - wire the patterns through worktree creation and copy matching ignored files - add tests plus design docs for the new worktree-copy flow
1 parent dc8571c commit 9f9a3a2

13 files changed

Lines changed: 403 additions & 1 deletion

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Copy gitignored files into new worktrees
2+
3+
**Date:** 2026-06-10
4+
**Status:** Approved
5+
6+
## Problem
7+
8+
Files excluded by `.gitignore` (most commonly `.env` and friends) are not part of a
9+
checkout, so a freshly created worktree is missing them. Users currently have to copy
10+
them by hand or script it via the setup script. Project worktree settings should let
11+
users declare which ignored files to carry over, and Lightcode should copy them
12+
automatically when a worktree is created.
13+
14+
## Decisions
15+
16+
- Users specify files with **gitignore-style patterns** (e.g. `.env.*`), one per line —
17+
not a file picker.
18+
- Copying happens **only at worktree creation**. No sync action for existing worktrees.
19+
- Candidate files are enumerated with git, not a filesystem walk: only files that are
20+
actually ignored in the main project can match, so tracked files in the fresh
21+
checkout are never clobbered, and fully-ignored directories (`node_modules/`)
22+
collapse to a single entry, keeping enumeration fast.
23+
24+
## Design
25+
26+
### Settings schema
27+
28+
Add to `projectScriptsSchema` in `src/shared/contracts/project.ts`:
29+
30+
```ts
31+
worktreeCopyPatterns: z.array(z.string()).optional(),
32+
```
33+
34+
The field rides in the existing `scripts` JSON column of the `projects` table — no DB
35+
migration. Patterns are stored as a cleaned array (trimmed, no blanks, no `#` comments).
36+
37+
### UI
38+
39+
`src/renderer/views/ProjectSettingsOverlay/parts/ScriptsSection.tsx` (the "Worktrees"
40+
settings page) gains a third field alongside Setup script and Cleanup script:
41+
42+
- Label: "Copy ignored files"
43+
- Help text: copied from the main project into each new worktree; gitignore-style
44+
patterns, one per line.
45+
- `TextArea`, monospace, placeholder `.env\n.env.*`
46+
- Saved on blur like the other fields. The textarea shows raw lines; on save, lines are
47+
trimmed and blank lines / `#` comments dropped before storing as `string[]`.
48+
49+
### IPC plumbing
50+
51+
- `gitAddWorktreePayloadSchema` (`src/shared/contracts/git.ts`) gains
52+
`copyIgnoredPatterns: z.array(z.string()).optional()`.
53+
- The renderer call site (`AppContent.tsx`, `gitAddWorktree` call) passes
54+
`project.scripts?.worktreeCopyPatterns`.
55+
- `runtime.gitAddWorktree` forwards the field to `worktreeService.addWorktree`.
56+
57+
### Copy step (supervisor)
58+
59+
In `src/supervisor/git/worktreeService.ts`, after `git worktree add` succeeds and
60+
before returning:
61+
62+
1. If no patterns, skip.
63+
2. Run `git ls-files --others --ignored --exclude-standard --directory` in the main
64+
project to enumerate ignored entries (files, plus collapsed `dir/` entries for
65+
fully-ignored directories).
66+
3. Filter entries with the existing `micromatch` dependency initialized from the
67+
user's patterns.
68+
4. For each match, copy from main project to the same relative path in the new
69+
worktree with `fs.cp` (`recursive: true` so matched directory entries copy whole).
70+
Destination parent directories are created as needed. Existing destination files are
71+
never overwritten (`force: false`).
72+
73+
Path handling: posix and windows locations use the repo path directly; WSL locations
74+
use the UNC path, which Node `fs` on Windows can read and write.
75+
76+
The matching/copy logic lives in a small helper module (e.g.
77+
`src/supervisor/git/copyIgnoredFiles.ts`) so it is unit-testable without a real
78+
worktree.
79+
80+
### Error handling
81+
82+
The copy step is non-fatal. Any failure (enumeration, matching, individual copy) is
83+
caught, logged with a warning, and worktree creation still returns success. A missing
84+
or empty pattern list is a no-op.
85+
86+
### Testing
87+
88+
- Unit tests for the helper: pattern filtering (`.env.*` matches `.env.local`, not
89+
`node_modules/`), directory entries, no-overwrite behavior, empty/comment lines.
90+
- Extend the existing renderer test that asserts the `gitAddWorktree` payload
91+
(`src/renderer/app.test.tsx`) to cover `copyIgnoredPatterns` being passed from
92+
project settings.
93+
94+
## Out of scope
95+
96+
- Re-syncing files into existing worktrees after pattern changes.
97+
- A file-picker UI for selecting ignored files.
98+
- Copying files that live inside fully-ignored directories without matching the
99+
directory itself (e.g. `dist/.env` when all of `dist/` is ignored — git collapses it
100+
to `dist/`, so only a pattern matching `dist/` would copy it).

src/renderer/app.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,7 @@ describe("App", () => {
895895
kind: "windows",
896896
path: "C:\\repo",
897897
},
898+
scripts: { actions: [], worktreeCopyPatterns: [".env", ".env.*"] },
898899
createdAt: "2026-03-22T00:00:00.000Z",
899900
},
900901
],
@@ -910,6 +911,7 @@ describe("App", () => {
910911
branch: "feature/x",
911912
createBranch: true,
912913
startPoint: "main",
914+
copyIgnoredPatterns: [".env", ".env.*"],
913915
});
914916
});
915917

src/renderer/views/MainView/parts/AppContent/AppContent.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export function AppContent() {
110110
branch: worktreeBranch,
111111
createBranch: worktreeIsNewBranch ?? false,
112112
startPoint: worktreeBaseBranch,
113+
copyIgnoredPatterns: project.scripts?.worktreeCopyPatterns,
113114
});
114115
worktreePath = result.path;
115116
newWorktreeSetupPath = result.path;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { beforeEach, describe, expect, it } from "vitest";
3+
import type { Project } from "@/shared/contracts";
4+
import { useAppStore } from "@/renderer/state/appStore";
5+
import { ScriptsSection } from "./ScriptsSection";
6+
7+
function seedProject(overrides: Partial<Project> = {}) {
8+
useAppStore.setState((state) => ({
9+
...state,
10+
projects: [
11+
{
12+
id: "project-1",
13+
name: "Repo",
14+
location: { kind: "posix", path: "/repo" },
15+
createdAt: "2026-06-10T00:00:00.000Z",
16+
...overrides,
17+
},
18+
],
19+
}));
20+
}
21+
22+
describe("ScriptsSection copy ignored files", () => {
23+
beforeEach(() => {
24+
seedProject();
25+
});
26+
27+
it("shows stored patterns one per line", () => {
28+
seedProject({ scripts: { actions: [], worktreeCopyPatterns: [".env", ".env.*"] } });
29+
render(<ScriptsSection projectId="project-1" />);
30+
31+
expect(screen.getByLabelText("Copy ignored files")).toHaveValue(".env\n.env.*");
32+
});
33+
34+
it("saves parsed patterns on blur, dropping blanks and comments", () => {
35+
render(<ScriptsSection projectId="project-1" />);
36+
37+
const textarea = screen.getByLabelText("Copy ignored files");
38+
fireEvent.change(textarea, { target: { value: " .env \n\n# secrets\n.env.*" } });
39+
fireEvent.blur(textarea);
40+
41+
expect(useAppStore.getState().projects[0]?.scripts?.worktreeCopyPatterns).toEqual([
42+
".env",
43+
".env.*",
44+
]);
45+
});
46+
47+
it("clears the setting when the textarea is emptied", () => {
48+
seedProject({ scripts: { actions: [], worktreeCopyPatterns: [".env"] } });
49+
render(<ScriptsSection projectId="project-1" />);
50+
51+
const textarea = screen.getByLabelText("Copy ignored files");
52+
fireEvent.change(textarea, { target: { value: "" } });
53+
fireEvent.blur(textarea);
54+
55+
expect(useAppStore.getState().projects[0]?.scripts?.worktreeCopyPatterns).toBeUndefined();
56+
});
57+
});

src/renderer/views/ProjectSettingsOverlay/parts/ScriptsSection.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ProjectScripts } from "@/shared/contracts";
33
import { useAppStore } from "@/renderer/state/appStore";
44
import { useProject } from "@/renderer/state/useThread";
55
import { TextArea } from "@/renderer/components/common";
6+
import { parseCopyPatterns } from "@/shared/worktree";
67

78
export function ScriptsSection(props: { projectId: string }) {
89
const project = useProject(props.projectId);
@@ -11,6 +12,7 @@ export function ScriptsSection(props: { projectId: string }) {
1112
const scripts = project?.scripts ?? { actions: [] };
1213
const [setupScript, setSetupScript] = useState(scripts.setupScript ?? "");
1314
const [cleanupScript, setCleanupScript] = useState(scripts.cleanupScript ?? "");
15+
const [copyPatterns, setCopyPatterns] = useState((scripts.worktreeCopyPatterns ?? []).join("\n"));
1416

1517
if (!project) return null;
1618

@@ -60,6 +62,28 @@ export function ScriptsSection(props: { projectId: string }) {
6062
onBlur={() => save({ cleanupScript: cleanupScript.trim() || undefined })}
6163
/>
6264
</div>
65+
66+
<div className="space-y-2">
67+
<div>
68+
<p className="text-sm font-medium text-foreground">Copy ignored files</p>
69+
<p className="text-xs text-muted">
70+
Gitignored files to copy from the main project into each new worktree.
71+
Gitignore-style patterns, one per line (e.g., <code>.env.*</code>).
72+
</p>
73+
</div>
74+
<TextArea
75+
aria-label="Copy ignored files"
76+
className="w-full font-mono text-xs"
77+
rows={3}
78+
placeholder={".env\n.env.*"}
79+
value={copyPatterns}
80+
onChange={(e) => setCopyPatterns(e.target.value)}
81+
onBlur={() => {
82+
const patterns = parseCopyPatterns(copyPatterns);
83+
save({ worktreeCopyPatterns: patterns.length > 0 ? patterns : undefined });
84+
}}
85+
/>
86+
</div>
6387
</div>
6488
</div>
6589
</div>

src/shared/contracts/git.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,8 @@ export const gitAddWorktreePayloadSchema = z.object({
322322
branch: z.string().optional(),
323323
createBranch: z.boolean().default(false),
324324
startPoint: z.string().optional(),
325+
/** Gitignore-style patterns for ignored files to copy from the main project. */
326+
copyIgnoredPatterns: z.array(z.string()).optional(),
325327
});
326328
export type GitAddWorktreePayload = z.infer<typeof gitAddWorktreePayloadSchema>;
327329

src/shared/contracts/project.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export type ProjectAction = z.infer<typeof projectActionSchema>;
1313
export const projectScriptsSchema = z.object({
1414
setupScript: z.string().optional(),
1515
cleanupScript: z.string().optional(),
16+
/** Gitignore-style patterns for ignored files to copy into new worktrees (e.g. `.env.*`). */
17+
worktreeCopyPatterns: z.array(z.string()).optional(),
1618
actions: z.array(projectActionSchema).default([]),
1719
});
1820
export type ProjectScripts = z.infer<typeof projectScriptsSchema>;

src/shared/worktree.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ export function sanitizeWorktreePathSegment(value: string): string {
2222
return sanitized || "project";
2323
}
2424

25+
/**
26+
* Parse the "copy ignored files" textarea into a clean pattern list:
27+
* one gitignore-style pattern per line, blanks and `#` comments dropped.
28+
*/
29+
export function parseCopyPatterns(text: string): string[] {
30+
return text
31+
.split(/\r?\n/g)
32+
.map((line) => line.trim())
33+
.filter((line) => line.length > 0 && !line.startsWith("#"));
34+
}
35+
2536
/**
2637
* Build a ProjectLocation pointing at a worktree directory.
2738
* Inherits kind/distro from the original project location.

src/supervisor/git.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,8 +299,16 @@ export class GitService {
299299
branch?: string,
300300
createBranch?: boolean,
301301
startPoint?: string,
302+
copyIgnoredPatterns?: string[],
302303
): Promise<GitAddWorktreeResult> {
303-
return this.worktreeService.addWorktree(location, path, branch, createBranch, startPoint);
304+
return this.worktreeService.addWorktree(
305+
location,
306+
path,
307+
branch,
308+
createBranch,
309+
startPoint,
310+
copyIgnoredPatterns,
311+
);
304312
}
305313

306314
async removeWorktree(
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Tests for the worktree "copy ignored files" step: pattern parsing,
3+
* gitignore-style matching against `git ls-files` output, and the actual
4+
* copy into a freshly created worktree. The copy test uses a real git repo
5+
* in a temp directory so git decides which entries are actually ignored.
6+
*/
7+
8+
import { mkdtemp, mkdir, readFile, rm, writeFile, stat } from "node:fs/promises";
9+
import { tmpdir } from "node:os";
10+
import { execFile } from "node:child_process";
11+
import { join } from "node:path";
12+
import { promisify } from "node:util";
13+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
14+
import { parseCopyPatterns } from "@/shared/worktree";
15+
import { copyIgnoredFilesIntoWorktree, matchIgnoredCopyEntries } from "./copyIgnoredFiles";
16+
17+
const execFileAsync = promisify(execFile);
18+
19+
describe("parseCopyPatterns", () => {
20+
it("splits lines, trims, and drops blanks and comments", () => {
21+
expect(parseCopyPatterns(" .env \n\n# comment\n.env.*\r\n")).toEqual([".env", ".env.*"]);
22+
});
23+
24+
it("returns empty array for empty input", () => {
25+
expect(parseCopyPatterns("")).toEqual([]);
26+
expect(parseCopyPatterns("\n# only a comment\n")).toEqual([]);
27+
});
28+
});
29+
30+
describe("matchIgnoredCopyEntries", () => {
31+
it("matches files with gitignore-style wildcards", () => {
32+
expect(matchIgnoredCopyEntries([".env.local", "node_modules/"], [".env.*"])).toEqual([
33+
".env.local",
34+
]);
35+
});
36+
37+
it("matches nested files for patterns without a slash", () => {
38+
expect(matchIgnoredCopyEntries(["packages/app/.env", "dist/"], [".env"])).toEqual([
39+
"packages/app/.env",
40+
]);
41+
});
42+
43+
it("matches collapsed directory entries", () => {
44+
expect(matchIgnoredCopyEntries(["secrets/", ".env"], ["secrets"])).toEqual(["secrets/"]);
45+
});
46+
47+
it("matches collapsed directory entries with trailing-slash patterns", () => {
48+
expect(matchIgnoredCopyEntries(["secrets/", ".env"], ["secrets/"])).toEqual(["secrets/"]);
49+
});
50+
51+
it("returns nothing when patterns are empty", () => {
52+
expect(matchIgnoredCopyEntries([".env"], [])).toEqual([]);
53+
});
54+
});
55+
56+
describe("copyIgnoredFilesIntoWorktree", () => {
57+
let repoDir: string;
58+
let worktreeDir: string;
59+
60+
beforeEach(async () => {
61+
repoDir = await mkdtemp(join(tmpdir(), "lightcode-copy-src-"));
62+
worktreeDir = await mkdtemp(join(tmpdir(), "lightcode-copy-dest-"));
63+
64+
await execFileAsync("git", ["init"], { cwd: repoDir });
65+
await writeFile(join(repoDir, ".gitignore"), ".env*\nsecrets/\n");
66+
await writeFile(join(repoDir, ".env"), "ROOT=1\n");
67+
await writeFile(join(repoDir, ".env.local"), "LOCAL=1\n");
68+
await mkdir(join(repoDir, "packages", "app"), { recursive: true });
69+
await writeFile(join(repoDir, "packages", "app", ".env"), "NESTED=1\n");
70+
await mkdir(join(repoDir, "secrets"), { recursive: true });
71+
await writeFile(join(repoDir, "secrets", "key.pem"), "KEY\n");
72+
await writeFile(join(repoDir, "tracked.ts"), "export {};\n");
73+
});
74+
75+
afterEach(async () => {
76+
await rm(repoDir, { recursive: true, force: true });
77+
await rm(worktreeDir, { recursive: true, force: true });
78+
});
79+
80+
function location() {
81+
return { kind: "posix" as const, path: repoDir };
82+
}
83+
84+
it("copies matching ignored files preserving relative paths", async () => {
85+
await copyIgnoredFilesIntoWorktree(location(), worktreeDir, [".env", ".env.*"]);
86+
87+
expect(await readFile(join(worktreeDir, ".env"), "utf8")).toBe("ROOT=1\n");
88+
expect(await readFile(join(worktreeDir, ".env.local"), "utf8")).toBe("LOCAL=1\n");
89+
expect(await readFile(join(worktreeDir, "packages", "app", ".env"), "utf8")).toBe("NESTED=1\n");
90+
await expect(stat(join(worktreeDir, "secrets"))).rejects.toThrow(/ENOENT/);
91+
await expect(stat(join(worktreeDir, "tracked.ts"))).rejects.toThrow(/ENOENT/);
92+
});
93+
94+
it("copies matched directories recursively", async () => {
95+
await copyIgnoredFilesIntoWorktree(location(), worktreeDir, ["secrets"]);
96+
97+
expect(await readFile(join(worktreeDir, "secrets", "key.pem"), "utf8")).toBe("KEY\n");
98+
await expect(stat(join(worktreeDir, ".env"))).rejects.toThrow(/ENOENT/);
99+
});
100+
101+
it("never overwrites files that already exist in the worktree", async () => {
102+
await writeFile(join(worktreeDir, ".env"), "EXISTING=1\n");
103+
104+
await copyIgnoredFilesIntoWorktree(location(), worktreeDir, [".env", ".env.*"]);
105+
106+
expect(await readFile(join(worktreeDir, ".env"), "utf8")).toBe("EXISTING=1\n");
107+
expect(await readFile(join(worktreeDir, ".env.local"), "utf8")).toBe("LOCAL=1\n");
108+
});
109+
110+
it("is a no-op when patterns are empty", async () => {
111+
await copyIgnoredFilesIntoWorktree(location(), worktreeDir, []);
112+
113+
await expect(stat(join(worktreeDir, ".env"))).rejects.toThrow(/ENOENT/);
114+
});
115+
});

0 commit comments

Comments
 (0)