Skip to content

Commit 557167b

Browse files
committed
feat: add glob pattern support to copyFiles option
Adds support for glob patterns in the postCreate.copyFiles configuration option, allowing users to specify patterns like *.env, **/*.local.yml, and other standard glob patterns instead of listing each file individually. Features: - Created glob-resolver module with pattern detection and expansion - Support for *, **, ?, and [abc] glob patterns - Handles literal filenames containing glob metacharacters - Custom **/ pattern matcher for dotfiles (works around Node.js limitation) - Excludes .git directory from recursive searches - Full backward compatibility with exact file paths Implementation: - Integrated glob resolution into file-copier workflow - Added recursiveReaddir() for walking directory trees - Added matchesPattern() for regex-based glob matching - Literal file paths take precedence over glob patterns - Zero dependencies added (uses Node.js native globSync) Testing: - Added comprehensive test coverage (22 glob resolver tests) - Added 12 file-copier integration tests - Fixed attach.test.js module mocking for globSync - All tests passing (193 tests total) Documentation: - Updated configuration.md with glob syntax reference and examples
1 parent 4fdae50 commit 557167b

File tree

6 files changed

+823
-11
lines changed

6 files changed

+823
-11
lines changed

docs/configuration.md

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,11 @@ parent-directory/
9191

9292
### postCreate.copyFiles
9393

94-
An array of file paths to automatically copy from the current worktree to newly created worktrees.
94+
An array of file paths or glob patterns to automatically copy from the current worktree to newly created worktrees.
9595

9696
**Use Cases:**
9797
- Environment configuration files (`.env`, `.env.local`)
98-
- Local development settings
98+
- Local development settings across subdirectories
9999
- Secret files that are gitignored
100100
- Database configuration files
101101
- API keys and certificates
@@ -106,18 +106,36 @@ An array of file paths to automatically copy from the current worktree to newly
106106
"postCreate": {
107107
"copyFiles": [
108108
".env",
109-
".env.local",
110-
"config/database.local.yml"
109+
".env*",
110+
"config/**/*.local.yml",
111+
"secrets/[ab]*.json"
111112
]
112113
}
113114
}
114115
```
115116

117+
**Glob Pattern Support:**
118+
119+
Glob patterns allow you to match multiple files with a single pattern. Supported patterns:
120+
121+
- `*` - Matches any characters except `/` (e.g., `*.env` matches `.env` but not `config/.env`)
122+
- `**` - Matches any characters including `/` (recursive, e.g., `**/*.yml` matches all `.yml` files in any subdirectory)
123+
- `?` - Matches any single character (e.g., `file?.txt` matches `file1.txt` but not `file10.txt`)
124+
- `[abc]` - Matches any character in the brackets (e.g., `file-[ab].txt` matches `file-a.txt` and `file-b.txt`)
125+
126+
**Common Patterns:**
127+
128+
- `.env*` - All files starting with `.env` (`.env`, `.env.local`, `.env.production`, etc.)
129+
- `*.local` - All files ending with `.local` in the root directory
130+
- `config/**/*.local.yml` - All `.local.yml` files anywhere under `config/` directory
131+
- `secrets/[ab]*.json` - All `.json` files in `secrets/` starting with `a` or `b`
132+
116133
**Notes:**
117-
- Paths are relative to the repository root
118-
- Currently, glob patterns are not supported
119-
- Files must exist in the source worktree
120-
- Non-existent files are silently skipped
134+
- Paths and patterns are relative to the repository root
135+
- Exact file paths and glob patterns can be mixed in the same array
136+
- Patterns matching no files are silently skipped (no error)
137+
- Directories are excluded from copying (only files are copied)
138+
- Overlapping patterns are automatically deduplicated
121139
- Can be overridden with `--copy-file` command line options
122140

123141
### postCreate.commands

packages/core/src/worktree/attach.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ mock.module("./validate.ts", {
2323
mock.module("node:fs", {
2424
namedExports: {
2525
existsSync: existsSyncMock,
26+
globSync: mock.fn(() => []),
2627
},
2728
});
2829

packages/core/src/worktree/file-copier.test.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,140 @@ describe("copyFiles", () => {
130130
);
131131
assert.strictEqual(copiedFile, '{"env": "local"}');
132132
});
133+
134+
test("should copy files matching wildcard pattern", async () => {
135+
await writeFile(path.join(sourceDir, ".env"), "TEST=value");
136+
await writeFile(path.join(sourceDir, ".env.local"), "LOCAL=value");
137+
await writeFile(path.join(sourceDir, ".env.production"), "PROD=value");
138+
await writeFile(path.join(sourceDir, "config.json"), '{"key": "value"}');
139+
140+
const result = await copyFiles(sourceDir, targetDir, [".env*"]);
141+
142+
assert.strictEqual(isOk(result), true);
143+
if (isOk(result)) {
144+
assert.strictEqual(result.value.copiedFiles.length, 3);
145+
assert.ok(result.value.copiedFiles.includes(".env"));
146+
assert.ok(result.value.copiedFiles.includes(".env.local"));
147+
assert.ok(result.value.copiedFiles.includes(".env.production"));
148+
}
149+
150+
const copiedEnv = await readFile(path.join(targetDir, ".env"), "utf-8");
151+
assert.strictEqual(copiedEnv, "TEST=value");
152+
});
153+
154+
test("should copy files matching recursive pattern", async () => {
155+
await mkdir(path.join(sourceDir, "config", "db"), { recursive: true });
156+
await mkdir(path.join(sourceDir, "config", "api"), { recursive: true });
157+
158+
await writeFile(
159+
path.join(sourceDir, "config", "db", "database.local.yml"),
160+
"db: local",
161+
);
162+
await writeFile(
163+
path.join(sourceDir, "config", "api", "api.local.yml"),
164+
"api: local",
165+
);
166+
await writeFile(
167+
path.join(sourceDir, "config", "settings.yml"),
168+
"settings: default",
169+
);
170+
171+
const result = await copyFiles(sourceDir, targetDir, [
172+
"config/**/*.local.yml",
173+
]);
174+
175+
assert.strictEqual(isOk(result), true);
176+
if (isOk(result)) {
177+
assert.strictEqual(result.value.copiedFiles.length, 2);
178+
assert.ok(
179+
result.value.copiedFiles.includes(
180+
path.join("config", "db", "database.local.yml"),
181+
),
182+
);
183+
assert.ok(
184+
result.value.copiedFiles.includes(
185+
path.join("config", "api", "api.local.yml"),
186+
),
187+
);
188+
}
189+
190+
const copiedDb = await readFile(
191+
path.join(targetDir, "config", "db", "database.local.yml"),
192+
"utf-8",
193+
);
194+
assert.strictEqual(copiedDb, "db: local");
195+
});
196+
197+
test("should skip patterns with no matches", async () => {
198+
await writeFile(path.join(sourceDir, "file.txt"), "content");
199+
200+
const result = await copyFiles(sourceDir, targetDir, ["*.nonexistent"]);
201+
202+
assert.strictEqual(isOk(result), true);
203+
if (isOk(result)) {
204+
assert.deepStrictEqual(result.value.copiedFiles, []);
205+
assert.deepStrictEqual(result.value.skippedFiles, []);
206+
}
207+
});
208+
209+
test("should combine glob patterns and exact paths", async () => {
210+
await writeFile(path.join(sourceDir, ".env"), "TEST=value");
211+
await writeFile(path.join(sourceDir, ".env.local"), "LOCAL=value");
212+
await writeFile(path.join(sourceDir, "config.json"), '{"key": "value"}');
213+
214+
const result = await copyFiles(sourceDir, targetDir, [
215+
".env*",
216+
"config.json",
217+
]);
218+
219+
assert.strictEqual(isOk(result), true);
220+
if (isOk(result)) {
221+
assert.strictEqual(result.value.copiedFiles.length, 3);
222+
assert.ok(result.value.copiedFiles.includes(".env"));
223+
assert.ok(result.value.copiedFiles.includes(".env.local"));
224+
assert.ok(result.value.copiedFiles.includes("config.json"));
225+
}
226+
});
227+
228+
test("should deduplicate files from overlapping patterns", async () => {
229+
await writeFile(path.join(sourceDir, ".env"), "TEST=value");
230+
await writeFile(path.join(sourceDir, ".env.local"), "LOCAL=value");
231+
232+
const result = await copyFiles(sourceDir, targetDir, [".env*", ".env"]);
233+
234+
assert.strictEqual(isOk(result), true);
235+
if (isOk(result)) {
236+
// Should only copy each file once
237+
assert.strictEqual(result.value.copiedFiles.length, 2);
238+
assert.strictEqual(
239+
result.value.copiedFiles.filter((f) => f === ".env").length,
240+
1,
241+
);
242+
}
243+
});
244+
245+
test("should preserve subdirectory structure for matched files", async () => {
246+
await mkdir(path.join(sourceDir, "nested"), { recursive: true });
247+
await writeFile(path.join(sourceDir, "nested", "file1.txt"), "content1");
248+
await writeFile(path.join(sourceDir, "nested", "file2.txt"), "content2");
249+
250+
const result = await copyFiles(sourceDir, targetDir, ["nested/*.txt"]);
251+
252+
assert.strictEqual(isOk(result), true);
253+
if (isOk(result)) {
254+
assert.strictEqual(result.value.copiedFiles.length, 2);
255+
assert.ok(
256+
result.value.copiedFiles.includes(path.join("nested", "file1.txt")),
257+
);
258+
assert.ok(
259+
result.value.copiedFiles.includes(path.join("nested", "file2.txt")),
260+
);
261+
}
262+
263+
const copiedFile1 = await readFile(
264+
path.join(targetDir, "nested", "file1.txt"),
265+
"utf-8",
266+
);
267+
assert.strictEqual(copiedFile1, "content1");
268+
});
133269
});

packages/core/src/worktree/file-copier.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { copyFile, mkdir, stat } from "node:fs/promises";
22
import path from "node:path";
3-
import { err, ok, type Result } from "@aku11i/phantom-shared";
3+
import { err, isErr, ok, type Result } from "@aku11i/phantom-shared";
4+
import {
5+
type GlobResolutionError,
6+
resolveGlobPatterns,
7+
} from "./glob-resolver.ts";
48

59
export interface CopyFileResult {
610
copiedFiles: string[];
@@ -21,11 +25,18 @@ export async function copyFiles(
2125
sourceDir: string,
2226
targetDir: string,
2327
files: string[],
24-
): Promise<Result<CopyFileResult, FileCopyError>> {
28+
): Promise<Result<CopyFileResult, FileCopyError | GlobResolutionError>> {
29+
// Resolve glob patterns first
30+
const resolveResult = await resolveGlobPatterns(sourceDir, files);
31+
if (isErr(resolveResult)) {
32+
return resolveResult;
33+
}
34+
35+
const resolvedFiles = resolveResult.value.resolvedFiles;
2536
const copiedFiles: string[] = [];
2637
const skippedFiles: string[] = [];
2738

28-
for (const file of files) {
39+
for (const file of resolvedFiles) {
2940
const sourcePath = path.join(sourceDir, file);
3041
const targetPath = path.join(targetDir, file);
3142

0 commit comments

Comments
 (0)