opencode-worktree 0.3.4 → 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 +76 -46
- package/package.json +1 -1
- package/src/config.ts +169 -30
- package/src/git.ts +131 -0
- package/src/opencode.ts +27 -4
- package/src/types.ts +26 -0
- package/src/ui.ts +425 -62
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# opencode-worktree
|
|
2
2
|
|
|
3
|
-
Terminal UI for managing git worktrees and launching
|
|
3
|
+
Terminal UI for managing git worktrees and launching your preferred coding tool in the selected worktree.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -8,18 +8,21 @@ Terminal UI for managing git worktrees and launching `opencode` in the selected
|
|
|
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
|
-
- Open worktree folder in file manager
|
|
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
|
|
20
|
+
- Update notifications when new versions are available
|
|
18
21
|
|
|
19
22
|
## Requirements
|
|
20
23
|
|
|
21
24
|
- Git
|
|
22
|
-
-
|
|
25
|
+
- A CLI tool available on your PATH (e.g., `opencode`, `cursor`, `claude`, `code`)
|
|
23
26
|
|
|
24
27
|
## Install (npm)
|
|
25
28
|
|
|
@@ -42,14 +45,22 @@ opencode-worktree /path/to/your/repo
|
|
|
42
45
|
## Keybindings
|
|
43
46
|
|
|
44
47
|
- `Up`/`Down` or `j`/`k`: navigate
|
|
45
|
-
- `Enter`: open selected worktree in
|
|
48
|
+
- `Enter`: open selected worktree in configured tool (or toggle selection in delete mode)
|
|
46
49
|
- `o`: open worktree folder in file manager or custom editor (configurable)
|
|
47
50
|
- `d`: enter multi-select delete mode (press again to confirm deletion)
|
|
48
51
|
- `n`: create new worktree
|
|
49
|
-
- `
|
|
52
|
+
- `b`: create a new branch from selected worktree's current commit
|
|
53
|
+
- `c`: edit configuration (hooks, open command, launch command)
|
|
50
54
|
- `r`: refresh list
|
|
51
55
|
- `q` or `Esc`: quit (or cancel dialogs/modes)
|
|
52
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
|
+
|
|
53
64
|
### Multi-select delete mode
|
|
54
65
|
|
|
55
66
|
1. Press `d` to enter selection mode
|
|
@@ -59,73 +70,92 @@ opencode-worktree /path/to/your/repo
|
|
|
59
70
|
|
|
60
71
|
## Configuration
|
|
61
72
|
|
|
62
|
-
|
|
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.
|
|
63
74
|
|
|
64
|
-
|
|
75
|
+
Repositories are identified by their git remote URL (e.g., `github.com/user/repo`).
|
|
65
76
|
|
|
66
|
-
|
|
77
|
+
### Configuration structure
|
|
67
78
|
|
|
68
|
-
|
|
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
|
+
```
|
|
69
94
|
|
|
70
|
-
|
|
95
|
+
### Configuration options
|
|
71
96
|
|
|
72
|
-
|
|
73
|
-
|
|
97
|
+
| Option | Description | Default |
|
|
98
|
+
|--------|-------------|---------|
|
|
99
|
+
| `postCreateHook` | Command to run after creating a worktree | none |
|
|
100
|
+
| `openCommand` | Command for opening worktree folders (`o` key) | system default |
|
|
101
|
+
| `launchCommand` | Command to launch when selecting a worktree (`Enter` key) | `opencode` |
|
|
74
102
|
|
|
75
|
-
###
|
|
103
|
+
### Example per-repo configuration
|
|
76
104
|
|
|
77
|
-
|
|
105
|
+
When you edit config in the TUI, settings are saved under the repo's key (derived from git remote URL):
|
|
78
106
|
|
|
79
107
|
```json
|
|
80
108
|
{
|
|
81
|
-
"
|
|
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
|
+
}
|
|
82
123
|
}
|
|
83
124
|
```
|
|
84
125
|
|
|
85
|
-
|
|
126
|
+
### Editing configuration
|
|
86
127
|
|
|
87
|
-
|
|
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.
|
|
88
129
|
|
|
89
|
-
|
|
90
|
-
{
|
|
91
|
-
"postCreateHook": "bun install"
|
|
92
|
-
}
|
|
93
|
-
```
|
|
130
|
+
**Note:** Repositories without a git remote will use default settings. The TUI shows a warning when editing config for repos without a remote.
|
|
94
131
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
132
|
+
### Post-create hooks
|
|
133
|
+
|
|
134
|
+
Run a command automatically after creating a new worktree. Useful for installing dependencies.
|
|
135
|
+
|
|
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.
|
|
137
|
+
|
|
138
|
+
**Examples:** `npm install`, `bun install`, `npm install && npm run setup`
|
|
100
139
|
|
|
101
140
|
### Custom open command
|
|
102
141
|
|
|
103
142
|
Use a custom command when pressing `o` to open worktree folders. Useful for opening in your preferred IDE.
|
|
104
143
|
|
|
105
|
-
|
|
106
|
-
{
|
|
107
|
-
"openCommand": "webstorm"
|
|
108
|
-
}
|
|
109
|
-
```
|
|
144
|
+
**Examples:** `code`, `webstorm`, `idea`
|
|
110
145
|
|
|
111
|
-
|
|
146
|
+
### Custom launch command
|
|
112
147
|
|
|
113
|
-
|
|
114
|
-
{
|
|
115
|
-
"openCommand": "code"
|
|
116
|
-
}
|
|
117
|
-
```
|
|
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.
|
|
118
149
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
```
|
|
150
|
+
**Examples:** `cursor`, `claude`, `code`, `zed`
|
|
151
|
+
|
|
152
|
+
### Migration from v0.3.x
|
|
153
|
+
|
|
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.
|
|
125
155
|
|
|
126
156
|
## Update notifications
|
|
127
157
|
|
|
128
|
-
When a new version is published to npm, the
|
|
158
|
+
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.
|
|
129
159
|
|
|
130
160
|
## Development
|
|
131
161
|
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,74 +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
|
+
// 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;
|
|
7
18
|
};
|
|
8
19
|
|
|
9
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Get the path to the global config file
|
|
22
|
+
*/
|
|
23
|
+
export const getGlobalConfigPath = (): string => {
|
|
24
|
+
return CONFIG_FILE;
|
|
25
|
+
};
|
|
10
26
|
|
|
11
27
|
/**
|
|
12
|
-
* Get the
|
|
28
|
+
* Get the default configuration values
|
|
13
29
|
*/
|
|
14
|
-
export const
|
|
15
|
-
return
|
|
30
|
+
export const getDefaultConfig = (): Config => {
|
|
31
|
+
return {
|
|
32
|
+
postCreateHook: "",
|
|
33
|
+
openCommand: "",
|
|
34
|
+
launchCommand: "opencode",
|
|
35
|
+
};
|
|
16
36
|
};
|
|
17
37
|
|
|
18
38
|
/**
|
|
19
|
-
*
|
|
39
|
+
* Create an empty global config structure
|
|
20
40
|
*/
|
|
21
|
-
|
|
22
|
-
return
|
|
41
|
+
const createEmptyGlobalConfig = (): GlobalConfig => {
|
|
42
|
+
return {
|
|
43
|
+
default: getDefaultConfig(),
|
|
44
|
+
repos: {},
|
|
45
|
+
};
|
|
23
46
|
};
|
|
24
47
|
|
|
25
48
|
/**
|
|
26
|
-
*
|
|
49
|
+
* Ensure the config directory exists
|
|
27
50
|
*/
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
};
|
|
30
61
|
|
|
31
|
-
|
|
32
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Load the entire global config file
|
|
64
|
+
*/
|
|
65
|
+
export const loadGlobalConfig = (): GlobalConfig => {
|
|
66
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
67
|
+
return createEmptyGlobalConfig();
|
|
33
68
|
}
|
|
34
69
|
|
|
35
70
|
try {
|
|
36
|
-
const content = readFileSync(
|
|
71
|
+
const content = readFileSync(CONFIG_FILE, "utf8");
|
|
37
72
|
const parsed = JSON.parse(content);
|
|
38
73
|
|
|
39
|
-
// Validate the config structure
|
|
74
|
+
// Validate and normalize the config structure
|
|
40
75
|
if (typeof parsed !== "object" || parsed === null) {
|
|
41
|
-
return
|
|
76
|
+
return createEmptyGlobalConfig();
|
|
42
77
|
}
|
|
43
78
|
|
|
44
|
-
const
|
|
79
|
+
const globalConfig: GlobalConfig = {
|
|
80
|
+
default: { ...getDefaultConfig() },
|
|
81
|
+
repos: {},
|
|
82
|
+
};
|
|
45
83
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|
|
48
95
|
}
|
|
49
96
|
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
}
|
|
113
|
+
|
|
114
|
+
// Only add if there are actual values
|
|
115
|
+
if (Object.keys(repoConfig).length > 0) {
|
|
116
|
+
globalConfig.repos[key] = repoConfig;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
52
120
|
}
|
|
53
121
|
|
|
54
|
-
return
|
|
122
|
+
return globalConfig;
|
|
55
123
|
} catch {
|
|
56
124
|
// If we can't read or parse the config, return empty
|
|
57
|
-
return
|
|
125
|
+
return createEmptyGlobalConfig();
|
|
58
126
|
}
|
|
59
127
|
};
|
|
60
128
|
|
|
61
129
|
/**
|
|
62
|
-
* Save
|
|
130
|
+
* Save the entire global config file
|
|
63
131
|
*/
|
|
64
|
-
export const
|
|
65
|
-
|
|
132
|
+
export const saveGlobalConfig = (config: GlobalConfig): boolean => {
|
|
133
|
+
if (!ensureConfigDir()) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
66
136
|
|
|
67
137
|
try {
|
|
68
138
|
const content = JSON.stringify(config, null, 2) + "\n";
|
|
69
|
-
writeFileSync(
|
|
139
|
+
writeFileSync(CONFIG_FILE, content, "utf8");
|
|
70
140
|
return true;
|
|
71
141
|
} catch {
|
|
72
142
|
return false;
|
|
73
143
|
}
|
|
74
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/opencode.ts
CHANGED
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Check if a command is available in PATH
|
|
5
|
+
*/
|
|
6
|
+
export const isCommandAvailable = (command: string): boolean => {
|
|
7
|
+
const result = spawnSync(command, ["--version"], {
|
|
5
8
|
stdio: "ignore",
|
|
6
9
|
});
|
|
7
10
|
return result.status === 0;
|
|
8
11
|
};
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
/**
|
|
14
|
+
* @deprecated Use isCommandAvailable instead
|
|
15
|
+
*/
|
|
16
|
+
export const isOpenCodeAvailable = (): boolean => {
|
|
17
|
+
return isCommandAvailable("opencode");
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Launch a command in a worktree directory
|
|
22
|
+
* If customCommand is provided, uses that instead of opencode
|
|
23
|
+
*/
|
|
24
|
+
export const launchCommand = (cwd: string, customCommand?: string): void => {
|
|
25
|
+
const command = customCommand || "opencode";
|
|
26
|
+
|
|
27
|
+
const child = spawn(command, [], {
|
|
12
28
|
cwd,
|
|
13
29
|
stdio: "inherit",
|
|
14
30
|
});
|
|
@@ -19,6 +35,13 @@ export const launchOpenCode = (cwd: string): void => {
|
|
|
19
35
|
});
|
|
20
36
|
};
|
|
21
37
|
|
|
38
|
+
/**
|
|
39
|
+
* @deprecated Use launchCommand instead
|
|
40
|
+
*/
|
|
41
|
+
export const launchOpenCode = (cwd: string): void => {
|
|
42
|
+
launchCommand(cwd);
|
|
43
|
+
};
|
|
44
|
+
|
|
22
45
|
/**
|
|
23
46
|
* Open a path in the system file manager or with a custom command
|
|
24
47
|
* If customCommand is provided, uses that instead of the system default
|
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
|
+
};
|