opencode-worktree 0.2.9 → 0.3.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 +84 -4
- package/package.json +1 -1
- package/src/config.ts +74 -0
- package/src/git.ts +76 -3
- package/src/hooks.ts +62 -0
- package/src/opencode.ts +40 -0
- package/src/types.ts +4 -0
- package/src/ui.ts +894 -23
package/README.md
CHANGED
|
@@ -4,10 +4,15 @@ Terminal UI for managing git worktrees and launching `opencode` in the selected
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- Lists all worktrees with branch, path, and
|
|
7
|
+
- Lists all worktrees with branch, path, and metadata
|
|
8
|
+
- Worktree metadata display: last edited time, dirty status, remote tracking
|
|
9
|
+
- Status indicators: `[main]` for main worktree, `[*]` for uncommitted changes, `[local]` for local-only branches
|
|
8
10
|
- Create new worktrees directly from the TUI
|
|
11
|
+
- Post-create hooks: automatically run commands (e.g., `npm install`) after creating a worktree
|
|
12
|
+
- Open worktree folder in file manager
|
|
9
13
|
- Unlink worktrees (remove directory, keep branch)
|
|
10
14
|
- Delete worktrees and local branches (never remote)
|
|
15
|
+
- Multi-select delete mode for batch deletion
|
|
11
16
|
- Launches `opencode` in the selected worktree
|
|
12
17
|
- Refresh list on demand
|
|
13
18
|
|
|
@@ -37,11 +42,86 @@ opencode-worktree /path/to/your/repo
|
|
|
37
42
|
## Keybindings
|
|
38
43
|
|
|
39
44
|
- `Up`/`Down` or `j`/`k`: navigate
|
|
40
|
-
- `Enter`: open selected worktree
|
|
41
|
-
- `
|
|
45
|
+
- `Enter`: open selected worktree in opencode (or toggle selection in delete mode)
|
|
46
|
+
- `o`: open worktree folder in file manager or custom editor (configurable)
|
|
47
|
+
- `d`: enter multi-select delete mode (press again to confirm deletion)
|
|
42
48
|
- `n`: create new worktree
|
|
49
|
+
- `c`: edit configuration (post-create hooks)
|
|
43
50
|
- `r`: refresh list
|
|
44
|
-
- `q` or `Esc`: quit (or cancel dialogs)
|
|
51
|
+
- `q` or `Esc`: quit (or cancel dialogs/modes)
|
|
52
|
+
|
|
53
|
+
### Multi-select delete mode
|
|
54
|
+
|
|
55
|
+
1. Press `d` to enter selection mode
|
|
56
|
+
2. Navigate with arrow keys and press `Enter` to toggle worktrees for deletion
|
|
57
|
+
3. Press `d` again to confirm and choose unlink/delete action
|
|
58
|
+
4. Press `Esc` to cancel and return to normal mode
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
You can configure per-repository settings by creating a `.opencode-worktree.json` file in your repository root.
|
|
63
|
+
|
|
64
|
+
### First-time setup
|
|
65
|
+
|
|
66
|
+
When you first run `opencode-worktree` in a repository without a configuration file, you'll be prompted to configure a post-create hook. You can also skip this step and configure it later by pressing `c`.
|
|
67
|
+
|
|
68
|
+
### Editing configuration
|
|
69
|
+
|
|
70
|
+
Press `c` at any time to edit your configuration. You can configure:
|
|
71
|
+
|
|
72
|
+
- **Post-create hook**: Command to run after creating a worktree (e.g., `npm install`)
|
|
73
|
+
- **Open command**: Custom command for opening worktree folders (e.g., `webstorm`, `code`)
|
|
74
|
+
|
|
75
|
+
### Post-create hooks
|
|
76
|
+
|
|
77
|
+
Run a command automatically after creating a new worktree. Useful for installing dependencies.
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"postCreateHook": "npm install"
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The hook output is streamed to the TUI in real-time. If the hook fails, you can choose to open opencode anyway or cancel.
|
|
86
|
+
|
|
87
|
+
**Examples:**
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"postCreateHook": "bun install"
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"postCreateHook": "npm install && npm run setup"
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Custom open command
|
|
102
|
+
|
|
103
|
+
Use a custom command when pressing `o` to open worktree folders. Useful for opening in your preferred IDE.
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"openCommand": "webstorm"
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Examples:**
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"openCommand": "code"
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"postCreateHook": "npm install",
|
|
122
|
+
"openCommand": "webstorm"
|
|
123
|
+
}
|
|
124
|
+
```
|
|
45
125
|
|
|
46
126
|
## Update notifications
|
|
47
127
|
|
package/package.json
CHANGED
package/src/config.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type Config = {
|
|
5
|
+
postCreateHook?: string;
|
|
6
|
+
openCommand?: string; // Custom command to open worktree folder (e.g., "webstorm", "code")
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const CONFIG_FILENAME = ".opencode-worktree.json";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get the path to the config file for a repo
|
|
13
|
+
*/
|
|
14
|
+
export const getConfigPath = (repoRoot: string): string => {
|
|
15
|
+
return join(repoRoot, CONFIG_FILENAME);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if a config file exists for the repo
|
|
20
|
+
*/
|
|
21
|
+
export const configExists = (repoRoot: string): boolean => {
|
|
22
|
+
return existsSync(getConfigPath(repoRoot));
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load per-repo configuration from .opencode-worktree.json in the repo root
|
|
27
|
+
*/
|
|
28
|
+
export const loadRepoConfig = (repoRoot: string): Config => {
|
|
29
|
+
const configPath = getConfigPath(repoRoot);
|
|
30
|
+
|
|
31
|
+
if (!existsSync(configPath)) {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const content = readFileSync(configPath, "utf8");
|
|
37
|
+
const parsed = JSON.parse(content);
|
|
38
|
+
|
|
39
|
+
// Validate the config structure
|
|
40
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const config: Config = {};
|
|
45
|
+
|
|
46
|
+
if (typeof parsed.postCreateHook === "string") {
|
|
47
|
+
config.postCreateHook = parsed.postCreateHook;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof parsed.openCommand === "string") {
|
|
51
|
+
config.openCommand = parsed.openCommand;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return config;
|
|
55
|
+
} catch {
|
|
56
|
+
// If we can't read or parse the config, return empty
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Save configuration to .opencode-worktree.json in the repo root
|
|
63
|
+
*/
|
|
64
|
+
export const saveRepoConfig = (repoRoot: string, config: Config): boolean => {
|
|
65
|
+
const configPath = getConfigPath(repoRoot);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const content = JSON.stringify(config, null, 2) + "\n";
|
|
69
|
+
writeFileSync(configPath, content, "utf8");
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
};
|
package/src/git.ts
CHANGED
|
@@ -17,11 +17,19 @@ export const resolveRepoRoot = (cwd: string): string | null => {
|
|
|
17
17
|
export const parseWorktreeList = (output: string): WorktreeInfo[] => {
|
|
18
18
|
const lines = output.split(/\r?\n/);
|
|
19
19
|
const worktrees: WorktreeInfo[] = [];
|
|
20
|
-
let current: WorktreeInfo | null = null;
|
|
20
|
+
let current: Partial<WorktreeInfo> | null = null;
|
|
21
21
|
|
|
22
22
|
const pushCurrent = (): void => {
|
|
23
23
|
if (current?.path) {
|
|
24
|
-
worktrees.push(
|
|
24
|
+
worktrees.push({
|
|
25
|
+
path: current.path,
|
|
26
|
+
head: current.head || "",
|
|
27
|
+
branch: current.branch || null,
|
|
28
|
+
isDetached: current.isDetached || false,
|
|
29
|
+
isDirty: false,
|
|
30
|
+
isOnRemote: false,
|
|
31
|
+
lastModified: null,
|
|
32
|
+
});
|
|
25
33
|
}
|
|
26
34
|
};
|
|
27
35
|
|
|
@@ -71,7 +79,10 @@ export const listWorktrees = (cwd: string): WorktreeInfo[] => {
|
|
|
71
79
|
stdio: ["ignore", "pipe", "ignore"],
|
|
72
80
|
encoding: "utf8",
|
|
73
81
|
});
|
|
74
|
-
|
|
82
|
+
const worktrees = parseWorktreeList(output);
|
|
83
|
+
|
|
84
|
+
// Enrich each worktree with metadata
|
|
85
|
+
return worktrees.map((wt) => enrichWorktreeInfo(repoRoot, wt));
|
|
75
86
|
};
|
|
76
87
|
|
|
77
88
|
export type CreateWorktreeResult =
|
|
@@ -133,6 +144,68 @@ export const hasUncommittedChanges = (worktreePath: string): boolean => {
|
|
|
133
144
|
}
|
|
134
145
|
};
|
|
135
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Get the last commit date for a worktree
|
|
149
|
+
*/
|
|
150
|
+
export const getLastCommitDate = (worktreePath: string): Date | null => {
|
|
151
|
+
try {
|
|
152
|
+
const output = execFileSync(
|
|
153
|
+
"git",
|
|
154
|
+
["log", "-1", "--format=%ci"],
|
|
155
|
+
{
|
|
156
|
+
cwd: worktreePath,
|
|
157
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
158
|
+
encoding: "utf8",
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
const dateStr = output.trim();
|
|
162
|
+
if (!dateStr) return null;
|
|
163
|
+
return new Date(dateStr);
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check if a branch exists on remote (origin)
|
|
171
|
+
*/
|
|
172
|
+
export const isBranchOnRemote = (
|
|
173
|
+
repoRoot: string,
|
|
174
|
+
branchName: string
|
|
175
|
+
): boolean => {
|
|
176
|
+
try {
|
|
177
|
+
const output = execFileSync(
|
|
178
|
+
"git",
|
|
179
|
+
["branch", "-r", "--list", `origin/${branchName}`],
|
|
180
|
+
{
|
|
181
|
+
cwd: repoRoot,
|
|
182
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
183
|
+
encoding: "utf8",
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
return output.trim().length > 0;
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Enrich worktree info with metadata (dirty status, remote status, last modified)
|
|
194
|
+
*/
|
|
195
|
+
export const enrichWorktreeInfo = (
|
|
196
|
+
repoRoot: string,
|
|
197
|
+
worktree: WorktreeInfo
|
|
198
|
+
): WorktreeInfo => {
|
|
199
|
+
return {
|
|
200
|
+
...worktree,
|
|
201
|
+
isDirty: hasUncommittedChanges(worktree.path),
|
|
202
|
+
isOnRemote: worktree.branch
|
|
203
|
+
? isBranchOnRemote(repoRoot, worktree.branch)
|
|
204
|
+
: false,
|
|
205
|
+
lastModified: getLastCommitDate(worktree.path),
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
|
|
136
209
|
/**
|
|
137
210
|
* Check if a worktree is the main worktree (the original repo clone)
|
|
138
211
|
*/
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export type HookResult = {
|
|
4
|
+
success: boolean;
|
|
5
|
+
exitCode: number | null;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type HookCallbacks = {
|
|
9
|
+
onOutput: (data: string) => void;
|
|
10
|
+
onComplete: (result: HookResult) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run a post-create hook command with streaming output
|
|
15
|
+
* Returns a function to abort the hook if needed
|
|
16
|
+
*/
|
|
17
|
+
export const runPostCreateHook = (
|
|
18
|
+
worktreePath: string,
|
|
19
|
+
command: string,
|
|
20
|
+
callbacks: HookCallbacks
|
|
21
|
+
): (() => void) => {
|
|
22
|
+
const shell = process.platform === "win32" ? "cmd" : "/bin/sh";
|
|
23
|
+
const shellFlag = process.platform === "win32" ? "/c" : "-c";
|
|
24
|
+
|
|
25
|
+
const child = spawn(shell, [shellFlag, command], {
|
|
26
|
+
cwd: worktreePath,
|
|
27
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
28
|
+
env: { ...process.env },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Stream stdout
|
|
32
|
+
child.stdout?.on("data", (data: Buffer) => {
|
|
33
|
+
callbacks.onOutput(data.toString());
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Stream stderr
|
|
37
|
+
child.stderr?.on("data", (data: Buffer) => {
|
|
38
|
+
callbacks.onOutput(data.toString());
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Handle completion
|
|
42
|
+
child.on("close", (code: number | null) => {
|
|
43
|
+
callbacks.onComplete({
|
|
44
|
+
success: code === 0,
|
|
45
|
+
exitCode: code,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Handle errors
|
|
50
|
+
child.on("error", (err: Error) => {
|
|
51
|
+
callbacks.onOutput(`Error: ${err.message}\n`);
|
|
52
|
+
callbacks.onComplete({
|
|
53
|
+
success: false,
|
|
54
|
+
exitCode: null,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Return abort function
|
|
59
|
+
return () => {
|
|
60
|
+
child.kill("SIGTERM");
|
|
61
|
+
};
|
|
62
|
+
};
|
package/src/opencode.ts
CHANGED
|
@@ -18,3 +18,43 @@ export const launchOpenCode = (cwd: string): void => {
|
|
|
18
18
|
process.exit(exitCode);
|
|
19
19
|
});
|
|
20
20
|
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Open a path in the system file manager or with a custom command
|
|
24
|
+
* If customCommand is provided, uses that instead of the system default
|
|
25
|
+
*/
|
|
26
|
+
export const openInFileManager = (path: string, customCommand?: string): boolean => {
|
|
27
|
+
let command: string;
|
|
28
|
+
let args: string[];
|
|
29
|
+
|
|
30
|
+
if (customCommand) {
|
|
31
|
+
// Use custom command (e.g., "webstorm", "code")
|
|
32
|
+
command = customCommand;
|
|
33
|
+
args = [path];
|
|
34
|
+
} else {
|
|
35
|
+
// Use system default
|
|
36
|
+
const platform = process.platform;
|
|
37
|
+
|
|
38
|
+
if (platform === "darwin") {
|
|
39
|
+
command = "open";
|
|
40
|
+
args = [path];
|
|
41
|
+
} else if (platform === "win32") {
|
|
42
|
+
command = "explorer";
|
|
43
|
+
args = [path];
|
|
44
|
+
} else {
|
|
45
|
+
// Linux and others
|
|
46
|
+
command = "xdg-open";
|
|
47
|
+
args = [path];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
spawn(command, args, {
|
|
53
|
+
detached: true,
|
|
54
|
+
stdio: "ignore",
|
|
55
|
+
}).unref();
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
};
|