@taranek/orche 0.0.30 → 0.0.32
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 +249 -0
- package/dist/cli.js +3 -1
- package/dist/types.d.ts +11 -0
- package/dist/worktree.d.ts +23 -1
- package/dist/worktree.js +58 -2
- package/dist/worktree.test.d.ts +1 -0
- package/dist/worktree.test.js +91 -0
- package/package.json +2 -1
- package/src/cli.ts +3 -1
- package/src/types.ts +13 -0
- package/src/worktree.test.ts +122 -0
- package/src/worktree.ts +74 -1
package/README.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/taranek/orche/master/assets/orche.svg" alt="orche" width="128" height="128" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">orche</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">Orchestrate coding agents across isolated git worktrees with a built-in code review UI.</p>
|
|
8
|
+
|
|
9
|
+
https://github.com/user-attachments/assets/35150d31-84ce-4f1d-8df9-4aef179dc532
|
|
10
|
+
|
|
11
|
+
orche runs your coding agents in isolated git worktrees, so several can work at once without stepping on each other. When an agent finishes, you review its diff in a desktop app and send comments straight back to its terminal.
|
|
12
|
+
|
|
13
|
+
Three commands do the work:
|
|
14
|
+
|
|
15
|
+
- `orche start <task>` opens a tmux or cmux session with your agent, a dev server, and a spare terminal, each in its own worktree.
|
|
16
|
+
- `orche review` opens the review app for a worktree. Leave line-level comments, hit submit, and the feedback lands in the agent's pane.
|
|
17
|
+
- `orche prune` cleans up the worktrees you're done with.
|
|
18
|
+
|
|
19
|
+
### How it works
|
|
20
|
+
|
|
21
|
+
1. Run `orche start fix-auth`. A session opens with your agent (Claude, Codex, whatever you configured), a dev server, and a spare terminal.
|
|
22
|
+
2. The agent works in its own worktree.
|
|
23
|
+
3. When it's done, run `orche review`, or tell the agent to.
|
|
24
|
+
4. Read the diff, leave comments, submit. The feedback goes to the agent's pane.
|
|
25
|
+
5. On to the next task.
|
|
26
|
+
|
|
27
|
+
## Prerequisites
|
|
28
|
+
|
|
29
|
+
- [Node.js](https://nodejs.org/) v18 or newer
|
|
30
|
+
- [tmux](https://github.com/tmux/tmux) or [cmux](https://cmux.dev/) for session management
|
|
31
|
+
- [git](https://git-scm.com/)
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install -g @taranek/orche
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
From inside a git repo, run:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
orche init
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That writes a starter `.orche.json` and adds `.orche/` to your `.gitignore`. Open the config and set the panes you want, an agent and a dev server to start with:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"layout": {
|
|
52
|
+
"direction": "horizontal",
|
|
53
|
+
"panes": [
|
|
54
|
+
{ "name": "agent", "command": "claude" },
|
|
55
|
+
{ "name": "dev", "command": "yarn dev" }
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then start a session:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
orche start fix-auth
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This creates a worktree under `.orche/worktrees/`, opens a tmux or cmux session with your panes, and runs each pane's command. When you want to look at the result, run `orche review`.
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
orche init [-f] Bootstrap .orche.json + .gitignore entry
|
|
73
|
+
orche start <task> [-p <preset>] Start a session for <task>
|
|
74
|
+
orche review [path] Open the review UI for a worktree
|
|
75
|
+
orche prune [--all] [-f] Remove orche worktrees
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
orche init # write .orche.json + ignore .orche/
|
|
80
|
+
orche start fix-auth # worktree + session for "fix-auth"
|
|
81
|
+
orche start fix-auth -p mobile # use .orche.mobile.json
|
|
82
|
+
orche review # review the current directory
|
|
83
|
+
orche review ./worktree # review a specific worktree
|
|
84
|
+
orche prune # pick worktrees to remove
|
|
85
|
+
orche prune --all # remove every orche worktree
|
|
86
|
+
orche prune --force # remove even with uncommitted changes
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`orche prune` lists every worktree under `.orche/worktrees/` in a multiselect. Anything with uncommitted changes is flagged and skipped unless you pass `--force`.
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
### `.orche.json`
|
|
94
|
+
|
|
95
|
+
This file defines your pane layout. Put it in the project root; it works with both tmux and cmux. Splits can nest as deep as you need:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"layout": {
|
|
100
|
+
"direction": "horizontal",
|
|
101
|
+
"panes": [
|
|
102
|
+
{ "name": "agent", "command": "claude" },
|
|
103
|
+
{
|
|
104
|
+
"direction": "vertical",
|
|
105
|
+
"panes": [
|
|
106
|
+
{ "name": "dev", "command": "npm run dev" },
|
|
107
|
+
{ "name": "tests", "command": "npm run test:watch" }
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
That config produces:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
┌─────────────────┬─────────────────┐
|
|
119
|
+
│ │ dev │
|
|
120
|
+
│ agent ├─────────────────┤
|
|
121
|
+
│ │ tests │
|
|
122
|
+
└─────────────────┴─────────────────┘
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
| Field | Type | Description |
|
|
126
|
+
|-------|------|-------------|
|
|
127
|
+
| `direction` | `"horizontal"` \| `"vertical"` | Split direction |
|
|
128
|
+
| `panes` | array | Panes or nested splits |
|
|
129
|
+
| `name` | string | Pane label |
|
|
130
|
+
| `command` | string | Command to run in the pane |
|
|
131
|
+
| `size` | number | Split percentage (optional) |
|
|
132
|
+
|
|
133
|
+
### Multiplexer
|
|
134
|
+
|
|
135
|
+
orche detects whether you're in tmux or [cmux](https://cmux.dev/). To force one, set it in your config:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"multiplexer": "cmux",
|
|
140
|
+
"layout": { ... }
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Use `"tmux"` (the default) or `"cmux"`.
|
|
145
|
+
|
|
146
|
+
### Gitignored files in worktrees (`.worktreeinclude`)
|
|
147
|
+
|
|
148
|
+
A worktree is a clean checkout, so untracked files like `.env` from your main checkout aren't there. List the ones you want carried over in a `.worktreeinclude` file at the project root. It uses `.gitignore` syntax, and only untracked files matching a pattern are placed — tracked files are never duplicated:
|
|
149
|
+
|
|
150
|
+
```text
|
|
151
|
+
.env
|
|
152
|
+
.env.local
|
|
153
|
+
config/secrets.json
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
By default the files are **copied**, so each worktree gets its own independent copy. To **symlink** them back to the main checkout instead (shared, so edits propagate both ways), set `worktree.includeMode`:
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"worktree": {
|
|
161
|
+
"includeMode": "symlink"
|
|
162
|
+
},
|
|
163
|
+
"layout": { ... }
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Use `"copy"` (the default) or `"symlink"`. Note that with `"symlink"`, a worktree editing a file like `.env` mutates the main checkout.
|
|
168
|
+
|
|
169
|
+
### `.orche.local.json`
|
|
170
|
+
|
|
171
|
+
Same format as `.orche.json`, and it wins when both exist. It's gitignored by default, so it's the place for personal overrides that shouldn't touch the team config.
|
|
172
|
+
|
|
173
|
+
### Presets
|
|
174
|
+
|
|
175
|
+
Name a preset file `.orche.<name>.json` and load it with `-p`:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
orche start fix-auth -p mobile # loads .orche.mobile.json
|
|
179
|
+
orche start fix-auth -p debug # loads .orche.debug.json
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
A preset overrides both `.orche.json` and `.orche.local.json`. Ask for one that doesn't exist and orche lists the presets it can find.
|
|
183
|
+
|
|
184
|
+
## Review app
|
|
185
|
+
|
|
186
|
+
`orche review` opens a desktop app to look over an agent's changes before they land. Run it from inside a worktree, or pass a path. If you're in a tmux or cmux session, the review links to the agent's pane automatically, so submitted feedback gets pasted straight into the terminal.
|
|
187
|
+
|
|
188
|
+
The window has three columns:
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
┌──────┬────────────┬──────────────────────────────────┐
|
|
192
|
+
│ │ │ │
|
|
193
|
+
│ icon │ file │ multi-file diff view │
|
|
194
|
+
│ rail │ tree │ (virtualized scroll) │
|
|
195
|
+
│ │ │ │
|
|
196
|
+
│ │ ├──────────────────────────────────┤
|
|
197
|
+
│ │ │ main ← 3 files · 2 comments │
|
|
198
|
+
└──────┴────────────┴──────────────────────────────────┘
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The icon rail switches between the file tree, comments, and theme panels. The side panel browses changed files (with `+`/`~`/`-` status) or your pending comments. The diff viewer is a virtualized split diff with syntax highlighting, collapsible unchanged regions, and sticky file headers.
|
|
202
|
+
|
|
203
|
+
To review: scroll through every changed file in one view (the sidebar tracks where you are), click a line to comment, then submit with `Cmd+Enter` on macOS or `Ctrl+Enter` on Linux. Your comments are saved as markdown under `.orche/reviews/` and pasted into the agent's pane.
|
|
204
|
+
|
|
205
|
+
| Key | Action |
|
|
206
|
+
|-----|--------|
|
|
207
|
+
| `Cmd/Ctrl+Enter` | Submit review |
|
|
208
|
+
| `Enter` (in comment) | Submit comment |
|
|
209
|
+
| `Shift+Enter` (in comment) | Newline |
|
|
210
|
+
| `Escape` (in comment) | Cancel |
|
|
211
|
+
|
|
212
|
+
Four color themes ship with the app and persist between sessions:
|
|
213
|
+
|
|
214
|
+
- Obsidian: dark, with a warm amber accent
|
|
215
|
+
- Porcelain: light, with a slate-blue accent
|
|
216
|
+
- Sandstone: warm light, with a burnt orange accent
|
|
217
|
+
- Arctic: dark, with a teal accent
|
|
218
|
+
|
|
219
|
+
## Contributing
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
git clone https://github.com/taranek/orche.git
|
|
223
|
+
cd orche
|
|
224
|
+
pnpm install
|
|
225
|
+
pnpm run build
|
|
226
|
+
pnpm run link:cli
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Workspace management uses [pnpm](https://pnpm.io/). Common scripts:
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
pnpm run dev:cli # watch + rebuild the CLI (linked globally)
|
|
233
|
+
pnpm run dev:review # run the review app in dev mode
|
|
234
|
+
pnpm run build # build all packages
|
|
235
|
+
pnpm run typecheck # type-check all packages
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The code lives in three packages:
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
packages/
|
|
242
|
+
cli/ @orche/cli CLI for session and worktree management
|
|
243
|
+
review/ @orche/review Electron code review app
|
|
244
|
+
shared/ @orche/shared shared components, themes, and state
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## License
|
|
248
|
+
|
|
249
|
+
MIT
|
package/dist/cli.js
CHANGED
|
@@ -132,7 +132,9 @@ function startSession() {
|
|
|
132
132
|
const mux = getMultiplexer(muxFlag ?? config.multiplexer);
|
|
133
133
|
// 1. Create worktree
|
|
134
134
|
console.log(`creating worktree for "${taskName}"...`);
|
|
135
|
-
const { worktreePath } = createWorktree(cwd, taskName
|
|
135
|
+
const { worktreePath } = createWorktree(cwd, taskName, {
|
|
136
|
+
includeMode: config.worktree?.includeMode,
|
|
137
|
+
});
|
|
136
138
|
// 2. Create session
|
|
137
139
|
const sessionName = `${repoName}-${taskName}`.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
138
140
|
mux.createSession(sessionName, worktreePath);
|
package/dist/types.d.ts
CHANGED
|
@@ -11,8 +11,19 @@ export interface SplitConfig {
|
|
|
11
11
|
size?: number;
|
|
12
12
|
}
|
|
13
13
|
export type MultiplexerType = "tmux" | "cmux" | "auto";
|
|
14
|
+
/** How `.worktreeinclude` files are placed into a new worktree. */
|
|
15
|
+
export type WorktreeIncludeMode = "copy" | "symlink";
|
|
16
|
+
export interface WorktreeConfig {
|
|
17
|
+
/**
|
|
18
|
+
* Whether gitignored files matched by `.worktreeinclude` are copied into the
|
|
19
|
+
* worktree (independent per-worktree copies, the default) or symlinked back
|
|
20
|
+
* to the main checkout (shared, so edits propagate). Defaults to "copy".
|
|
21
|
+
*/
|
|
22
|
+
includeMode?: WorktreeIncludeMode;
|
|
23
|
+
}
|
|
14
24
|
export interface AgentsConfig {
|
|
15
25
|
multiplexer?: MultiplexerType;
|
|
26
|
+
worktree?: WorktreeConfig;
|
|
16
27
|
layout: PaneConfig | SplitConfig;
|
|
17
28
|
}
|
|
18
29
|
export declare function isSplit(node: PaneConfig | SplitConfig): node is SplitConfig;
|
package/dist/worktree.d.ts
CHANGED
|
@@ -1,8 +1,30 @@
|
|
|
1
|
+
import type { WorktreeIncludeMode } from "./types.js";
|
|
1
2
|
export interface WorktreeInfo {
|
|
2
3
|
worktreePath: string;
|
|
3
4
|
branchName: string;
|
|
4
5
|
}
|
|
5
|
-
export
|
|
6
|
+
export interface CreateWorktreeOptions {
|
|
7
|
+
/** How `.worktreeinclude` files are placed into the worktree. Defaults to "copy". */
|
|
8
|
+
includeMode?: WorktreeIncludeMode;
|
|
9
|
+
}
|
|
10
|
+
export declare function createWorktree(repoPath: string, name: string, options?: CreateWorktreeOptions): WorktreeInfo;
|
|
11
|
+
/**
|
|
12
|
+
* Places gitignored files listed in a `.worktreeinclude` file (`.gitignore`
|
|
13
|
+
* syntax) from the main checkout into the freshly created worktree. A worktree
|
|
14
|
+
* is a clean checkout, so untracked files like `.env` aren't present otherwise.
|
|
15
|
+
*
|
|
16
|
+
* In "copy" mode (the default) each file is duplicated, so the worktree can
|
|
17
|
+
* diverge freely. In "symlink" mode each file links back to the main checkout,
|
|
18
|
+
* so edits are shared — handy for config you keep in lockstep, but it means a
|
|
19
|
+
* worktree editing e.g. `.env` mutates the main checkout.
|
|
20
|
+
*
|
|
21
|
+
* Only untracked files matching a `.worktreeinclude` pattern are placed —
|
|
22
|
+
* tracked files are never duplicated. The match is computed with `git ls-files
|
|
23
|
+
* --others --ignored --exclude-from`: `--others` restricts to untracked files,
|
|
24
|
+
* and omitting `--exclude-standard` means only the `.worktreeinclude` patterns
|
|
25
|
+
* (not the repo's normal `.gitignore`) decide what matches.
|
|
26
|
+
*/
|
|
27
|
+
export declare function applyWorktreeIncludes(repoPath: string, worktreePath: string, mode?: WorktreeIncludeMode): void;
|
|
6
28
|
export interface OrcheWorktree {
|
|
7
29
|
worktreePath: string;
|
|
8
30
|
branch: string | null;
|
package/dist/worktree.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execSync, execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, readFileSync, appendFileSync, mkdirSync, symlinkSync, readdirSync, lstatSync, } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, appendFileSync, mkdirSync, symlinkSync, copyFileSync, readdirSync, lstatSync, } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
function slugify(text) {
|
|
5
5
|
return text
|
|
@@ -26,7 +26,7 @@ function ensureGitRepo(repoPath) {
|
|
|
26
26
|
execSync('git init && git add -A && git commit -m "Initial commit" --allow-empty', { cwd: repoPath, stdio: "ignore" });
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
export function createWorktree(repoPath, name) {
|
|
29
|
+
export function createWorktree(repoPath, name, options = {}) {
|
|
30
30
|
ensureGitRepo(repoPath);
|
|
31
31
|
const slug = slugify(name);
|
|
32
32
|
const timestamp = Date.now();
|
|
@@ -40,9 +40,65 @@ export function createWorktree(repoPath, name) {
|
|
|
40
40
|
stdio: "ignore",
|
|
41
41
|
});
|
|
42
42
|
symlinkNodeModules(repoPath, worktreePath);
|
|
43
|
+
applyWorktreeIncludes(repoPath, worktreePath, options.includeMode ?? "copy");
|
|
43
44
|
console.log(` worktree: ${worktreePath} (${branchName})`);
|
|
44
45
|
return { worktreePath, branchName };
|
|
45
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Places gitignored files listed in a `.worktreeinclude` file (`.gitignore`
|
|
49
|
+
* syntax) from the main checkout into the freshly created worktree. A worktree
|
|
50
|
+
* is a clean checkout, so untracked files like `.env` aren't present otherwise.
|
|
51
|
+
*
|
|
52
|
+
* In "copy" mode (the default) each file is duplicated, so the worktree can
|
|
53
|
+
* diverge freely. In "symlink" mode each file links back to the main checkout,
|
|
54
|
+
* so edits are shared — handy for config you keep in lockstep, but it means a
|
|
55
|
+
* worktree editing e.g. `.env` mutates the main checkout.
|
|
56
|
+
*
|
|
57
|
+
* Only untracked files matching a `.worktreeinclude` pattern are placed —
|
|
58
|
+
* tracked files are never duplicated. The match is computed with `git ls-files
|
|
59
|
+
* --others --ignored --exclude-from`: `--others` restricts to untracked files,
|
|
60
|
+
* and omitting `--exclude-standard` means only the `.worktreeinclude` patterns
|
|
61
|
+
* (not the repo's normal `.gitignore`) decide what matches.
|
|
62
|
+
*/
|
|
63
|
+
export function applyWorktreeIncludes(repoPath, worktreePath, mode = "copy") {
|
|
64
|
+
const includeFile = path.join(repoPath, ".worktreeinclude");
|
|
65
|
+
if (!existsSync(includeFile))
|
|
66
|
+
return;
|
|
67
|
+
let output;
|
|
68
|
+
try {
|
|
69
|
+
output = execFileSync("git", ["ls-files", "-z", "--others", "--ignored", "--exclude-from", includeFile], { cwd: repoPath, encoding: "utf-8" });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let placed = 0;
|
|
75
|
+
for (const rel of output.split("\0").filter(Boolean)) {
|
|
76
|
+
const src = path.join(repoPath, rel);
|
|
77
|
+
const dest = path.join(worktreePath, rel);
|
|
78
|
+
try {
|
|
79
|
+
if (!lstatSync(src).isFile())
|
|
80
|
+
continue;
|
|
81
|
+
if (existsSync(dest))
|
|
82
|
+
continue;
|
|
83
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
84
|
+
if (mode === "symlink") {
|
|
85
|
+
symlinkSync(src, dest);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
copyFileSync(src, dest);
|
|
89
|
+
}
|
|
90
|
+
placed++;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Skip files that vanished or can't be read; placement is best-effort.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (placed > 0) {
|
|
97
|
+
const noun = placed === 1 ? "file" : "files";
|
|
98
|
+
const verb = mode === "symlink" ? "symlinked" : "copied";
|
|
99
|
+
console.log(` ${verb} ${placed} gitignored ${noun} from .worktreeinclude`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
46
102
|
const ORCHE_SEGMENT = `${path.sep}.orche${path.sep}worktrees${path.sep}`;
|
|
47
103
|
function parseWorktreeList(output) {
|
|
48
104
|
const result = [];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, readFileSync, lstatSync, } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { createWorktree } from "./worktree.js";
|
|
7
|
+
function git(cwd, ...args) {
|
|
8
|
+
execFileSync("git", args, { cwd, stdio: "ignore" });
|
|
9
|
+
}
|
|
10
|
+
function initRepo() {
|
|
11
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "orche-wt-test-"));
|
|
12
|
+
git(dir, "init");
|
|
13
|
+
git(dir, "config", "user.email", "test@example.com");
|
|
14
|
+
git(dir, "config", "user.name", "Test");
|
|
15
|
+
// A tracked file so the repo has a real HEAD to branch from.
|
|
16
|
+
writeFileSync(path.join(dir, "README.md"), "# repo\n");
|
|
17
|
+
git(dir, "add", "README.md");
|
|
18
|
+
git(dir, "commit", "-m", "init");
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
describe("createWorktree .worktreeinclude", () => {
|
|
22
|
+
let repo;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
repo = initRepo();
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
rmSync(repo, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
it("copies gitignored files matching .worktreeinclude into the worktree", () => {
|
|
30
|
+
writeFileSync(path.join(repo, ".gitignore"), ".env\nconfig/\n");
|
|
31
|
+
writeFileSync(path.join(repo, ".worktreeinclude"), ".env\nconfig/secrets.json\n");
|
|
32
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=abc\n");
|
|
33
|
+
mkdirSync(path.join(repo, "config"));
|
|
34
|
+
writeFileSync(path.join(repo, "config", "secrets.json"), '{"k":"v"}\n');
|
|
35
|
+
const { worktreePath } = createWorktree(repo, "feature");
|
|
36
|
+
const env = path.join(worktreePath, ".env");
|
|
37
|
+
const secrets = path.join(worktreePath, "config", "secrets.json");
|
|
38
|
+
expect(existsSync(env)).toBe(true);
|
|
39
|
+
expect(lstatSync(env).isSymbolicLink()).toBe(false);
|
|
40
|
+
expect(readFileSync(env, "utf-8")).toBe("SECRET=abc\n");
|
|
41
|
+
expect(existsSync(secrets)).toBe(true);
|
|
42
|
+
expect(readFileSync(secrets, "utf-8")).toBe('{"k":"v"}\n');
|
|
43
|
+
});
|
|
44
|
+
it("defaults to copy mode (independent files)", () => {
|
|
45
|
+
writeFileSync(path.join(repo, ".gitignore"), ".env\n");
|
|
46
|
+
writeFileSync(path.join(repo, ".worktreeinclude"), ".env\n");
|
|
47
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=abc\n");
|
|
48
|
+
const { worktreePath } = createWorktree(repo, "feature");
|
|
49
|
+
const env = path.join(worktreePath, ".env");
|
|
50
|
+
expect(lstatSync(env).isSymbolicLink()).toBe(false);
|
|
51
|
+
// Editing the main checkout's copy does not affect the worktree's.
|
|
52
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=changed\n");
|
|
53
|
+
expect(readFileSync(env, "utf-8")).toBe("SECRET=abc\n");
|
|
54
|
+
});
|
|
55
|
+
it("symlinks files when includeMode is 'symlink' (shared with main checkout)", () => {
|
|
56
|
+
writeFileSync(path.join(repo, ".gitignore"), ".env\n");
|
|
57
|
+
writeFileSync(path.join(repo, ".worktreeinclude"), ".env\n");
|
|
58
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=abc\n");
|
|
59
|
+
const { worktreePath } = createWorktree(repo, "feature", { includeMode: "symlink" });
|
|
60
|
+
const env = path.join(worktreePath, ".env");
|
|
61
|
+
expect(lstatSync(env).isSymbolicLink()).toBe(true);
|
|
62
|
+
// Editing the main checkout propagates through the symlink.
|
|
63
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=changed\n");
|
|
64
|
+
expect(readFileSync(env, "utf-8")).toBe("SECRET=changed\n");
|
|
65
|
+
});
|
|
66
|
+
it("does not copy gitignored files that aren't listed in .worktreeinclude", () => {
|
|
67
|
+
writeFileSync(path.join(repo, ".gitignore"), ".env\n.env.local\n");
|
|
68
|
+
writeFileSync(path.join(repo, ".worktreeinclude"), ".env\n");
|
|
69
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=abc\n");
|
|
70
|
+
writeFileSync(path.join(repo, ".env.local"), "LOCAL=1\n");
|
|
71
|
+
const { worktreePath } = createWorktree(repo, "feature");
|
|
72
|
+
expect(existsSync(path.join(worktreePath, ".env"))).toBe(true);
|
|
73
|
+
expect(existsSync(path.join(worktreePath, ".env.local"))).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
it("ignores patterns that match tracked files (never duplicated via copy)", () => {
|
|
76
|
+
// README.md is tracked; even if listed it shouldn't be copied by the include logic.
|
|
77
|
+
writeFileSync(path.join(repo, ".worktreeinclude"), "README.md\n.env\n");
|
|
78
|
+
writeFileSync(path.join(repo, ".gitignore"), ".env\n");
|
|
79
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=abc\n");
|
|
80
|
+
const { worktreePath } = createWorktree(repo, "feature");
|
|
81
|
+
// README.md is present because git checked it out, .env because we copied it.
|
|
82
|
+
expect(existsSync(path.join(worktreePath, "README.md"))).toBe(true);
|
|
83
|
+
expect(existsSync(path.join(worktreePath, ".env"))).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
it("is a no-op when .worktreeinclude is absent", () => {
|
|
86
|
+
writeFileSync(path.join(repo, ".gitignore"), ".env\n");
|
|
87
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=abc\n");
|
|
88
|
+
const { worktreePath } = createWorktree(repo, "feature");
|
|
89
|
+
expect(existsSync(path.join(worktreePath, ".env"))).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taranek/orche",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.32",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"orche": "./dist/cli.js"
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"access": "public"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
|
+
"prepack": "node ../../scripts/sync-cli-readme.mjs",
|
|
18
19
|
"build": "tsc",
|
|
19
20
|
"dev": "tsc --watch",
|
|
20
21
|
"test": "vitest run",
|
package/src/cli.ts
CHANGED
|
@@ -139,7 +139,9 @@ function startSession(): void {
|
|
|
139
139
|
|
|
140
140
|
// 1. Create worktree
|
|
141
141
|
console.log(`creating worktree for "${taskName}"...`);
|
|
142
|
-
const { worktreePath } = createWorktree(cwd, taskName
|
|
142
|
+
const { worktreePath } = createWorktree(cwd, taskName, {
|
|
143
|
+
includeMode: config.worktree?.includeMode,
|
|
144
|
+
});
|
|
143
145
|
|
|
144
146
|
// 2. Create session
|
|
145
147
|
const sessionName = `${repoName}-${taskName}`.replace(
|
package/src/types.ts
CHANGED
|
@@ -14,8 +14,21 @@ export interface SplitConfig {
|
|
|
14
14
|
|
|
15
15
|
export type MultiplexerType = "tmux" | "cmux" | "auto";
|
|
16
16
|
|
|
17
|
+
/** How `.worktreeinclude` files are placed into a new worktree. */
|
|
18
|
+
export type WorktreeIncludeMode = "copy" | "symlink";
|
|
19
|
+
|
|
20
|
+
export interface WorktreeConfig {
|
|
21
|
+
/**
|
|
22
|
+
* Whether gitignored files matched by `.worktreeinclude` are copied into the
|
|
23
|
+
* worktree (independent per-worktree copies, the default) or symlinked back
|
|
24
|
+
* to the main checkout (shared, so edits propagate). Defaults to "copy".
|
|
25
|
+
*/
|
|
26
|
+
includeMode?: WorktreeIncludeMode;
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
export interface AgentsConfig {
|
|
18
30
|
multiplexer?: MultiplexerType;
|
|
31
|
+
worktree?: WorktreeConfig;
|
|
19
32
|
layout: PaneConfig | SplitConfig;
|
|
20
33
|
}
|
|
21
34
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
lstatSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import { createWorktree } from "./worktree.js";
|
|
15
|
+
|
|
16
|
+
function git(cwd: string, ...args: string[]): void {
|
|
17
|
+
execFileSync("git", args, { cwd, stdio: "ignore" });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function initRepo(): string {
|
|
21
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "orche-wt-test-"));
|
|
22
|
+
git(dir, "init");
|
|
23
|
+
git(dir, "config", "user.email", "test@example.com");
|
|
24
|
+
git(dir, "config", "user.name", "Test");
|
|
25
|
+
// A tracked file so the repo has a real HEAD to branch from.
|
|
26
|
+
writeFileSync(path.join(dir, "README.md"), "# repo\n");
|
|
27
|
+
git(dir, "add", "README.md");
|
|
28
|
+
git(dir, "commit", "-m", "init");
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("createWorktree .worktreeinclude", () => {
|
|
33
|
+
let repo: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
repo = initRepo();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
rmSync(repo, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("copies gitignored files matching .worktreeinclude into the worktree", () => {
|
|
44
|
+
writeFileSync(path.join(repo, ".gitignore"), ".env\nconfig/\n");
|
|
45
|
+
writeFileSync(path.join(repo, ".worktreeinclude"), ".env\nconfig/secrets.json\n");
|
|
46
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=abc\n");
|
|
47
|
+
mkdirSync(path.join(repo, "config"));
|
|
48
|
+
writeFileSync(path.join(repo, "config", "secrets.json"), '{"k":"v"}\n');
|
|
49
|
+
|
|
50
|
+
const { worktreePath } = createWorktree(repo, "feature");
|
|
51
|
+
|
|
52
|
+
const env = path.join(worktreePath, ".env");
|
|
53
|
+
const secrets = path.join(worktreePath, "config", "secrets.json");
|
|
54
|
+
expect(existsSync(env)).toBe(true);
|
|
55
|
+
expect(lstatSync(env).isSymbolicLink()).toBe(false);
|
|
56
|
+
expect(readFileSync(env, "utf-8")).toBe("SECRET=abc\n");
|
|
57
|
+
expect(existsSync(secrets)).toBe(true);
|
|
58
|
+
expect(readFileSync(secrets, "utf-8")).toBe('{"k":"v"}\n');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("defaults to copy mode (independent files)", () => {
|
|
62
|
+
writeFileSync(path.join(repo, ".gitignore"), ".env\n");
|
|
63
|
+
writeFileSync(path.join(repo, ".worktreeinclude"), ".env\n");
|
|
64
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=abc\n");
|
|
65
|
+
|
|
66
|
+
const { worktreePath } = createWorktree(repo, "feature");
|
|
67
|
+
const env = path.join(worktreePath, ".env");
|
|
68
|
+
|
|
69
|
+
expect(lstatSync(env).isSymbolicLink()).toBe(false);
|
|
70
|
+
// Editing the main checkout's copy does not affect the worktree's.
|
|
71
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=changed\n");
|
|
72
|
+
expect(readFileSync(env, "utf-8")).toBe("SECRET=abc\n");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("symlinks files when includeMode is 'symlink' (shared with main checkout)", () => {
|
|
76
|
+
writeFileSync(path.join(repo, ".gitignore"), ".env\n");
|
|
77
|
+
writeFileSync(path.join(repo, ".worktreeinclude"), ".env\n");
|
|
78
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=abc\n");
|
|
79
|
+
|
|
80
|
+
const { worktreePath } = createWorktree(repo, "feature", { includeMode: "symlink" });
|
|
81
|
+
const env = path.join(worktreePath, ".env");
|
|
82
|
+
|
|
83
|
+
expect(lstatSync(env).isSymbolicLink()).toBe(true);
|
|
84
|
+
// Editing the main checkout propagates through the symlink.
|
|
85
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=changed\n");
|
|
86
|
+
expect(readFileSync(env, "utf-8")).toBe("SECRET=changed\n");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does not copy gitignored files that aren't listed in .worktreeinclude", () => {
|
|
90
|
+
writeFileSync(path.join(repo, ".gitignore"), ".env\n.env.local\n");
|
|
91
|
+
writeFileSync(path.join(repo, ".worktreeinclude"), ".env\n");
|
|
92
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=abc\n");
|
|
93
|
+
writeFileSync(path.join(repo, ".env.local"), "LOCAL=1\n");
|
|
94
|
+
|
|
95
|
+
const { worktreePath } = createWorktree(repo, "feature");
|
|
96
|
+
|
|
97
|
+
expect(existsSync(path.join(worktreePath, ".env"))).toBe(true);
|
|
98
|
+
expect(existsSync(path.join(worktreePath, ".env.local"))).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("ignores patterns that match tracked files (never duplicated via copy)", () => {
|
|
102
|
+
// README.md is tracked; even if listed it shouldn't be copied by the include logic.
|
|
103
|
+
writeFileSync(path.join(repo, ".worktreeinclude"), "README.md\n.env\n");
|
|
104
|
+
writeFileSync(path.join(repo, ".gitignore"), ".env\n");
|
|
105
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=abc\n");
|
|
106
|
+
|
|
107
|
+
const { worktreePath } = createWorktree(repo, "feature");
|
|
108
|
+
|
|
109
|
+
// README.md is present because git checked it out, .env because we copied it.
|
|
110
|
+
expect(existsSync(path.join(worktreePath, "README.md"))).toBe(true);
|
|
111
|
+
expect(existsSync(path.join(worktreePath, ".env"))).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("is a no-op when .worktreeinclude is absent", () => {
|
|
115
|
+
writeFileSync(path.join(repo, ".gitignore"), ".env\n");
|
|
116
|
+
writeFileSync(path.join(repo, ".env"), "SECRET=abc\n");
|
|
117
|
+
|
|
118
|
+
const { worktreePath } = createWorktree(repo, "feature");
|
|
119
|
+
|
|
120
|
+
expect(existsSync(path.join(worktreePath, ".env"))).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
package/src/worktree.ts
CHANGED
|
@@ -5,16 +5,23 @@ import {
|
|
|
5
5
|
appendFileSync,
|
|
6
6
|
mkdirSync,
|
|
7
7
|
symlinkSync,
|
|
8
|
+
copyFileSync,
|
|
8
9
|
readdirSync,
|
|
9
10
|
lstatSync,
|
|
10
11
|
} from "node:fs";
|
|
11
12
|
import path from "node:path";
|
|
13
|
+
import type { WorktreeIncludeMode } from "./types.js";
|
|
12
14
|
|
|
13
15
|
export interface WorktreeInfo {
|
|
14
16
|
worktreePath: string;
|
|
15
17
|
branchName: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
export interface CreateWorktreeOptions {
|
|
21
|
+
/** How `.worktreeinclude` files are placed into the worktree. Defaults to "copy". */
|
|
22
|
+
includeMode?: WorktreeIncludeMode;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
function slugify(text: string): string {
|
|
19
26
|
return text
|
|
20
27
|
.toLowerCase()
|
|
@@ -45,7 +52,11 @@ function ensureGitRepo(repoPath: string): void {
|
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
export function createWorktree(
|
|
55
|
+
export function createWorktree(
|
|
56
|
+
repoPath: string,
|
|
57
|
+
name: string,
|
|
58
|
+
options: CreateWorktreeOptions = {}
|
|
59
|
+
): WorktreeInfo {
|
|
49
60
|
ensureGitRepo(repoPath);
|
|
50
61
|
|
|
51
62
|
const slug = slugify(name);
|
|
@@ -64,11 +75,73 @@ export function createWorktree(repoPath: string, name: string): WorktreeInfo {
|
|
|
64
75
|
});
|
|
65
76
|
|
|
66
77
|
symlinkNodeModules(repoPath, worktreePath);
|
|
78
|
+
applyWorktreeIncludes(repoPath, worktreePath, options.includeMode ?? "copy");
|
|
67
79
|
|
|
68
80
|
console.log(` worktree: ${worktreePath} (${branchName})`);
|
|
69
81
|
return { worktreePath, branchName };
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Places gitignored files listed in a `.worktreeinclude` file (`.gitignore`
|
|
86
|
+
* syntax) from the main checkout into the freshly created worktree. A worktree
|
|
87
|
+
* is a clean checkout, so untracked files like `.env` aren't present otherwise.
|
|
88
|
+
*
|
|
89
|
+
* In "copy" mode (the default) each file is duplicated, so the worktree can
|
|
90
|
+
* diverge freely. In "symlink" mode each file links back to the main checkout,
|
|
91
|
+
* so edits are shared — handy for config you keep in lockstep, but it means a
|
|
92
|
+
* worktree editing e.g. `.env` mutates the main checkout.
|
|
93
|
+
*
|
|
94
|
+
* Only untracked files matching a `.worktreeinclude` pattern are placed —
|
|
95
|
+
* tracked files are never duplicated. The match is computed with `git ls-files
|
|
96
|
+
* --others --ignored --exclude-from`: `--others` restricts to untracked files,
|
|
97
|
+
* and omitting `--exclude-standard` means only the `.worktreeinclude` patterns
|
|
98
|
+
* (not the repo's normal `.gitignore`) decide what matches.
|
|
99
|
+
*/
|
|
100
|
+
export function applyWorktreeIncludes(
|
|
101
|
+
repoPath: string,
|
|
102
|
+
worktreePath: string,
|
|
103
|
+
mode: WorktreeIncludeMode = "copy"
|
|
104
|
+
): void {
|
|
105
|
+
const includeFile = path.join(repoPath, ".worktreeinclude");
|
|
106
|
+
if (!existsSync(includeFile)) return;
|
|
107
|
+
|
|
108
|
+
let output: string;
|
|
109
|
+
try {
|
|
110
|
+
output = execFileSync(
|
|
111
|
+
"git",
|
|
112
|
+
["ls-files", "-z", "--others", "--ignored", "--exclude-from", includeFile],
|
|
113
|
+
{ cwd: repoPath, encoding: "utf-8" }
|
|
114
|
+
);
|
|
115
|
+
} catch {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let placed = 0;
|
|
120
|
+
for (const rel of output.split("\0").filter(Boolean)) {
|
|
121
|
+
const src = path.join(repoPath, rel);
|
|
122
|
+
const dest = path.join(worktreePath, rel);
|
|
123
|
+
try {
|
|
124
|
+
if (!lstatSync(src).isFile()) continue;
|
|
125
|
+
if (existsSync(dest)) continue;
|
|
126
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
127
|
+
if (mode === "symlink") {
|
|
128
|
+
symlinkSync(src, dest);
|
|
129
|
+
} else {
|
|
130
|
+
copyFileSync(src, dest);
|
|
131
|
+
}
|
|
132
|
+
placed++;
|
|
133
|
+
} catch {
|
|
134
|
+
// Skip files that vanished or can't be read; placement is best-effort.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (placed > 0) {
|
|
139
|
+
const noun = placed === 1 ? "file" : "files";
|
|
140
|
+
const verb = mode === "symlink" ? "symlinked" : "copied";
|
|
141
|
+
console.log(` ${verb} ${placed} gitignored ${noun} from .worktreeinclude`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
72
145
|
export interface OrcheWorktree {
|
|
73
146
|
worktreePath: string;
|
|
74
147
|
branch: string | null;
|