opencode-worktree 0.3.5 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -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 +368 -58
- package/src/update-check.ts +74 -94
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,111 +100,66 @@ You can configure per-repository settings by creating a `.opencode-worktree.json
|
|
|
70
100
|
| `openCommand` | Command for opening worktree folders (`o` key) | system default |
|
|
71
101
|
| `launchCommand` | Command to launch when selecting a worktree (`Enter` key) | `opencode` |
|
|
72
102
|
|
|
73
|
-
### Example configuration
|
|
103
|
+
### Example per-repo configuration
|
|
104
|
+
|
|
105
|
+
When you edit config in the TUI, settings are saved under the repo's key (derived from git remote URL):
|
|
74
106
|
|
|
75
107
|
```json
|
|
76
108
|
{
|
|
77
|
-
"
|
|
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
|
-
```
|
|
150
|
+
**Examples:** `cursor`, `claude`, `code`, `zed`
|
|
144
151
|
|
|
145
|
-
|
|
152
|
+
### Migration from v0.3.x
|
|
146
153
|
|
|
147
|
-
|
|
148
|
-
{
|
|
149
|
-
"launchCommand": "claude"
|
|
150
|
-
}
|
|
151
|
-
```
|
|
154
|
+
Previous versions stored config in `.opencode-worktree.json` files in each repository. These files are now ignored. Your settings will need to be reconfigured via the TUI (`c` key), which will save them to the new global config location.
|
|
152
155
|
|
|
153
|
-
|
|
154
|
-
{
|
|
155
|
-
"launchCommand": "code"
|
|
156
|
-
}
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
```json
|
|
160
|
-
{
|
|
161
|
-
"launchCommand": "zed"
|
|
162
|
-
}
|
|
163
|
-
```
|
|
156
|
+
## Update notifications
|
|
164
157
|
|
|
165
|
-
|
|
158
|
+
On launch, the app performs a single in-process npm version check and stores the result in a local cache.
|
|
166
159
|
|
|
167
|
-
|
|
168
|
-
{
|
|
169
|
-
"postCreateHook": "npm install",
|
|
170
|
-
"openCommand": "code",
|
|
171
|
-
"launchCommand": "cursor"
|
|
172
|
-
}
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
## Update notifications
|
|
160
|
+
If a newer version is found, the warning appears on the next launch in the title bar as:
|
|
176
161
|
|
|
177
|
-
|
|
162
|
+
`Update: <current> -> <latest> (npm i -g)`
|
|
178
163
|
|
|
179
164
|
## Development
|
|
180
165
|
|
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
|
+
};
|