opencode-worktree 0.3.5 → 0.4.0

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,107 +100,58 @@ 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
- ```
144
-
145
- **Examples:**
150
+ **Examples:** `cursor`, `claude`, `code`, `zed`
146
151
 
147
- ```json
148
- {
149
- "launchCommand": "claude"
150
- }
151
- ```
152
+ ### Migration from v0.3.x
152
153
 
153
- ```json
154
- {
155
- "launchCommand": "code"
156
- }
157
- ```
158
-
159
- ```json
160
- {
161
- "launchCommand": "zed"
162
- }
163
- ```
164
-
165
- ### Full configuration example
166
-
167
- ```json
168
- {
169
- "postCreateHook": "npm install",
170
- "openCommand": "code",
171
- "launchCommand": "cursor"
172
- }
173
- ```
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.
174
155
 
175
156
  ## Update notifications
176
157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-worktree",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
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
+ };
package/src/ui.ts CHANGED
@@ -13,9 +13,12 @@ import {
13
13
  import { checkForUpdate } from "./update-check.js";
14
14
  import { basename } from "node:path";
15
15
  import {
16
+ checkoutBranch,
17
+ createBranchFromCommit,
16
18
  createWorktree,
17
19
  deleteWorktree,
18
20
  getDefaultWorktreesDir,
21
+ getHeadCommit,
19
22
  hasUncommittedChanges,
20
23
  isMainWorktree,
21
24
  listWorktrees,
@@ -24,7 +27,7 @@ import {
24
27
  } from "./git.js";
25
28
  import { isCommandAvailable, launchCommand, openInFileManager } from "./opencode.js";
26
29
  import { WorktreeInfo } from "./types.js";
27
- import { loadRepoConfig, saveRepoConfig, configExists, type Config } from "./config.js";
30
+ import { loadRepoConfig, saveRepoConfig, type Config } from "./config.js";
28
31
  import { runPostCreateHook, type HookResult } from "./hooks.js";
29
32
 
30
33
  type StatusLevel = "info" | "warning" | "error" | "success";
@@ -106,7 +109,16 @@ class WorktreeSelector {
106
109
  private configOpenInput: InputRenderable | null = null;
107
110
  private configLaunchInput: InputRenderable | null = null;
108
111
  private configActiveField: "hook" | "open" | "launch" = "hook";
109
- private isFirstTimeSetup = false;
112
+ private repoKey: string | null = null; // Normalized git remote URL for config lookup
113
+
114
+ // Branch creation state
115
+ private isCreatingBranch = false;
116
+ private branchCreateContainer: BoxRenderable | null = null;
117
+ private branchNameInput: InputRenderable | null = null;
118
+ private sourceWorktree: WorktreeInfo | null = null;
119
+ private pendingBranchName: string | null = null;
120
+ private isAskingCheckout = false;
121
+ private checkoutSelect: SelectRenderable | null = null;
110
122
 
111
123
  constructor(
112
124
  private renderer: CliRenderer,
@@ -115,7 +127,11 @@ class WorktreeSelector {
115
127
  ) {
116
128
  // Load worktrees first to get initial options
117
129
  this.repoRoot = resolveRepoRoot(this.targetPath);
118
- this.repoConfig = this.repoRoot ? loadRepoConfig(this.repoRoot) : {};
130
+ if (this.repoRoot) {
131
+ const { config, repoKey } = loadRepoConfig(this.repoRoot);
132
+ this.repoConfig = config;
133
+ this.repoKey = repoKey;
134
+ }
119
135
  this.opencodeAvailable = isCommandAvailable(this.repoConfig.launchCommand || "opencode");
120
136
  this.worktreeOptions = this.buildInitialOptions();
121
137
 
@@ -195,7 +211,7 @@ class WorktreeSelector {
195
211
  left: 2,
196
212
  top: 20,
197
213
  content:
198
- "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit",
214
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • b branch • c config • q quit",
199
215
  fg: "#64748B",
200
216
  });
201
217
  this.renderer.root.add(this.instructions);
@@ -216,11 +232,6 @@ class WorktreeSelector {
216
232
  });
217
233
 
218
234
  this.selectElement.focus();
219
-
220
- // Check for first-time setup
221
- if (this.repoRoot && !configExists(this.repoRoot)) {
222
- this.showFirstTimeSetup();
223
- }
224
235
  }
225
236
 
226
237
  private getInitialStatusMessage(): string {
@@ -265,7 +276,7 @@ class WorktreeSelector {
265
276
  this.selectElement.visible = true;
266
277
  this.selectElement.focus();
267
278
  this.instructions.content =
268
- "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
279
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • b branch • c config • q quit";
269
280
  return;
270
281
  }
271
282
  this.cleanup(true);
@@ -353,6 +364,24 @@ class WorktreeSelector {
353
364
  return;
354
365
  }
355
366
 
367
+ // Handle branch creation mode
368
+ if (this.isCreatingBranch) {
369
+ if (key.name === "escape") {
370
+ this.hideBranchCreateInput();
371
+ return;
372
+ }
373
+ return;
374
+ }
375
+
376
+ // Handle checkout confirmation mode
377
+ if (this.isAskingCheckout) {
378
+ if (key.name === "escape") {
379
+ this.hideCheckoutConfirm();
380
+ return;
381
+ }
382
+ return;
383
+ }
384
+
356
385
  if (key.name === "q" || key.name === "escape") {
357
386
  this.cleanup(true);
358
387
  return;
@@ -386,6 +415,12 @@ class WorktreeSelector {
386
415
  this.showConfigEditor();
387
416
  return;
388
417
  }
418
+
419
+ // 'b' for creating a new branch from selected worktree
420
+ if (key.name === "b") {
421
+ this.showBranchCreateInput();
422
+ return;
423
+ }
389
424
  }
390
425
 
391
426
  private handleSelection(value: SelectionValue): void {
@@ -412,9 +447,8 @@ class WorktreeSelector {
412
447
  return;
413
448
  }
414
449
 
415
- // Load config to check for custom open command
416
- const config = this.repoRoot ? loadRepoConfig(this.repoRoot) : {};
417
- const customCommand = config.openCommand;
450
+ // Use the already-loaded config's openCommand
451
+ const customCommand = this.repoConfig.openCommand;
418
452
 
419
453
  const success = openInFileManager(worktree.path, customCommand);
420
454
  if (success) {
@@ -497,7 +531,7 @@ class WorktreeSelector {
497
531
 
498
532
  this.selectElement.visible = true;
499
533
  this.instructions.content =
500
- "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
534
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • b branch • c config • q quit";
501
535
  this.selectElement.focus();
502
536
  this.loadWorktrees(selectWorktreePath);
503
537
  }
@@ -523,11 +557,10 @@ class WorktreeSelector {
523
557
  if (result.success) {
524
558
  this.setStatus(`Worktree created at ${result.path}`, "success");
525
559
 
526
- // Check for post-create hook
527
- const config = loadRepoConfig(this.repoRoot);
528
- if (config.postCreateHook) {
560
+ // Check for post-create hook (use already-loaded config)
561
+ if (this.repoConfig.postCreateHook) {
529
562
  this.pendingWorktreePath = result.path;
530
- this.runHook(result.path, config.postCreateHook);
563
+ this.runHook(result.path, this.repoConfig.postCreateHook);
531
564
  } else {
532
565
  // No hook, launch command directly
533
566
  this.hideCreateWorktreeInput();
@@ -692,7 +725,7 @@ class WorktreeSelector {
692
725
  this.selectElement.visible = true;
693
726
  this.selectElement.focus();
694
727
  this.instructions.content =
695
- "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
728
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • b branch • c config • q quit";
696
729
  }
697
730
  }
698
731
 
@@ -716,11 +749,6 @@ class WorktreeSelector {
716
749
 
717
750
  // ========== Config Editor Methods ==========
718
751
 
719
- private showFirstTimeSetup(): void {
720
- this.isFirstTimeSetup = true;
721
- this.showConfigEditor();
722
- }
723
-
724
752
  private showConfigEditor(): void {
725
753
  if (!this.repoRoot) {
726
754
  this.setStatus("No git repository found.", "error");
@@ -732,12 +760,18 @@ class WorktreeSelector {
732
760
  this.selectElement.visible = false;
733
761
  this.selectElement.blur();
734
762
 
735
- // Load existing config to pre-fill
736
- const existingConfig = loadRepoConfig(this.repoRoot);
737
-
738
- const title = this.isFirstTimeSetup
739
- ? "First-time Setup: Project Configuration"
740
- : "Edit Project Configuration";
763
+ // Build title showing repo key
764
+ let title: string;
765
+ if (this.repoKey) {
766
+ // Truncate if too long for the box
767
+ const maxKeyLen = 50;
768
+ const displayKey = this.repoKey.length > maxKeyLen
769
+ ? "..." + this.repoKey.slice(-maxKeyLen + 3)
770
+ : this.repoKey;
771
+ title = `Config: ${displayKey}`;
772
+ } else {
773
+ title = "Config: [no remote]";
774
+ }
741
775
 
742
776
  this.configContainer = new BoxRenderable(this.renderer, {
743
777
  id: "config-container",
@@ -773,7 +807,7 @@ class WorktreeSelector {
773
807
  top: 2,
774
808
  width: 72,
775
809
  placeholder: "npm install",
776
- value: existingConfig.postCreateHook || "",
810
+ value: this.repoConfig.postCreateHook || "",
777
811
  focusedBackgroundColor: "#1E293B",
778
812
  backgroundColor: "#1E293B",
779
813
  });
@@ -797,7 +831,7 @@ class WorktreeSelector {
797
831
  top: 5,
798
832
  width: 72,
799
833
  placeholder: "open (default)",
800
- value: existingConfig.openCommand || "",
834
+ value: this.repoConfig.openCommand || "",
801
835
  focusedBackgroundColor: "#1E293B",
802
836
  backgroundColor: "#1E293B",
803
837
  });
@@ -821,30 +855,38 @@ class WorktreeSelector {
821
855
  top: 8,
822
856
  width: 72,
823
857
  placeholder: "opencode (default)",
824
- value: existingConfig.launchCommand || "",
858
+ value: this.repoConfig.launchCommand || "",
825
859
  focusedBackgroundColor: "#1E293B",
826
860
  backgroundColor: "#1E293B",
827
861
  });
828
862
  this.configContainer.add(this.configLaunchInput);
829
863
 
830
- // Help text
864
+ // Help text - show warning if no remote
865
+ let helpContent: string;
866
+ if (this.repoKey) {
867
+ helpContent = "Tab to switch fields • Leave empty to use defaults";
868
+ } else {
869
+ helpContent = "No remote - config won't be saved for this repo";
870
+ }
871
+
831
872
  const helpText = new TextRenderable(this.renderer, {
832
873
  id: "config-help",
833
874
  position: "absolute",
834
875
  left: 1,
835
876
  top: 10,
836
- content: "Tab to switch fields • Leave empty to use defaults",
837
- fg: "#64748B",
877
+ content: helpContent,
878
+ fg: this.repoKey ? "#64748B" : "#F59E0B",
838
879
  });
839
880
  this.configContainer.add(helpText);
840
881
 
841
882
  this.instructions.content = "Tab switch • Enter save • Esc cancel";
842
- this.setStatus(
843
- this.isFirstTimeSetup
844
- ? "Welcome! Configure your project settings."
845
- : "Edit project configuration.",
846
- "info"
847
- );
883
+
884
+ // Show warning if no remote
885
+ if (this.repoKey) {
886
+ this.setStatus("Edit project configuration.", "info");
887
+ } else {
888
+ this.setStatus("Warning: No git remote. Config changes won't be saved.", "warning");
889
+ }
848
890
 
849
891
  // Delay focus to prevent the triggering keypress from being captured
850
892
  setTimeout(() => {
@@ -855,7 +897,6 @@ class WorktreeSelector {
855
897
 
856
898
  private hideConfigEditor(): void {
857
899
  this.isEditingConfig = false;
858
- this.isFirstTimeSetup = false;
859
900
 
860
901
  if (this.configHookInput) {
861
902
  this.configHookInput.blur();
@@ -877,7 +918,7 @@ class WorktreeSelector {
877
918
 
878
919
  this.selectElement.visible = true;
879
920
  this.instructions.content =
880
- "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
921
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • b branch • c config • q quit";
881
922
 
882
923
  // Delay focus to prevent the Enter keypress from triggering a selection
883
924
  setTimeout(() => {
@@ -935,6 +976,279 @@ class WorktreeSelector {
935
976
  this.hideConfigEditor();
936
977
  }
937
978
 
979
+ // ========== Branch Creation Methods ==========
980
+
981
+ private showBranchCreateInput(): void {
982
+ const worktree = this.getSelectedWorktree();
983
+ if (!worktree) {
984
+ this.setStatus("Select a worktree to create a branch from.", "warning");
985
+ return;
986
+ }
987
+
988
+ if (!this.repoRoot) {
989
+ this.setStatus("No git repository found.", "error");
990
+ return;
991
+ }
992
+
993
+ this.isCreatingBranch = true;
994
+ this.sourceWorktree = worktree;
995
+ this.selectElement.visible = false;
996
+ this.selectElement.blur();
997
+
998
+ const sourceName = worktree.branch || basename(worktree.path);
999
+
1000
+ this.branchCreateContainer = new BoxRenderable(this.renderer, {
1001
+ id: "branch-create-container",
1002
+ position: "absolute",
1003
+ left: 2,
1004
+ top: 3,
1005
+ width: 76,
1006
+ height: 7,
1007
+ borderStyle: "single",
1008
+ borderColor: "#38BDF8",
1009
+ title: `New Branch from: ${sourceName}`,
1010
+ titleAlignment: "center",
1011
+ backgroundColor: "#0F172A",
1012
+ border: true,
1013
+ });
1014
+ this.renderer.root.add(this.branchCreateContainer);
1015
+
1016
+ const inputLabel = new TextRenderable(this.renderer, {
1017
+ id: "branch-name-label",
1018
+ position: "absolute",
1019
+ left: 1,
1020
+ top: 1,
1021
+ content: "Branch name:",
1022
+ fg: "#E2E8F0",
1023
+ });
1024
+ this.branchCreateContainer.add(inputLabel);
1025
+
1026
+ this.branchNameInput = new InputRenderable(this.renderer, {
1027
+ id: "branch-name-input",
1028
+ position: "absolute",
1029
+ left: 14,
1030
+ top: 1,
1031
+ width: 58,
1032
+ placeholder: "feature/new-branch",
1033
+ focusedBackgroundColor: "#1E293B",
1034
+ backgroundColor: "#1E293B",
1035
+ });
1036
+ this.branchCreateContainer.add(this.branchNameInput);
1037
+
1038
+ const helpText = new TextRenderable(this.renderer, {
1039
+ id: "branch-create-help",
1040
+ position: "absolute",
1041
+ left: 1,
1042
+ top: 3,
1043
+ content: `Branch will start from commit: ${worktree.head.slice(0, 8)}`,
1044
+ fg: "#64748B",
1045
+ });
1046
+ this.branchCreateContainer.add(helpText);
1047
+
1048
+ this.branchNameInput.on(InputRenderableEvents.CHANGE, (value: string) => {
1049
+ this.handleBranchCreate(value);
1050
+ });
1051
+
1052
+ this.instructions.content = "Enter to create • Esc to cancel";
1053
+ this.setStatus("Enter a name for the new branch.", "info");
1054
+
1055
+ this.branchNameInput.focus();
1056
+ this.renderer.requestRender();
1057
+ }
1058
+
1059
+ private hideBranchCreateInput(): void {
1060
+ this.isCreatingBranch = false;
1061
+ this.sourceWorktree = null;
1062
+
1063
+ if (this.branchNameInput) {
1064
+ this.branchNameInput.blur();
1065
+ }
1066
+
1067
+ if (this.branchCreateContainer) {
1068
+ this.renderer.root.remove(this.branchCreateContainer.id);
1069
+ this.branchCreateContainer = null;
1070
+ this.branchNameInput = null;
1071
+ }
1072
+
1073
+ this.selectElement.visible = true;
1074
+ this.instructions.content =
1075
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • b branch • c config • q quit";
1076
+ this.selectElement.focus();
1077
+ this.renderer.requestRender();
1078
+ }
1079
+
1080
+ private handleBranchCreate(branchName: string): void {
1081
+ const trimmed = branchName.trim();
1082
+ if (!trimmed) {
1083
+ this.setStatus("Branch name cannot be empty.", "error");
1084
+ return;
1085
+ }
1086
+
1087
+ if (!this.repoRoot || !this.sourceWorktree) {
1088
+ this.setStatus("No source worktree selected.", "error");
1089
+ return;
1090
+ }
1091
+
1092
+ const commitHash = this.sourceWorktree.head;
1093
+ if (!commitHash) {
1094
+ this.setStatus("Could not determine source commit.", "error");
1095
+ return;
1096
+ }
1097
+
1098
+ this.setStatus(`Creating branch '${trimmed}'...`, "info");
1099
+ this.renderer.requestRender();
1100
+
1101
+ const result = createBranchFromCommit(this.repoRoot, trimmed, commitHash);
1102
+
1103
+ if (result.success) {
1104
+ this.pendingBranchName = trimmed;
1105
+ // Hide the input and show checkout confirmation
1106
+ if (this.branchNameInput) {
1107
+ this.branchNameInput.blur();
1108
+ }
1109
+ if (this.branchCreateContainer) {
1110
+ this.renderer.root.remove(this.branchCreateContainer.id);
1111
+ this.branchCreateContainer = null;
1112
+ this.branchNameInput = null;
1113
+ }
1114
+ this.isCreatingBranch = false;
1115
+
1116
+ this.showCheckoutConfirm(trimmed);
1117
+ } else {
1118
+ this.setStatus(`Failed to create branch: ${result.error}`, "error");
1119
+ }
1120
+ }
1121
+
1122
+ private showCheckoutConfirm(branchName: string): void {
1123
+ if (!this.sourceWorktree) {
1124
+ this.setStatus("No source worktree.", "error");
1125
+ this.hideBranchCreateInput();
1126
+ return;
1127
+ }
1128
+
1129
+ this.isAskingCheckout = true;
1130
+
1131
+ const sourceName = this.sourceWorktree.branch || basename(this.sourceWorktree.path);
1132
+
1133
+ this.branchCreateContainer = new BoxRenderable(this.renderer, {
1134
+ id: "checkout-confirm-container",
1135
+ position: "absolute",
1136
+ left: 2,
1137
+ top: 3,
1138
+ width: 76,
1139
+ height: 8,
1140
+ borderStyle: "single",
1141
+ borderColor: "#10B981",
1142
+ title: `Branch '${branchName}' Created`,
1143
+ titleAlignment: "center",
1144
+ backgroundColor: "#0F172A",
1145
+ border: true,
1146
+ });
1147
+ this.renderer.root.add(this.branchCreateContainer);
1148
+
1149
+ const infoText = new TextRenderable(this.renderer, {
1150
+ id: "checkout-info",
1151
+ position: "absolute",
1152
+ left: 1,
1153
+ top: 1,
1154
+ content: `Checkout '${branchName}' in worktree '${sourceName}'?`,
1155
+ fg: "#E2E8F0",
1156
+ });
1157
+ this.branchCreateContainer.add(infoText);
1158
+
1159
+ this.checkoutSelect = new SelectRenderable(this.renderer, {
1160
+ id: "checkout-select",
1161
+ position: "absolute",
1162
+ left: 1,
1163
+ top: 3,
1164
+ width: 72,
1165
+ height: 3,
1166
+ options: [
1167
+ {
1168
+ name: "Yes, checkout the new branch",
1169
+ description: "Switch this worktree to the new branch",
1170
+ value: "checkout",
1171
+ },
1172
+ {
1173
+ name: "No, keep current branch",
1174
+ description: "Branch created but worktree stays on current branch",
1175
+ value: "keep",
1176
+ },
1177
+ ],
1178
+ backgroundColor: "#0F172A",
1179
+ focusedBackgroundColor: "#1E293B",
1180
+ selectedBackgroundColor: "#1E3A5F",
1181
+ textColor: "#E2E8F0",
1182
+ selectedTextColor: "#38BDF8",
1183
+ descriptionColor: "#94A3B8",
1184
+ selectedDescriptionColor: "#E2E8F0",
1185
+ showDescription: true,
1186
+ wrapSelection: true,
1187
+ });
1188
+ this.branchCreateContainer.add(this.checkoutSelect);
1189
+
1190
+ this.checkoutSelect.on(
1191
+ SelectRenderableEvents.ITEM_SELECTED,
1192
+ (_index: number, option: SelectOption) => {
1193
+ this.handleCheckoutChoice(option.value as string);
1194
+ }
1195
+ );
1196
+
1197
+ this.instructions.content = "↑/↓ select • Enter confirm • Esc cancel";
1198
+ this.setStatus(`Branch '${branchName}' created successfully!`, "success");
1199
+
1200
+ this.checkoutSelect.focus();
1201
+ this.renderer.requestRender();
1202
+ }
1203
+
1204
+ private hideCheckoutConfirm(): void {
1205
+ this.isAskingCheckout = false;
1206
+ this.pendingBranchName = null;
1207
+ this.sourceWorktree = null;
1208
+
1209
+ if (this.checkoutSelect) {
1210
+ this.checkoutSelect.blur();
1211
+ this.checkoutSelect = null;
1212
+ }
1213
+
1214
+ if (this.branchCreateContainer) {
1215
+ this.renderer.root.remove(this.branchCreateContainer.id);
1216
+ this.branchCreateContainer = null;
1217
+ }
1218
+
1219
+ this.selectElement.visible = true;
1220
+ this.instructions.content =
1221
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • b branch • c config • q quit";
1222
+ this.selectElement.focus();
1223
+ this.loadWorktrees();
1224
+ this.renderer.requestRender();
1225
+ }
1226
+
1227
+ private handleCheckoutChoice(choice: string): void {
1228
+ if (choice === "checkout" && this.pendingBranchName && this.sourceWorktree) {
1229
+ this.setStatus(`Checking out '${this.pendingBranchName}'...`, "info");
1230
+ this.renderer.requestRender();
1231
+
1232
+ const result = checkoutBranch(this.sourceWorktree.path, this.pendingBranchName);
1233
+
1234
+ if (result.success) {
1235
+ this.setStatus(
1236
+ `Switched to branch '${this.pendingBranchName}'.`,
1237
+ "success"
1238
+ );
1239
+ } else {
1240
+ this.setStatus(`Failed to checkout: ${result.error}`, "error");
1241
+ }
1242
+ } else {
1243
+ this.setStatus(
1244
+ `Branch '${this.pendingBranchName}' created (not checked out).`,
1245
+ "success"
1246
+ );
1247
+ }
1248
+
1249
+ this.hideCheckoutConfirm();
1250
+ }
1251
+
938
1252
  private loadWorktrees(selectWorktreePath?: string): void {
939
1253
  this.repoRoot = resolveRepoRoot(this.targetPath);
940
1254
  if (!this.repoRoot) {
@@ -1242,7 +1556,7 @@ class WorktreeSelector {
1242
1556
 
1243
1557
  this.selectElement.visible = true;
1244
1558
  this.instructions.content =
1245
- "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
1559
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • b branch • c config • q quit";
1246
1560
  this.selectElement.focus();
1247
1561
  this.loadWorktrees();
1248
1562
  }
@@ -1371,7 +1685,7 @@ class WorktreeSelector {
1371
1685
 
1372
1686
  this.loadWorktrees();
1373
1687
  this.instructions.content =
1374
- "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
1688
+ "↑/↓ navigate • Enter open • o folder • d delete • n new • b branch • c config • q quit";
1375
1689
  this.renderer.requestRender();
1376
1690
  }
1377
1691