diff --git a/README.md b/README.md index 777cc86..02dc65e 100644 --- a/README.md +++ b/README.md @@ -80,12 +80,14 @@ Repositories are identified by their git remote URL (e.g., `github.com/user/repo { "default": { "postCreateHook": "", + "copyFiles": [], "openCommand": "", "launchCommand": "opencode" }, "repos": { "github.com/user/repo": { "postCreateHook": "npm install", + "copyFiles": [".env", ".env.local"], "launchCommand": "cursor" } } @@ -97,6 +99,7 @@ Repositories are identified by their git remote URL (e.g., `github.com/user/repo | Option | Description | Default | |--------|-------------|---------| | `postCreateHook` | Command to run after creating a worktree | none | +| `copyFiles` | Repository-relative files copied into a new worktree before the post-create hook | `[]` | | `openCommand` | Command for opening worktree folders (`o` key) | system default | | `launchCommand` | Command to launch when selecting a worktree (`Enter` key) | `opencode` | @@ -137,6 +140,14 @@ The hook output is streamed to the TUI in real-time. If the hook fails, you can **Examples:** `npm install`, `bun install`, `npm install && npm run setup` +### Copy files + +Use `copyFiles` for untracked local files that should exist in every new worktree, such as environment files. Values are explicit repository-relative file paths; directories and glob patterns are not supported. + +```json +"copyFiles": [".env", ".env.local"] +``` + ### Custom open command Use a custom command when pressing `o` to open worktree folders. Useful for opening in your preferred IDE. diff --git a/src/config.ts b/src/config.ts index 6b3f43a..c9de699 100644 --- a/src/config.ts +++ b/src/config.ts @@ -30,6 +30,7 @@ export const getGlobalConfigPath = (): string => { export const getDefaultConfig = (): Config => { return { postCreateHook: "", + copyFiles: [], openCommand: "", launchCommand: "opencode", }; @@ -86,6 +87,9 @@ export const loadGlobalConfig = (): GlobalConfig => { if (typeof parsed.default.postCreateHook === "string") { globalConfig.default.postCreateHook = parsed.default.postCreateHook; } + if (Array.isArray(parsed.default.copyFiles) && parsed.default.copyFiles.every((file: unknown) => typeof file === "string")) { + globalConfig.default.copyFiles = parsed.default.copyFiles; + } if (typeof parsed.default.openCommand === "string") { globalConfig.default.openCommand = parsed.default.openCommand; } @@ -104,6 +108,9 @@ export const loadGlobalConfig = (): GlobalConfig => { if (typeof v.postCreateHook === "string") { repoConfig.postCreateHook = v.postCreateHook; } + if (Array.isArray(v.copyFiles) && v.copyFiles.every((file: unknown) => typeof file === "string")) { + repoConfig.copyFiles = v.copyFiles; + } if (typeof v.openCommand === "string") { repoConfig.openCommand = v.openCommand; } @@ -162,6 +169,9 @@ export const loadRepoConfig = (repoRoot: string): LoadRepoConfigResult => { if (repoConfig.postCreateHook !== undefined) { config.postCreateHook = repoConfig.postCreateHook; } + if (repoConfig.copyFiles !== undefined) { + config.copyFiles = repoConfig.copyFiles; + } if (repoConfig.openCommand !== undefined) { config.openCommand = repoConfig.openCommand; } @@ -194,6 +204,9 @@ export const saveRepoConfig = (repoRoot: string, config: Config): boolean => { if (config.postCreateHook !== globalConfig.default.postCreateHook) { repoConfig.postCreateHook = config.postCreateHook; } + if (JSON.stringify(config.copyFiles || []) !== JSON.stringify(globalConfig.default.copyFiles || [])) { + repoConfig.copyFiles = config.copyFiles; + } if (config.openCommand !== globalConfig.default.openCommand) { repoConfig.openCommand = config.openCommand; } diff --git a/src/git.ts b/src/git.ts index 80b7a5f..279f770 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,4 +1,6 @@ import { execFileSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, statSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { WorktreeInfo } from "./types.js"; /** @@ -185,6 +187,35 @@ export const createWorktree = ( } }; +export type CopyFilesResult = + | { success: true } + | { success: false; error: string }; + +export const copyWorktreeFiles = ( + repoRoot: string, + worktreePath: string, + files: string[], +): CopyFilesResult => { + try { + for (const file of files) { + const source = resolve(repoRoot, file); + const destination = resolve(worktreePath, file); + const relativeSource = relative(repoRoot, source); + if (isAbsolute(relativeSource) || relativeSource === ".." || relativeSource.startsWith(`..${sep}`)) { + return { success: false, error: `Copy path must be inside the repository: ${file}` }; + } + if (!existsSync(source) || !statSync(source).isFile()) { + return { success: false, error: `Copy source is not a file: ${file}` }; + } + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(source, destination); + } + return { success: true }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : String(e) }; + } +}; + export const getDefaultWorktreesDir = (repoRoot: string): string => { // Place worktrees in a sibling 'worktrees' folder const parentDir = repoRoot.replace(/\/[^/]+$/, ""); diff --git a/src/types.ts b/src/types.ts index 6e6e040..3897496 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,7 @@ export type WorktreeInfo = { */ export type Config = { postCreateHook?: string; + copyFiles?: string[]; openCommand?: string; // Custom command to open worktree folder (e.g., "webstorm", "code") launchCommand?: string; // Custom command to launch instead of opencode (e.g., "cursor", "claude") }; diff --git a/src/ui.ts b/src/ui.ts index 1dedbeb..91e8d63 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -16,6 +16,7 @@ import { checkoutBranch, createBranchFromCommit, createWorktree, + copyWorktreeFiles, deleteWorktree, getDefaultWorktreesDir, getHeadCommit, @@ -106,9 +107,10 @@ class WorktreeSelector { private isEditingConfig = false; private configContainer: BoxRenderable | null = null; private configHookInput: InputRenderable | null = null; + private configCopyInput: InputRenderable | null = null; private configOpenInput: InputRenderable | null = null; private configLaunchInput: InputRenderable | null = null; - private configActiveField: "hook" | "open" | "launch" = "hook"; + private configActiveField: "hook" | "copy" | "open" | "launch" = "hook"; private repoKey: string | null = null; // Normalized git remote URL for config lookup // Branch creation state @@ -320,8 +322,12 @@ class WorktreeSelector { if (key.name === "tab") { // Cycle between fields: hook -> open -> launch -> hook if (this.configActiveField === "hook") { - this.configActiveField = "open"; + this.configActiveField = "copy"; this.configHookInput?.blur(); + this.configCopyInput?.focus(); + } else if (this.configActiveField === "copy") { + this.configActiveField = "open"; + this.configCopyInput?.blur(); this.configOpenInput?.focus(); } else if (this.configActiveField === "open") { this.configActiveField = "launch"; @@ -552,6 +558,12 @@ class WorktreeSelector { if (result.success) { this.setStatus(`Worktree created at ${result.path}`, "success"); + + const copyResult = copyWorktreeFiles(this.repoRoot, result.path, this.repoConfig.copyFiles || []); + if (!copyResult.success) { + this.setStatus(`Failed to copy files: ${copyResult.error}`, "error"); + return; + } // Check for post-create hook (use already-loaded config) if (this.repoConfig.postCreateHook) { @@ -775,7 +787,7 @@ class WorktreeSelector { left: 2, top: 3, width: 76, - height: 15, + height: 18, borderStyle: "single", borderColor: "#38BDF8", title, @@ -809,12 +821,35 @@ class WorktreeSelector { }); this.configContainer.add(this.configHookInput); + const copyLabel = new TextRenderable(this.renderer, { + id: "config-copy-label", + position: "absolute", + left: 1, + top: 4, + content: "Files to copy (comma-separated, e.g., .env, .env.local):", + fg: "#94A3B8", + }); + this.configContainer.add(copyLabel); + + this.configCopyInput = new InputRenderable(this.renderer, { + id: "config-copy-input", + position: "absolute", + left: 1, + top: 5, + width: 72, + placeholder: ".env, .env.local", + value: (this.repoConfig.copyFiles || []).join(", "), + focusedBackgroundColor: "#1E293B", + backgroundColor: "#1E293B", + }); + this.configContainer.add(this.configCopyInput); + // Open folder command field const openLabel = new TextRenderable(this.renderer, { id: "config-open-label", position: "absolute", left: 1, - top: 4, + top: 7, content: "Open folder command (e.g., code, webstorm):", fg: "#94A3B8", }); @@ -824,7 +859,7 @@ class WorktreeSelector { id: "config-open-input", position: "absolute", left: 1, - top: 5, + top: 8, width: 72, placeholder: "open (default)", value: this.repoConfig.openCommand || "", @@ -838,7 +873,7 @@ class WorktreeSelector { id: "config-launch-label", position: "absolute", left: 1, - top: 7, + top: 10, content: "Launch command (e.g., cursor, claude, code):", fg: "#94A3B8", }); @@ -848,7 +883,7 @@ class WorktreeSelector { id: "config-launch-input", position: "absolute", left: 1, - top: 8, + top: 11, width: 72, placeholder: "opencode (default)", value: this.repoConfig.launchCommand || "", @@ -869,7 +904,7 @@ class WorktreeSelector { id: "config-help", position: "absolute", left: 1, - top: 10, + top: 13, content: helpContent, fg: this.repoKey ? "#64748B" : "#F59E0B", }); @@ -897,6 +932,9 @@ class WorktreeSelector { if (this.configHookInput) { this.configHookInput.blur(); } + if (this.configCopyInput) { + this.configCopyInput.blur(); + } if (this.configOpenInput) { this.configOpenInput.blur(); } @@ -908,6 +946,7 @@ class WorktreeSelector { this.renderer.root.remove(this.configContainer.id); this.configContainer = null; this.configHookInput = null; + this.configCopyInput = null; this.configOpenInput = null; this.configLaunchInput = null; } @@ -931,6 +970,7 @@ class WorktreeSelector { } const hookValue = (this.configHookInput?.value || "").trim(); + const copyFiles = (this.configCopyInput?.value || "").split(",").map((file: string) => file.trim()).filter(Boolean); const openValue = (this.configOpenInput?.value || "").trim(); const launchValue = (this.configLaunchInput?.value || "").trim(); const config: Config = {}; @@ -938,6 +978,9 @@ class WorktreeSelector { if (hookValue) { config.postCreateHook = hookValue; } + if (copyFiles.length > 0) { + config.copyFiles = copyFiles; + } if (openValue) { config.openCommand = openValue; } @@ -957,6 +1000,7 @@ class WorktreeSelector { const changes: string[] = []; if (hookValue) changes.push(`hook: "${hookValue}"`); + if (copyFiles.length > 0) changes.push(`copy: ${copyFiles.join(", ")}`); if (openValue) changes.push(`open: "${openValue}"`); if (launchValue) changes.push(`launch: "${launchValue}"`);