opencode-worktree 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,10 +4,15 @@ Terminal UI for managing git worktrees and launching `opencode` in the selected
4
4
 
5
5
  ## Features
6
6
 
7
- - Lists all worktrees with branch, path, and short HEAD
7
+ - Lists all worktrees with branch, path, and metadata
8
+ - Worktree metadata display: last edited time, dirty status, remote tracking
9
+ - Status indicators: `[main]` for main worktree, `[*]` for uncommitted changes, `[local]` for local-only branches
8
10
  - Create new worktrees directly from the TUI
11
+ - Post-create hooks: automatically run commands (e.g., `npm install`) after creating a worktree
12
+ - Open worktree folder in file manager
9
13
  - Unlink worktrees (remove directory, keep branch)
10
14
  - Delete worktrees and local branches (never remote)
15
+ - Multi-select delete mode for batch deletion
11
16
  - Launches `opencode` in the selected worktree
12
17
  - Refresh list on demand
13
18
 
@@ -37,11 +42,86 @@ opencode-worktree /path/to/your/repo
37
42
  ## Keybindings
38
43
 
39
44
  - `Up`/`Down` or `j`/`k`: navigate
40
- - `Enter`: open selected worktree
41
- - `d`: unlink/delete menu
45
+ - `Enter`: open selected worktree in opencode (or toggle selection in delete mode)
46
+ - `o`: open worktree folder in file manager or custom editor (configurable)
47
+ - `d`: enter multi-select delete mode (press again to confirm deletion)
42
48
  - `n`: create new worktree
49
+ - `c`: edit configuration (post-create hooks)
43
50
  - `r`: refresh list
44
- - `q` or `Esc`: quit (or cancel dialogs)
51
+ - `q` or `Esc`: quit (or cancel dialogs/modes)
52
+
53
+ ### Multi-select delete mode
54
+
55
+ 1. Press `d` to enter selection mode
56
+ 2. Navigate with arrow keys and press `Enter` to toggle worktrees for deletion
57
+ 3. Press `d` again to confirm and choose unlink/delete action
58
+ 4. Press `Esc` to cancel and return to normal mode
59
+
60
+ ## Configuration
61
+
62
+ You can configure per-repository settings by creating a `.opencode-worktree.json` file in your repository root.
63
+
64
+ ### First-time setup
65
+
66
+ When you first run `opencode-worktree` in a repository without a configuration file, you'll be prompted to configure a post-create hook. You can also skip this step and configure it later by pressing `c`.
67
+
68
+ ### Editing configuration
69
+
70
+ Press `c` at any time to edit your configuration. You can configure:
71
+
72
+ - **Post-create hook**: Command to run after creating a worktree (e.g., `npm install`)
73
+ - **Open command**: Custom command for opening worktree folders (e.g., `webstorm`, `code`)
74
+
75
+ ### Post-create hooks
76
+
77
+ Run a command automatically after creating a new worktree. Useful for installing dependencies.
78
+
79
+ ```json
80
+ {
81
+ "postCreateHook": "npm install"
82
+ }
83
+ ```
84
+
85
+ The hook output is streamed to the TUI in real-time. If the hook fails, you can choose to open opencode anyway or cancel.
86
+
87
+ **Examples:**
88
+
89
+ ```json
90
+ {
91
+ "postCreateHook": "bun install"
92
+ }
93
+ ```
94
+
95
+ ```json
96
+ {
97
+ "postCreateHook": "npm install && npm run setup"
98
+ }
99
+ ```
100
+
101
+ ### Custom open command
102
+
103
+ Use a custom command when pressing `o` to open worktree folders. Useful for opening in your preferred IDE.
104
+
105
+ ```json
106
+ {
107
+ "openCommand": "webstorm"
108
+ }
109
+ ```
110
+
111
+ **Examples:**
112
+
113
+ ```json
114
+ {
115
+ "openCommand": "code"
116
+ }
117
+ ```
118
+
119
+ ```json
120
+ {
121
+ "postCreateHook": "npm install",
122
+ "openCommand": "webstorm"
123
+ }
124
+ ```
45
125
 
46
126
  ## Update notifications
47
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-worktree",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "TUI for managing git worktrees with opencode integration.",
package/src/config.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export type Config = {
5
+ postCreateHook?: string;
6
+ openCommand?: string; // Custom command to open worktree folder (e.g., "webstorm", "code")
7
+ };
8
+
9
+ const CONFIG_FILENAME = ".opencode-worktree.json";
10
+
11
+ /**
12
+ * Get the path to the config file for a repo
13
+ */
14
+ export const getConfigPath = (repoRoot: string): string => {
15
+ return join(repoRoot, CONFIG_FILENAME);
16
+ };
17
+
18
+ /**
19
+ * Check if a config file exists for the repo
20
+ */
21
+ export const configExists = (repoRoot: string): boolean => {
22
+ return existsSync(getConfigPath(repoRoot));
23
+ };
24
+
25
+ /**
26
+ * Load per-repo configuration from .opencode-worktree.json in the repo root
27
+ */
28
+ export const loadRepoConfig = (repoRoot: string): Config => {
29
+ const configPath = getConfigPath(repoRoot);
30
+
31
+ if (!existsSync(configPath)) {
32
+ return {};
33
+ }
34
+
35
+ try {
36
+ const content = readFileSync(configPath, "utf8");
37
+ const parsed = JSON.parse(content);
38
+
39
+ // Validate the config structure
40
+ if (typeof parsed !== "object" || parsed === null) {
41
+ return {};
42
+ }
43
+
44
+ const config: Config = {};
45
+
46
+ if (typeof parsed.postCreateHook === "string") {
47
+ config.postCreateHook = parsed.postCreateHook;
48
+ }
49
+
50
+ if (typeof parsed.openCommand === "string") {
51
+ config.openCommand = parsed.openCommand;
52
+ }
53
+
54
+ return config;
55
+ } catch {
56
+ // If we can't read or parse the config, return empty
57
+ return {};
58
+ }
59
+ };
60
+
61
+ /**
62
+ * Save configuration to .opencode-worktree.json in the repo root
63
+ */
64
+ export const saveRepoConfig = (repoRoot: string, config: Config): boolean => {
65
+ const configPath = getConfigPath(repoRoot);
66
+
67
+ try {
68
+ const content = JSON.stringify(config, null, 2) + "\n";
69
+ writeFileSync(configPath, content, "utf8");
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ };
package/src/git.ts CHANGED
@@ -17,11 +17,19 @@ export const resolveRepoRoot = (cwd: string): string | null => {
17
17
  export const parseWorktreeList = (output: string): WorktreeInfo[] => {
18
18
  const lines = output.split(/\r?\n/);
19
19
  const worktrees: WorktreeInfo[] = [];
20
- let current: WorktreeInfo | null = null;
20
+ let current: Partial<WorktreeInfo> | null = null;
21
21
 
22
22
  const pushCurrent = (): void => {
23
23
  if (current?.path) {
24
- worktrees.push(current);
24
+ worktrees.push({
25
+ path: current.path,
26
+ head: current.head || "",
27
+ branch: current.branch || null,
28
+ isDetached: current.isDetached || false,
29
+ isDirty: false,
30
+ isOnRemote: false,
31
+ lastModified: null,
32
+ });
25
33
  }
26
34
  };
27
35
 
@@ -71,7 +79,10 @@ export const listWorktrees = (cwd: string): WorktreeInfo[] => {
71
79
  stdio: ["ignore", "pipe", "ignore"],
72
80
  encoding: "utf8",
73
81
  });
74
- return parseWorktreeList(output);
82
+ const worktrees = parseWorktreeList(output);
83
+
84
+ // Enrich each worktree with metadata
85
+ return worktrees.map((wt) => enrichWorktreeInfo(repoRoot, wt));
75
86
  };
76
87
 
77
88
  export type CreateWorktreeResult =
@@ -133,6 +144,68 @@ export const hasUncommittedChanges = (worktreePath: string): boolean => {
133
144
  }
134
145
  };
135
146
 
147
+ /**
148
+ * Get the last commit date for a worktree
149
+ */
150
+ export const getLastCommitDate = (worktreePath: string): Date | null => {
151
+ try {
152
+ const output = execFileSync(
153
+ "git",
154
+ ["log", "-1", "--format=%ci"],
155
+ {
156
+ cwd: worktreePath,
157
+ stdio: ["ignore", "pipe", "ignore"],
158
+ encoding: "utf8",
159
+ }
160
+ );
161
+ const dateStr = output.trim();
162
+ if (!dateStr) return null;
163
+ return new Date(dateStr);
164
+ } catch {
165
+ return null;
166
+ }
167
+ };
168
+
169
+ /**
170
+ * Check if a branch exists on remote (origin)
171
+ */
172
+ export const isBranchOnRemote = (
173
+ repoRoot: string,
174
+ branchName: string
175
+ ): boolean => {
176
+ try {
177
+ const output = execFileSync(
178
+ "git",
179
+ ["branch", "-r", "--list", `origin/${branchName}`],
180
+ {
181
+ cwd: repoRoot,
182
+ stdio: ["ignore", "pipe", "ignore"],
183
+ encoding: "utf8",
184
+ }
185
+ );
186
+ return output.trim().length > 0;
187
+ } catch {
188
+ return false;
189
+ }
190
+ };
191
+
192
+ /**
193
+ * Enrich worktree info with metadata (dirty status, remote status, last modified)
194
+ */
195
+ export const enrichWorktreeInfo = (
196
+ repoRoot: string,
197
+ worktree: WorktreeInfo
198
+ ): WorktreeInfo => {
199
+ return {
200
+ ...worktree,
201
+ isDirty: hasUncommittedChanges(worktree.path),
202
+ isOnRemote: worktree.branch
203
+ ? isBranchOnRemote(repoRoot, worktree.branch)
204
+ : false,
205
+ lastModified: getLastCommitDate(worktree.path),
206
+ };
207
+ };
208
+
136
209
  /**
137
210
  * Check if a worktree is the main worktree (the original repo clone)
138
211
  */
package/src/hooks.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export type HookResult = {
4
+ success: boolean;
5
+ exitCode: number | null;
6
+ };
7
+
8
+ export type HookCallbacks = {
9
+ onOutput: (data: string) => void;
10
+ onComplete: (result: HookResult) => void;
11
+ };
12
+
13
+ /**
14
+ * Run a post-create hook command with streaming output
15
+ * Returns a function to abort the hook if needed
16
+ */
17
+ export const runPostCreateHook = (
18
+ worktreePath: string,
19
+ command: string,
20
+ callbacks: HookCallbacks
21
+ ): (() => void) => {
22
+ const shell = process.platform === "win32" ? "cmd" : "/bin/sh";
23
+ const shellFlag = process.platform === "win32" ? "/c" : "-c";
24
+
25
+ const child = spawn(shell, [shellFlag, command], {
26
+ cwd: worktreePath,
27
+ stdio: ["ignore", "pipe", "pipe"],
28
+ env: { ...process.env },
29
+ });
30
+
31
+ // Stream stdout
32
+ child.stdout?.on("data", (data: Buffer) => {
33
+ callbacks.onOutput(data.toString());
34
+ });
35
+
36
+ // Stream stderr
37
+ child.stderr?.on("data", (data: Buffer) => {
38
+ callbacks.onOutput(data.toString());
39
+ });
40
+
41
+ // Handle completion
42
+ child.on("close", (code: number | null) => {
43
+ callbacks.onComplete({
44
+ success: code === 0,
45
+ exitCode: code,
46
+ });
47
+ });
48
+
49
+ // Handle errors
50
+ child.on("error", (err: Error) => {
51
+ callbacks.onOutput(`Error: ${err.message}\n`);
52
+ callbacks.onComplete({
53
+ success: false,
54
+ exitCode: null,
55
+ });
56
+ });
57
+
58
+ // Return abort function
59
+ return () => {
60
+ child.kill("SIGTERM");
61
+ };
62
+ };
package/src/opencode.ts CHANGED
@@ -18,3 +18,43 @@ export const launchOpenCode = (cwd: string): void => {
18
18
  process.exit(exitCode);
19
19
  });
20
20
  };
21
+
22
+ /**
23
+ * Open a path in the system file manager or with a custom command
24
+ * If customCommand is provided, uses that instead of the system default
25
+ */
26
+ export const openInFileManager = (path: string, customCommand?: string): boolean => {
27
+ let command: string;
28
+ let args: string[];
29
+
30
+ if (customCommand) {
31
+ // Use custom command (e.g., "webstorm", "code")
32
+ command = customCommand;
33
+ args = [path];
34
+ } else {
35
+ // Use system default
36
+ const platform = process.platform;
37
+
38
+ if (platform === "darwin") {
39
+ command = "open";
40
+ args = [path];
41
+ } else if (platform === "win32") {
42
+ command = "explorer";
43
+ args = [path];
44
+ } else {
45
+ // Linux and others
46
+ command = "xdg-open";
47
+ args = [path];
48
+ }
49
+ }
50
+
51
+ try {
52
+ spawn(command, args, {
53
+ detached: true,
54
+ stdio: "ignore",
55
+ }).unref();
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ };
package/src/types.ts CHANGED
@@ -3,4 +3,8 @@ export type WorktreeInfo = {
3
3
  head: string;
4
4
  branch: string | null;
5
5
  isDetached: boolean;
6
+ // Metadata
7
+ isDirty: boolean;
8
+ isOnRemote: boolean;
9
+ lastModified: Date | null;
6
10
  };