opencode-worktree 0.3.5 → 0.4.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
@@ -8,12 +8,14 @@ Terminal UI for managing git worktrees and launching your preferred coding tool
8
8
  - Worktree metadata display: last edited time, dirty status, remote tracking
9
9
  - Status indicators: `[main]` for main worktree, `[*]` for uncommitted changes, `[local]` for local-only branches
10
10
  - Create new worktrees directly from the TUI
11
+ - **Create branch from worktree**: create a new branch from any worktree's current commit, with optional checkout
11
12
  - Post-create hooks: automatically run commands (e.g., `npm install`) after creating a worktree
12
13
  - Open worktree folder in file manager or custom editor
13
14
  - Unlink worktrees (remove directory, keep branch)
14
15
  - Delete worktrees and local branches (never remote)
15
16
  - Multi-select delete mode for batch deletion
16
17
  - **Customizable launch command**: use `opencode`, `cursor`, `claude`, `code`, or any CLI tool
18
+ - **Global configuration**: settings stored in `~/.config/opencode-worktree/config.json` with per-repo overrides
17
19
  - Refresh list on demand
18
20
  - Update notifications when new versions are available
19
21
 
@@ -47,10 +49,18 @@ opencode-worktree /path/to/your/repo
47
49
  - `o`: open worktree folder in file manager or custom editor (configurable)
48
50
  - `d`: enter multi-select delete mode (press again to confirm deletion)
49
51
  - `n`: create new worktree
52
+ - `b`: create a new branch from selected worktree's current commit
50
53
  - `c`: edit configuration (hooks, open command, launch command)
51
54
  - `r`: refresh list
52
55
  - `q` or `Esc`: quit (or cancel dialogs/modes)
53
56
 
57
+ ### Create branch from worktree
58
+
59
+ 1. Select a worktree and press `b`
60
+ 2. Enter a name for the new branch
61
+ 3. The branch is created starting from the worktree's current commit
62
+ 4. Choose whether to checkout the new branch in the worktree
63
+
54
64
  ### Multi-select delete mode
55
65
 
56
66
  1. Press `d` to enter selection mode
@@ -60,7 +70,27 @@ opencode-worktree /path/to/your/repo
60
70
 
61
71
  ## Configuration
62
72
 
63
- You can configure per-repository settings by creating a `.opencode-worktree.json` file in your repository root, or by pressing `c` in the TUI.
73
+ Configuration is stored globally at `~/.config/opencode-worktree/config.json` with support for default settings and per-repository overrides. Press `c` in the TUI to edit settings.
74
+
75
+ Repositories are identified by their git remote URL (e.g., `github.com/user/repo`).
76
+
77
+ ### Configuration structure
78
+
79
+ ```json
80
+ {
81
+ "default": {
82
+ "postCreateHook": "",
83
+ "openCommand": "",
84
+ "launchCommand": "opencode"
85
+ },
86
+ "repos": {
87
+ "github.com/user/repo": {
88
+ "postCreateHook": "npm install",
89
+ "launchCommand": "cursor"
90
+ }
91
+ }
92
+ }
93
+ ```
64
94
 
65
95
  ### Configuration options
66
96
 
@@ -70,111 +100,66 @@ You can configure per-repository settings by creating a `.opencode-worktree.json
70
100
  | `openCommand` | Command for opening worktree folders (`o` key) | system default |
71
101
  | `launchCommand` | Command to launch when selecting a worktree (`Enter` key) | `opencode` |
72
102
 
73
- ### Example configuration
103
+ ### Example per-repo configuration
104
+
105
+ When you edit config in the TUI, settings are saved under the repo's key (derived from git remote URL):
74
106
 
75
107
  ```json
76
108
  {
77
- "postCreateHook": "npm install",
78
- "openCommand": "code",
79
- "launchCommand": "cursor"
109
+ "default": {
110
+ "launchCommand": "opencode"
111
+ },
112
+ "repos": {
113
+ "github.com/myorg/frontend": {
114
+ "postCreateHook": "npm install",
115
+ "openCommand": "code",
116
+ "launchCommand": "cursor"
117
+ },
118
+ "github.com/myorg/backend": {
119
+ "postCreateHook": "go mod download",
120
+ "launchCommand": "zed"
121
+ }
122
+ }
80
123
  }
81
124
  ```
82
125
 
83
- ### First-time setup
84
-
85
- When you first run `opencode-worktree` in a repository without a configuration file, you'll be prompted to configure settings. You can also skip this step and configure later by pressing `c`.
86
-
87
126
  ### Editing configuration
88
127
 
89
- Press `c` at any time to edit your configuration. Use `Tab` to switch between fields.
128
+ Press `c` at any time to edit your configuration. The config editor title shows which repository you're configuring (e.g., "Config: github.com/user/repo"). Use `Tab` to switch between fields.
129
+
130
+ **Note:** Repositories without a git remote will use default settings. The TUI shows a warning when editing config for repos without a remote.
90
131
 
91
132
  ### Post-create hooks
92
133
 
93
134
  Run a command automatically after creating a new worktree. Useful for installing dependencies.
94
135
 
95
- ```json
96
- {
97
- "postCreateHook": "npm install"
98
- }
99
- ```
100
-
101
136
  The hook output is streamed to the TUI in real-time. If the hook fails, you can choose to open the tool anyway or cancel.
102
137
 
103
- **Examples:**
104
-
105
- ```json
106
- {
107
- "postCreateHook": "bun install"
108
- }
109
- ```
110
-
111
- ```json
112
- {
113
- "postCreateHook": "npm install && npm run setup"
114
- }
115
- ```
138
+ **Examples:** `npm install`, `bun install`, `npm install && npm run setup`
116
139
 
117
140
  ### Custom open command
118
141
 
119
142
  Use a custom command when pressing `o` to open worktree folders. Useful for opening in your preferred IDE.
120
143
 
121
- ```json
122
- {
123
- "openCommand": "webstorm"
124
- }
125
- ```
126
-
127
- **Examples:**
128
-
129
- ```json
130
- {
131
- "openCommand": "code"
132
- }
133
- ```
144
+ **Examples:** `code`, `webstorm`, `idea`
134
145
 
135
146
  ### Custom launch command
136
147
 
137
148
  Use a different tool instead of `opencode` when pressing `Enter` to open a worktree. This allows you to use any CLI-based coding tool.
138
149
 
139
- ```json
140
- {
141
- "launchCommand": "cursor"
142
- }
143
- ```
150
+ **Examples:** `cursor`, `claude`, `code`, `zed`
144
151
 
145
- **Examples:**
152
+ ### Migration from v0.3.x
146
153
 
147
- ```json
148
- {
149
- "launchCommand": "claude"
150
- }
151
- ```
154
+ Previous versions stored config in `.opencode-worktree.json` files in each repository. These files are now ignored. Your settings will need to be reconfigured via the TUI (`c` key), which will save them to the new global config location.
152
155
 
153
- ```json
154
- {
155
- "launchCommand": "code"
156
- }
157
- ```
158
-
159
- ```json
160
- {
161
- "launchCommand": "zed"
162
- }
163
- ```
156
+ ## Update notifications
164
157
 
165
- ### Full configuration example
158
+ On launch, the app performs a single in-process npm version check and stores the result in a local cache.
166
159
 
167
- ```json
168
- {
169
- "postCreateHook": "npm install",
170
- "openCommand": "code",
171
- "launchCommand": "cursor"
172
- }
173
- ```
174
-
175
- ## Update notifications
160
+ If a newer version is found, the warning appears on the next launch in the title bar as:
176
161
 
177
- When a new version is published to npm, the TUI will show a non-intrusive update message in the title bar. The version check runs in the background and doesn't slow down startup.
162
+ `Update: <current> -> <latest> (npm i -g)`
178
163
 
179
164
  ## Development
180
165
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-worktree",
3
- "version": "0.3.5",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "TUI for managing git worktrees with opencode integration.",
package/src/config.ts CHANGED
@@ -1,79 +1,213 @@
1
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { getRepoKey } from "./git.js";
5
+ import type { Config, GlobalConfig, LoadRepoConfigResult } from "./types.js";
3
6
 
4
- export type Config = {
5
- postCreateHook?: string;
6
- openCommand?: string; // Custom command to open worktree folder (e.g., "webstorm", "code")
7
- launchCommand?: string; // Custom command to launch instead of opencode (e.g., "cursor", "claude")
7
+ // Re-export Config type for backwards compatibility
8
+ export type { Config } from "./types.js";
9
+
10
+ const CONFIG_DIR = join(homedir(), ".config", "opencode-worktree");
11
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
12
+
13
+ /**
14
+ * Get the path to the global config directory
15
+ */
16
+ export const getGlobalConfigDir = (): string => {
17
+ return CONFIG_DIR;
8
18
  };
9
19
 
10
- const CONFIG_FILENAME = ".opencode-worktree.json";
20
+ /**
21
+ * Get the path to the global config file
22
+ */
23
+ export const getGlobalConfigPath = (): string => {
24
+ return CONFIG_FILE;
25
+ };
11
26
 
12
27
  /**
13
- * Get the path to the config file for a repo
28
+ * Get the default configuration values
14
29
  */
15
- export const getConfigPath = (repoRoot: string): string => {
16
- return join(repoRoot, CONFIG_FILENAME);
30
+ export const getDefaultConfig = (): Config => {
31
+ return {
32
+ postCreateHook: "",
33
+ openCommand: "",
34
+ launchCommand: "opencode",
35
+ };
17
36
  };
18
37
 
19
38
  /**
20
- * Check if a config file exists for the repo
39
+ * Create an empty global config structure
21
40
  */
22
- export const configExists = (repoRoot: string): boolean => {
23
- return existsSync(getConfigPath(repoRoot));
41
+ const createEmptyGlobalConfig = (): GlobalConfig => {
42
+ return {
43
+ default: getDefaultConfig(),
44
+ repos: {},
45
+ };
24
46
  };
25
47
 
26
48
  /**
27
- * Load per-repo configuration from .opencode-worktree.json in the repo root
49
+ * Ensure the config directory exists
28
50
  */
29
- export const loadRepoConfig = (repoRoot: string): Config => {
30
- const configPath = getConfigPath(repoRoot);
51
+ const ensureConfigDir = (): boolean => {
52
+ try {
53
+ if (!existsSync(CONFIG_DIR)) {
54
+ mkdirSync(CONFIG_DIR, { recursive: true });
55
+ }
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ };
31
61
 
32
- if (!existsSync(configPath)) {
33
- return {};
62
+ /**
63
+ * Load the entire global config file
64
+ */
65
+ export const loadGlobalConfig = (): GlobalConfig => {
66
+ if (!existsSync(CONFIG_FILE)) {
67
+ return createEmptyGlobalConfig();
34
68
  }
35
69
 
36
70
  try {
37
- const content = readFileSync(configPath, "utf8");
71
+ const content = readFileSync(CONFIG_FILE, "utf8");
38
72
  const parsed = JSON.parse(content);
39
73
 
40
- // Validate the config structure
74
+ // Validate and normalize the config structure
41
75
  if (typeof parsed !== "object" || parsed === null) {
42
- return {};
76
+ return createEmptyGlobalConfig();
43
77
  }
44
78
 
45
- const config: Config = {};
79
+ const globalConfig: GlobalConfig = {
80
+ default: { ...getDefaultConfig() },
81
+ repos: {},
82
+ };
46
83
 
47
- if (typeof parsed.postCreateHook === "string") {
48
- config.postCreateHook = parsed.postCreateHook;
84
+ // Parse default config
85
+ if (typeof parsed.default === "object" && parsed.default !== null) {
86
+ if (typeof parsed.default.postCreateHook === "string") {
87
+ globalConfig.default.postCreateHook = parsed.default.postCreateHook;
88
+ }
89
+ if (typeof parsed.default.openCommand === "string") {
90
+ globalConfig.default.openCommand = parsed.default.openCommand;
91
+ }
92
+ if (typeof parsed.default.launchCommand === "string") {
93
+ globalConfig.default.launchCommand = parsed.default.launchCommand;
94
+ }
49
95
  }
50
96
 
51
- if (typeof parsed.openCommand === "string") {
52
- config.openCommand = parsed.openCommand;
53
- }
97
+ // Parse repos config
98
+ if (typeof parsed.repos === "object" && parsed.repos !== null) {
99
+ for (const [key, value] of Object.entries(parsed.repos)) {
100
+ if (typeof value === "object" && value !== null) {
101
+ const repoConfig: Partial<Config> = {};
102
+ const v = value as Record<string, unknown>;
103
+
104
+ if (typeof v.postCreateHook === "string") {
105
+ repoConfig.postCreateHook = v.postCreateHook;
106
+ }
107
+ if (typeof v.openCommand === "string") {
108
+ repoConfig.openCommand = v.openCommand;
109
+ }
110
+ if (typeof v.launchCommand === "string") {
111
+ repoConfig.launchCommand = v.launchCommand;
112
+ }
54
113
 
55
- if (typeof parsed.launchCommand === "string") {
56
- config.launchCommand = parsed.launchCommand;
114
+ // Only add if there are actual values
115
+ if (Object.keys(repoConfig).length > 0) {
116
+ globalConfig.repos[key] = repoConfig;
117
+ }
118
+ }
119
+ }
57
120
  }
58
121
 
59
- return config;
122
+ return globalConfig;
60
123
  } catch {
61
124
  // If we can't read or parse the config, return empty
62
- return {};
125
+ return createEmptyGlobalConfig();
63
126
  }
64
127
  };
65
128
 
66
129
  /**
67
- * Save configuration to .opencode-worktree.json in the repo root
130
+ * Save the entire global config file
68
131
  */
69
- export const saveRepoConfig = (repoRoot: string, config: Config): boolean => {
70
- const configPath = getConfigPath(repoRoot);
132
+ export const saveGlobalConfig = (config: GlobalConfig): boolean => {
133
+ if (!ensureConfigDir()) {
134
+ return false;
135
+ }
71
136
 
72
137
  try {
73
138
  const content = JSON.stringify(config, null, 2) + "\n";
74
- writeFileSync(configPath, content, "utf8");
139
+ writeFileSync(CONFIG_FILE, content, "utf8");
75
140
  return true;
76
141
  } catch {
77
142
  return false;
78
143
  }
79
144
  };
145
+
146
+ /**
147
+ * Load configuration for a specific repository
148
+ * Merges default config with repo-specific overrides
149
+ * Returns the config and the repo key (null if no remote)
150
+ */
151
+ export const loadRepoConfig = (repoRoot: string): LoadRepoConfigResult => {
152
+ const repoKey = getRepoKey(repoRoot);
153
+ const globalConfig = loadGlobalConfig();
154
+
155
+ // Start with default config
156
+ const config: Config = { ...globalConfig.default };
157
+
158
+ // If we have a repo key, merge in repo-specific config
159
+ if (repoKey && globalConfig.repos[repoKey]) {
160
+ const repoConfig = globalConfig.repos[repoKey];
161
+
162
+ if (repoConfig.postCreateHook !== undefined) {
163
+ config.postCreateHook = repoConfig.postCreateHook;
164
+ }
165
+ if (repoConfig.openCommand !== undefined) {
166
+ config.openCommand = repoConfig.openCommand;
167
+ }
168
+ if (repoConfig.launchCommand !== undefined) {
169
+ config.launchCommand = repoConfig.launchCommand;
170
+ }
171
+ }
172
+
173
+ return { config, repoKey };
174
+ };
175
+
176
+ /**
177
+ * Save configuration for a specific repository
178
+ * Only saves values that differ from the default
179
+ * Returns false if there's no remote (can't save repo-specific config)
180
+ */
181
+ export const saveRepoConfig = (repoRoot: string, config: Config): boolean => {
182
+ const repoKey = getRepoKey(repoRoot);
183
+
184
+ if (!repoKey) {
185
+ // No remote - can't save repo-specific config
186
+ return false;
187
+ }
188
+
189
+ const globalConfig = loadGlobalConfig();
190
+
191
+ // Calculate which values differ from default
192
+ const repoConfig: Partial<Config> = {};
193
+
194
+ if (config.postCreateHook !== globalConfig.default.postCreateHook) {
195
+ repoConfig.postCreateHook = config.postCreateHook;
196
+ }
197
+ if (config.openCommand !== globalConfig.default.openCommand) {
198
+ repoConfig.openCommand = config.openCommand;
199
+ }
200
+ if (config.launchCommand !== globalConfig.default.launchCommand) {
201
+ repoConfig.launchCommand = config.launchCommand;
202
+ }
203
+
204
+ // Update or remove the repo entry
205
+ if (Object.keys(repoConfig).length > 0) {
206
+ globalConfig.repos[repoKey] = repoConfig;
207
+ } else {
208
+ // All values match default, remove the entry
209
+ delete globalConfig.repos[repoKey];
210
+ }
211
+
212
+ return saveGlobalConfig(globalConfig);
213
+ };
package/src/git.ts CHANGED
@@ -1,6 +1,71 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { WorktreeInfo } from "./types.js";
3
3
 
4
+ /**
5
+ * Get the remote origin URL for a repository
6
+ */
7
+ export const getRemoteOriginUrl = (repoRoot: string): string | null => {
8
+ try {
9
+ const output = execFileSync("git", ["remote", "get-url", "origin"], {
10
+ cwd: repoRoot,
11
+ stdio: ["ignore", "pipe", "ignore"],
12
+ encoding: "utf8",
13
+ });
14
+ return output.trim() || null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ };
19
+
20
+ /**
21
+ * Normalize a git remote URL to a consistent key format
22
+ * Examples:
23
+ * git@github.com:user/repo.git → github.com/user/repo
24
+ * https://github.com/user/repo.git → github.com/user/repo
25
+ * ssh://git@github.com/user/repo.git → github.com/user/repo
26
+ */
27
+ export const normalizeRemoteUrl = (url: string): string => {
28
+ let normalized = url.trim();
29
+
30
+ // Remove .git suffix
31
+ if (normalized.endsWith(".git")) {
32
+ normalized = normalized.slice(0, -4);
33
+ }
34
+
35
+ // Handle SSH format: git@github.com:user/repo
36
+ const sshMatch = normalized.match(/^git@([^:]+):(.+)$/);
37
+ if (sshMatch) {
38
+ return `${sshMatch[1]}/${sshMatch[2]}`;
39
+ }
40
+
41
+ // Handle SSH URL format: ssh://git@github.com/user/repo
42
+ const sshUrlMatch = normalized.match(/^ssh:\/\/git@([^/]+)\/(.+)$/);
43
+ if (sshUrlMatch) {
44
+ return `${sshUrlMatch[1]}/${sshUrlMatch[2]}`;
45
+ }
46
+
47
+ // Handle HTTPS format: https://github.com/user/repo
48
+ const httpsMatch = normalized.match(/^https?:\/\/([^/]+)\/(.+)$/);
49
+ if (httpsMatch) {
50
+ return `${httpsMatch[1]}/${httpsMatch[2]}`;
51
+ }
52
+
53
+ // Fallback: return as-is (shouldn't happen for valid URLs)
54
+ return normalized;
55
+ };
56
+
57
+ /**
58
+ * Get the normalized repo key for a repository (for config lookup)
59
+ * Returns null if no remote origin is configured
60
+ */
61
+ export const getRepoKey = (repoRoot: string): string | null => {
62
+ const remoteUrl = getRemoteOriginUrl(repoRoot);
63
+ if (!remoteUrl) {
64
+ return null;
65
+ }
66
+ return normalizeRemoteUrl(remoteUrl);
67
+ };
68
+
4
69
  export const resolveRepoRoot = (cwd: string): string | null => {
5
70
  try {
6
71
  const output = execFileSync("git", ["rev-parse", "--show-toplevel"], {
@@ -280,3 +345,69 @@ export const deleteWorktree = (
280
345
  return { success: false, error, step: "branch" };
281
346
  }
282
347
  };
348
+
349
+ export type CreateBranchResult =
350
+ | { success: true }
351
+ | { success: false; error: string };
352
+
353
+ /**
354
+ * Create a new branch from a specific commit
355
+ */
356
+ export const createBranchFromCommit = (
357
+ repoRoot: string,
358
+ branchName: string,
359
+ commitHash: string,
360
+ ): CreateBranchResult => {
361
+ try {
362
+ execFileSync("git", ["branch", branchName, commitHash], {
363
+ cwd: repoRoot,
364
+ stdio: ["ignore", "pipe", "pipe"],
365
+ encoding: "utf8",
366
+ });
367
+ return { success: true };
368
+ } catch (e) {
369
+ const error = e instanceof Error ? e.message : String(e);
370
+ return { success: false, error };
371
+ }
372
+ };
373
+
374
+ export type CheckoutResult =
375
+ | { success: true }
376
+ | { success: false; error: string };
377
+
378
+ /**
379
+ * Checkout a branch in a specific worktree
380
+ * Note: This changes the branch the worktree is tracking
381
+ */
382
+ export const checkoutBranch = (
383
+ worktreePath: string,
384
+ branchName: string,
385
+ ): CheckoutResult => {
386
+ try {
387
+ execFileSync("git", ["checkout", branchName], {
388
+ cwd: worktreePath,
389
+ stdio: ["ignore", "pipe", "pipe"],
390
+ encoding: "utf8",
391
+ });
392
+ return { success: true };
393
+ } catch (e) {
394
+ const error = e instanceof Error ? e.message : String(e);
395
+ return { success: false, error };
396
+ }
397
+ };
398
+
399
+ /**
400
+ * Get the current HEAD commit hash for a worktree
401
+ */
402
+ export const getHeadCommit = (worktreePath: string): string | null => {
403
+ try {
404
+ const output = execFileSync("git", ["rev-parse", "HEAD"], {
405
+ cwd: worktreePath,
406
+ stdio: ["ignore", "pipe", "ignore"],
407
+ encoding: "utf8",
408
+ });
409
+ return output.trim() || null;
410
+ } catch {
411
+ return null;
412
+ }
413
+ };
package/src/types.ts CHANGED
@@ -8,3 +8,29 @@ export type WorktreeInfo = {
8
8
  isOnRemote: boolean;
9
9
  lastModified: Date | null;
10
10
  };
11
+
12
+ /**
13
+ * Per-repo configuration options
14
+ */
15
+ export type Config = {
16
+ postCreateHook?: string;
17
+ openCommand?: string; // Custom command to open worktree folder (e.g., "webstorm", "code")
18
+ launchCommand?: string; // Custom command to launch instead of opencode (e.g., "cursor", "claude")
19
+ };
20
+
21
+ /**
22
+ * Global configuration structure stored at ~/.config/opencode-worktree/config.json
23
+ * Contains default settings and per-repo overrides keyed by normalized git remote URL
24
+ */
25
+ export type GlobalConfig = {
26
+ default: Config;
27
+ repos: Record<string, Partial<Config>>;
28
+ };
29
+
30
+ /**
31
+ * Result of loading repo config, includes the repo key for display purposes
32
+ */
33
+ export type LoadRepoConfigResult = {
34
+ config: Config;
35
+ repoKey: string | null; // null means no remote origin configured
36
+ };