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 +56 -75
- package/package.json +1 -1
- package/src/config.ts +168 -34
- package/src/git.ts +131 -0
- package/src/types.ts +26 -0
- package/src/ui.ts +360 -46
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
|
-
|
|
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
|
-
"
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
{
|
|
141
|
-
"launchCommand": "cursor"
|
|
142
|
-
}
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
**Examples:**
|
|
150
|
+
**Examples:** `cursor`, `claude`, `code`, `zed`
|
|
146
151
|
|
|
147
|
-
|
|
148
|
-
{
|
|
149
|
-
"launchCommand": "claude"
|
|
150
|
-
}
|
|
151
|
-
```
|
|
152
|
+
### Migration from v0.3.x
|
|
152
153
|
|
|
153
|
-
|
|
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
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
28
|
+
* Get the default configuration values
|
|
14
29
|
*/
|
|
15
|
-
export const
|
|
16
|
-
return
|
|
30
|
+
export const getDefaultConfig = (): Config => {
|
|
31
|
+
return {
|
|
32
|
+
postCreateHook: "",
|
|
33
|
+
openCommand: "",
|
|
34
|
+
launchCommand: "opencode",
|
|
35
|
+
};
|
|
17
36
|
};
|
|
18
37
|
|
|
19
38
|
/**
|
|
20
|
-
*
|
|
39
|
+
* Create an empty global config structure
|
|
21
40
|
*/
|
|
22
|
-
|
|
23
|
-
return
|
|
41
|
+
const createEmptyGlobalConfig = (): GlobalConfig => {
|
|
42
|
+
return {
|
|
43
|
+
default: getDefaultConfig(),
|
|
44
|
+
repos: {},
|
|
45
|
+
};
|
|
24
46
|
};
|
|
25
47
|
|
|
26
48
|
/**
|
|
27
|
-
*
|
|
49
|
+
* Ensure the config directory exists
|
|
28
50
|
*/
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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(
|
|
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
|
|
79
|
+
const globalConfig: GlobalConfig = {
|
|
80
|
+
default: { ...getDefaultConfig() },
|
|
81
|
+
repos: {},
|
|
82
|
+
};
|
|
46
83
|
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
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
|
|
130
|
+
* Save the entire global config file
|
|
68
131
|
*/
|
|
69
|
-
export const
|
|
70
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
416
|
-
const
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
|