@taranek/orche 0.0.29 → 0.0.31

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 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;
@@ -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 declare function createWorktree(repoPath: string, name: string): WorktreeInfo;
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.29",
3
+ "version": "0.0.31",
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(repoPath: string, name: string): WorktreeInfo {
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;