Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Expand All @@ -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` |

Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const getGlobalConfigPath = (): string => {
export const getDefaultConfig = (): Config => {
return {
postCreateHook: "",
copyFiles: [],
openCommand: "",
launchCommand: "opencode",
};
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
31 changes: 31 additions & 0 deletions src/git.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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(/\/[^/]+$/, "");
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
};
Expand Down
60 changes: 52 additions & 8 deletions src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
checkoutBranch,
createBranchFromCommit,
createWorktree,
copyWorktreeFiles,
deleteWorktree,
getDefaultWorktreesDir,
getHeadCommit,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -775,7 +787,7 @@ class WorktreeSelector {
left: 2,
top: 3,
width: 76,
height: 15,
height: 18,
borderStyle: "single",
borderColor: "#38BDF8",
title,
Expand Down Expand Up @@ -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",
});
Expand All @@ -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 || "",
Expand All @@ -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",
});
Expand All @@ -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 || "",
Expand All @@ -869,7 +904,7 @@ class WorktreeSelector {
id: "config-help",
position: "absolute",
left: 1,
top: 10,
top: 13,
content: helpContent,
fg: this.repoKey ? "#64748B" : "#F59E0B",
});
Expand Down Expand Up @@ -897,6 +932,9 @@ class WorktreeSelector {
if (this.configHookInput) {
this.configHookInput.blur();
}
if (this.configCopyInput) {
this.configCopyInput.blur();
}
if (this.configOpenInput) {
this.configOpenInput.blur();
}
Expand All @@ -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;
}
Expand All @@ -931,13 +970,17 @@ 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 = {};

if (hookValue) {
config.postCreateHook = hookValue;
}
if (copyFiles.length > 0) {
config.copyFiles = copyFiles;
}
if (openValue) {
config.openCommand = openValue;
}
Expand All @@ -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}"`);

Expand Down