chapterhouse 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/cli.js +24 -0
- package/dist/squad/index.js +1 -0
- package/dist/squad/worktree.js +295 -0
- package/dist/squad/worktree.test.js +189 -0
- package/package.json +10 -3
- package/web/dist/assets/index-CxT9905O.css +10 -0
- package/web/dist/assets/{index-Bgs6Mze7.js → index-DI3rnGm-.js} +55 -55
- package/web/dist/assets/index-DI3rnGm-.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-Bgs6Mze7.js.map +0 -1
- package/web/dist/assets/index-CxeGtVlE.css +0 -10
package/README.md
CHANGED
|
@@ -239,8 +239,31 @@ The deployment assets for the shared instance set Entra auth and `CHAPTERHOUSE_M
|
|
|
239
239
|
| `chapterhouse update --check-only` | Print current/latest version without updating |
|
|
240
240
|
| `chapterhouse update --ref <ver>` | Install a specific version |
|
|
241
241
|
| `chapterhouse daemon <sub>` | Manage the persistent background service |
|
|
242
|
+
| `chapterhouse squad worktree <sub>` | Manage per-agent git worktrees for concurrent squad work |
|
|
242
243
|
| `chapterhouse help` | Show available commands |
|
|
243
244
|
|
|
245
|
+
### Squad Worktree Commands
|
|
246
|
+
|
|
247
|
+
Squad agents that work on GitHub issues each get a dedicated git worktree so they never step on each other. The `chapterhouse squad worktree` subcommands manage these worktrees:
|
|
248
|
+
|
|
249
|
+
```sh
|
|
250
|
+
chapterhouse squad worktree create <agent> <issue> [--base main] [--slug <slug>]
|
|
251
|
+
# Creates .worktrees/{agent}-{issue}/ and branch squad/{issue}-{slug}
|
|
252
|
+
# Prints the worktree path to stdout. Reuses existing worktree if present.
|
|
253
|
+
|
|
254
|
+
chapterhouse squad worktree list
|
|
255
|
+
# Shows all active squad worktrees: agent, issue, branch, status, path
|
|
256
|
+
|
|
257
|
+
chapterhouse squad worktree remove <agent> <issue> [--force] [--delete-branch]
|
|
258
|
+
# Removes a worktree. Refuses if dirty unless --force is passed.
|
|
259
|
+
|
|
260
|
+
chapterhouse squad worktree prune [--base main] [--dry-run]
|
|
261
|
+
# Removes all worktrees whose branch has been merged into main.
|
|
262
|
+
# Skips dirty worktrees with a warning.
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**How it works:** Worktrees live at `.worktrees/{agent}-{issue}/` inside the repo (gitignored). The Squad coordinator creates each worktree *before* spawning an agent and passes the path as `WORKTREE_PATH` in the spawn prompt. Agents do all their work—reads, edits, commits—inside that path. No agent ever runs `git checkout` in a working tree it doesn't own.
|
|
266
|
+
|
|
244
267
|
### Flags
|
|
245
268
|
|
|
246
269
|
| Flag | Description |
|
|
@@ -428,3 +451,13 @@ git push origin main --follow-tags
|
|
|
428
451
|
```
|
|
429
452
|
|
|
430
453
|
`npm version` handles the commit and tag automatically. `prepublishOnly` runs `npm run build` before publish so the tarball always contains a fresh build. If you don't have CI set up, publish manually with `npm publish` after the tag push.
|
|
454
|
+
|
|
455
|
+
> **Pre-release gate:** `preversion` runs `npm run release:check` automatically before any `npm version` call. The script aborts with a clear error if the git working tree is dirty. Stash or commit all changes (including `.squad/` metadata edits) before bumping the version.
|
|
456
|
+
|
|
457
|
+
### Commit message convention
|
|
458
|
+
|
|
459
|
+
All commits on this repository follow **[Conventional Commits v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/)**. The format is `<type>(<scope>): <subject>` (e.g. `feat(api): add session export endpoint`). Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `build`, `ci`, `revert`, `release`.
|
|
460
|
+
|
|
461
|
+
This is automatically enforced:
|
|
462
|
+
- **Locally:** `husky` installs a `commit-msg` git hook on `npm install` that runs `commitlint` against every commit message. Bad messages are rejected before the commit lands.
|
|
463
|
+
- **On PRs:** A GitHub Action (`lint-pr-title.yml`) validates the PR title on every open/edit. This matters because squash-merges use the PR title as the commit message on `main`.
|
package/dist/cli.js
CHANGED
|
@@ -25,6 +25,7 @@ Commands:
|
|
|
25
25
|
setup Pick a default model and write ~/.chapterhouse/.env
|
|
26
26
|
update Check for updates and install the latest version
|
|
27
27
|
daemon <sub> Manage the persistent background service (install/uninstall/start/stop/restart/status/logs)
|
|
28
|
+
squad <sub> Squad agent tools (worktree management)
|
|
28
29
|
help Show this help message
|
|
29
30
|
|
|
30
31
|
Flags (start):
|
|
@@ -178,6 +179,29 @@ switch (command) {
|
|
|
178
179
|
}
|
|
179
180
|
break;
|
|
180
181
|
}
|
|
182
|
+
case "squad": {
|
|
183
|
+
const squadSub = args[1];
|
|
184
|
+
if (squadSub === 'worktree') {
|
|
185
|
+
const { runWorktreeCli, printWorktreeHelp } = await import("./squad/worktree.js");
|
|
186
|
+
await runWorktreeCli(args.slice(2));
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
if (squadSub) {
|
|
190
|
+
console.error(`Unknown squad subcommand: ${squadSub}\n`);
|
|
191
|
+
}
|
|
192
|
+
console.log(`
|
|
193
|
+
chapterhouse squad — Squad agent tools
|
|
194
|
+
|
|
195
|
+
Subcommands:
|
|
196
|
+
worktree Manage per-agent git worktrees (create / list / remove / prune)
|
|
197
|
+
|
|
198
|
+
Run \`chapterhouse squad worktree\` for worktree subcommand help.
|
|
199
|
+
`.trim());
|
|
200
|
+
if (squadSub)
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
181
205
|
case "help":
|
|
182
206
|
case "--help":
|
|
183
207
|
case "-h":
|
package/dist/squad/index.js
CHANGED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Path convention
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
/**
|
|
8
|
+
* Location: `.worktrees/{agent}-{issueNum}/` inside the repo root.
|
|
9
|
+
* This keeps worktrees discoverable (they're adjacent to the code) and
|
|
10
|
+
* `git worktree list` always surfaces them. The directory is gitignored.
|
|
11
|
+
*
|
|
12
|
+
* Tradeoff vs `~/.cache/chapterhouse-worktrees/<repo>/`:
|
|
13
|
+
* + Visible without knowing a cache path
|
|
14
|
+
* + `chapterhouse squad worktree list` doesn't need to guess the home
|
|
15
|
+
* - Must be added to .gitignore (done once, documented here)
|
|
16
|
+
*/
|
|
17
|
+
export function getWorktreePath(repoRoot, agent, issueNum) {
|
|
18
|
+
return join(repoRoot, '.worktrees', `${agent}-${issueNum}`);
|
|
19
|
+
}
|
|
20
|
+
export function getBranchName(issueNum, slug) {
|
|
21
|
+
const safeslug = slug ? `-${slug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}` : '';
|
|
22
|
+
return `squad/${issueNum}${safeslug}`;
|
|
23
|
+
}
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Git helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
function git(args, cwd) {
|
|
28
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf-8' });
|
|
29
|
+
return {
|
|
30
|
+
ok: result.status === 0,
|
|
31
|
+
stdout: (result.stdout ?? '').trim(),
|
|
32
|
+
stderr: (result.stderr ?? '').trim(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function hasUncommittedChanges(worktreePath) {
|
|
36
|
+
const r = git(['status', '--porcelain'], worktreePath);
|
|
37
|
+
return r.ok && r.stdout.length > 0;
|
|
38
|
+
}
|
|
39
|
+
function isBranchMerged(repoRoot, branch, base = 'main') {
|
|
40
|
+
// merged if branch tip is reachable from base
|
|
41
|
+
const r = git(['branch', '--merged', base, '--list', branch], repoRoot);
|
|
42
|
+
return r.ok && r.stdout.length > 0;
|
|
43
|
+
}
|
|
44
|
+
function getRepoRoot(cwd) {
|
|
45
|
+
const r = git(['rev-parse', '--show-toplevel'], cwd);
|
|
46
|
+
if (!r.ok)
|
|
47
|
+
throw new Error(`Not inside a git repo: ${r.stderr}`);
|
|
48
|
+
return r.stdout;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Create a dedicated worktree for an agent + issue pair.
|
|
52
|
+
* Branch: `squad/{issueNum}-{agent}` (off `main` by default).
|
|
53
|
+
* Returns the worktree path.
|
|
54
|
+
*/
|
|
55
|
+
export function createWorktree(repoRoot, agent, issueNum, opts = {}) {
|
|
56
|
+
const baseBranch = opts.baseBranch ?? 'main';
|
|
57
|
+
const branch = getBranchName(issueNum, opts.slug ?? agent);
|
|
58
|
+
const worktreePath = getWorktreePath(repoRoot, agent, issueNum);
|
|
59
|
+
if (existsSync(worktreePath)) {
|
|
60
|
+
// Reuse existing worktree
|
|
61
|
+
console.log(`↩ Reusing existing worktree: ${relative(repoRoot, worktreePath)}`);
|
|
62
|
+
return worktreePath;
|
|
63
|
+
}
|
|
64
|
+
// Check if branch already exists (another agent on same issue)
|
|
65
|
+
const branchExists = git(['show-ref', '--verify', `refs/heads/${branch}`], repoRoot);
|
|
66
|
+
let result;
|
|
67
|
+
if (branchExists.ok) {
|
|
68
|
+
result = git(['worktree', 'add', worktreePath, branch], repoRoot);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
result = git(['worktree', 'add', '-b', branch, worktreePath, baseBranch], repoRoot);
|
|
72
|
+
}
|
|
73
|
+
if (!result.ok) {
|
|
74
|
+
throw new Error(`Failed to create worktree: ${result.stderr}`);
|
|
75
|
+
}
|
|
76
|
+
console.log(`✅ Worktree created: ${relative(repoRoot, worktreePath)} (branch: ${branch})`);
|
|
77
|
+
return worktreePath;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* List all squad worktrees (worktrees under `.worktrees/` with `squad/` branches).
|
|
81
|
+
*/
|
|
82
|
+
export function listWorktrees(repoRoot) {
|
|
83
|
+
const r = git(['worktree', 'list', '--porcelain'], repoRoot);
|
|
84
|
+
if (!r.ok)
|
|
85
|
+
throw new Error(`git worktree list failed: ${r.stderr}`);
|
|
86
|
+
const entries = parseWorktreePorcelain(r.stdout);
|
|
87
|
+
const squadEntries = entries.filter(e => e.path.includes('/.worktrees/'));
|
|
88
|
+
return squadEntries.map(e => {
|
|
89
|
+
const dirName = e.path.split('/.worktrees/')[1] ?? '';
|
|
90
|
+
const match = dirName.match(/^([^-]+)-(\d+)/);
|
|
91
|
+
const agent = match ? match[1] : dirName;
|
|
92
|
+
const issueNum = match ? match[2] : '?';
|
|
93
|
+
const dirty = existsSync(e.path) && hasUncommittedChanges(e.path);
|
|
94
|
+
return {
|
|
95
|
+
agent,
|
|
96
|
+
issueNum,
|
|
97
|
+
path: e.path,
|
|
98
|
+
branch: e.branch ?? '(detached)',
|
|
99
|
+
exists: existsSync(e.path),
|
|
100
|
+
dirty,
|
|
101
|
+
head: e.head ?? '',
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Remove a specific worktree. Warns but refuses if the worktree has
|
|
107
|
+
* uncommitted changes (pass `force: true` to override).
|
|
108
|
+
*/
|
|
109
|
+
export function removeWorktree(repoRoot, agent, issueNum, opts = {}) {
|
|
110
|
+
const worktreePath = getWorktreePath(repoRoot, agent, issueNum);
|
|
111
|
+
if (!existsSync(worktreePath)) {
|
|
112
|
+
console.log(`ℹ Worktree not found: ${worktreePath} (nothing to remove)`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!opts.force && hasUncommittedChanges(worktreePath)) {
|
|
116
|
+
console.error(`⚠ Worktree ${relative(repoRoot, worktreePath)} has uncommitted changes.\n` +
|
|
117
|
+
` Commit or stash your work before removing, or pass --force to discard.`);
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const removeArgs = ['worktree', 'remove', worktreePath];
|
|
122
|
+
if (opts.force)
|
|
123
|
+
removeArgs.push('--force');
|
|
124
|
+
const r = git(removeArgs, repoRoot);
|
|
125
|
+
if (!r.ok)
|
|
126
|
+
throw new Error(`Failed to remove worktree: ${r.stderr}`);
|
|
127
|
+
if (opts.deleteBranch) {
|
|
128
|
+
const branch = getBranchName(issueNum, agent);
|
|
129
|
+
const del = git(['branch', '-d', branch], repoRoot);
|
|
130
|
+
if (!del.ok) {
|
|
131
|
+
console.warn(`⚠ Could not delete branch ${branch}: ${del.stderr}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
console.log(`🗑 Removed worktree: ${relative(repoRoot, worktreePath)}`);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Remove all squad worktrees whose branch has been merged into `main`.
|
|
138
|
+
* Warns and skips worktrees with uncommitted changes.
|
|
139
|
+
*/
|
|
140
|
+
export function pruneWorktrees(repoRoot, opts = {}) {
|
|
141
|
+
const base = opts.base ?? 'main';
|
|
142
|
+
const worktrees = listWorktrees(repoRoot);
|
|
143
|
+
if (worktrees.length === 0) {
|
|
144
|
+
console.log('ℹ No squad worktrees found.');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
for (const wt of worktrees) {
|
|
148
|
+
const merged = isBranchMerged(repoRoot, wt.branch, base);
|
|
149
|
+
if (!merged) {
|
|
150
|
+
console.log(`↷ Skipping ${wt.agent}-${wt.issueNum} (branch not merged into ${base})`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (wt.dirty) {
|
|
154
|
+
console.warn(`⚠ Skipping ${wt.agent}-${wt.issueNum} (has uncommitted changes — clean up manually)`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (opts.dryRun) {
|
|
158
|
+
console.log(`[dry-run] Would remove: ${wt.agent}-${wt.issueNum} (branch: ${wt.branch})`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
removeWorktree(repoRoot, wt.agent, Number(wt.issueNum), { deleteBranch: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Print helpers
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
export function printWorktreeList(repoRoot) {
|
|
168
|
+
const worktrees = listWorktrees(repoRoot);
|
|
169
|
+
if (worktrees.length === 0) {
|
|
170
|
+
console.log('No squad worktrees found.');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const header = ['AGENT', 'ISSUE', 'BRANCH', 'STATUS', 'PATH'];
|
|
174
|
+
const rows = worktrees.map(wt => [
|
|
175
|
+
wt.agent,
|
|
176
|
+
wt.issueNum,
|
|
177
|
+
wt.branch,
|
|
178
|
+
wt.dirty ? '⚠ dirty' : '✓ clean',
|
|
179
|
+
wt.path,
|
|
180
|
+
]);
|
|
181
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
|
|
182
|
+
const fmt = (cols) => cols.map((c, i) => c.padEnd(widths[i])).join(' ');
|
|
183
|
+
console.log(fmt(header));
|
|
184
|
+
console.log(widths.map(w => '-'.repeat(w)).join(' '));
|
|
185
|
+
for (const row of rows)
|
|
186
|
+
console.log(fmt(row));
|
|
187
|
+
}
|
|
188
|
+
export function printWorktreeHelp() {
|
|
189
|
+
console.log(`
|
|
190
|
+
chapterhouse squad worktree — manage per-agent git worktrees
|
|
191
|
+
|
|
192
|
+
Usage:
|
|
193
|
+
chapterhouse squad worktree <subcommand>
|
|
194
|
+
|
|
195
|
+
Subcommands:
|
|
196
|
+
create <agent> <issue> [--base <branch>] [--slug <slug>]
|
|
197
|
+
Create a dedicated worktree for an agent + issue.
|
|
198
|
+
Path: .worktrees/{agent}-{issue}/ Branch: squad/{issue}-{agent}
|
|
199
|
+
|
|
200
|
+
list
|
|
201
|
+
List all squad worktrees with agent, branch, and status.
|
|
202
|
+
|
|
203
|
+
remove <agent> <issue> [--force] [--delete-branch]
|
|
204
|
+
Remove a worktree. Refuses if there are uncommitted changes
|
|
205
|
+
unless --force is passed (which discards the changes).
|
|
206
|
+
|
|
207
|
+
prune [--base <branch>] [--dry-run]
|
|
208
|
+
Remove all worktrees whose branch has been merged into main
|
|
209
|
+
(or the specified --base branch). Skips dirty worktrees.
|
|
210
|
+
`.trim());
|
|
211
|
+
}
|
|
212
|
+
function parseWorktreePorcelain(output) {
|
|
213
|
+
const entries = [];
|
|
214
|
+
let current = null;
|
|
215
|
+
for (const line of output.split('\n')) {
|
|
216
|
+
if (line.startsWith('worktree ')) {
|
|
217
|
+
if (current?.path)
|
|
218
|
+
entries.push(current);
|
|
219
|
+
current = { path: line.slice('worktree '.length) };
|
|
220
|
+
}
|
|
221
|
+
else if (line.startsWith('HEAD ') && current) {
|
|
222
|
+
current.head = line.slice('HEAD '.length);
|
|
223
|
+
}
|
|
224
|
+
else if (line.startsWith('branch ') && current) {
|
|
225
|
+
current.branch = line.slice('branch refs/heads/'.length);
|
|
226
|
+
}
|
|
227
|
+
else if (line === '' && current?.path) {
|
|
228
|
+
entries.push(current);
|
|
229
|
+
current = null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (current?.path)
|
|
233
|
+
entries.push(current);
|
|
234
|
+
return entries;
|
|
235
|
+
}
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// CLI entry point
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
export async function runWorktreeCli(args) {
|
|
240
|
+
let repoRoot;
|
|
241
|
+
try {
|
|
242
|
+
repoRoot = getRepoRoot(process.cwd());
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
console.error('❌ Not inside a git repository.');
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
const subcommand = args[0];
|
|
249
|
+
switch (subcommand) {
|
|
250
|
+
case 'create': {
|
|
251
|
+
const agent = args[1];
|
|
252
|
+
const issueNum = args[2];
|
|
253
|
+
if (!agent || !issueNum) {
|
|
254
|
+
console.error('Usage: chapterhouse squad worktree create <agent> <issue> [--base <branch>] [--slug <slug>]');
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
const baseIdx = args.indexOf('--base');
|
|
258
|
+
const baseBranch = baseIdx !== -1 ? args[baseIdx + 1] : undefined;
|
|
259
|
+
const slugIdx = args.indexOf('--slug');
|
|
260
|
+
const slug = slugIdx !== -1 ? args[slugIdx + 1] : undefined;
|
|
261
|
+
const path = createWorktree(repoRoot, agent, issueNum, { baseBranch, slug });
|
|
262
|
+
console.log(path);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
case 'list':
|
|
266
|
+
printWorktreeList(repoRoot);
|
|
267
|
+
break;
|
|
268
|
+
case 'remove': {
|
|
269
|
+
const agent = args[1];
|
|
270
|
+
const issueNum = args[2];
|
|
271
|
+
if (!agent || !issueNum) {
|
|
272
|
+
console.error('Usage: chapterhouse squad worktree remove <agent> <issue> [--force] [--delete-branch]');
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
removeWorktree(repoRoot, agent, issueNum, {
|
|
276
|
+
force: args.includes('--force'),
|
|
277
|
+
deleteBranch: args.includes('--delete-branch'),
|
|
278
|
+
});
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
case 'prune': {
|
|
282
|
+
const baseIdx = args.indexOf('--base');
|
|
283
|
+
const base = baseIdx !== -1 ? args[baseIdx + 1] : undefined;
|
|
284
|
+
pruneWorktrees(repoRoot, { base, dryRun: args.includes('--dry-run') });
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
default:
|
|
288
|
+
if (subcommand)
|
|
289
|
+
console.error(`Unknown worktree subcommand: ${subcommand}\n`);
|
|
290
|
+
printWorktreeHelp();
|
|
291
|
+
if (subcommand)
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
//# sourceMappingURL=worktree.js.map
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Test fixture: minimal git repo
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const repoRoot = process.cwd();
|
|
10
|
+
const sandboxRoot = join(repoRoot, '.test-work', `worktree-${process.pid}`);
|
|
11
|
+
function initRepo(dir) {
|
|
12
|
+
mkdirSync(dir, { recursive: true });
|
|
13
|
+
// --initial-branch=main ensures the branch is named 'main' regardless of
|
|
14
|
+
// the runner's global init.defaultBranch setting (older git may default to 'master').
|
|
15
|
+
execSync('git init --initial-branch=main', { cwd: dir, stdio: 'pipe' });
|
|
16
|
+
execSync('git config user.email "test@example.com"', { cwd: dir, stdio: 'pipe' });
|
|
17
|
+
execSync('git config user.name "Test CI"', { cwd: dir, stdio: 'pipe' });
|
|
18
|
+
writeFileSync(join(dir, 'README.md'), '# test\n');
|
|
19
|
+
execSync('git add .', { cwd: dir, stdio: 'pipe' });
|
|
20
|
+
// An initial commit is required so that 'main' resolves as a ref.
|
|
21
|
+
// Without it, `git worktree add <path> -b <branch> main` fails with
|
|
22
|
+
// "fatal: invalid reference: main" because the branch has no commits.
|
|
23
|
+
execSync('git commit -m "chore: initial commit"', { cwd: dir, stdio: 'pipe' });
|
|
24
|
+
}
|
|
25
|
+
// We need a separate repo because the main repo's worktrees would interfere.
|
|
26
|
+
// Each test suite gets its own isolated sandbox git repo.
|
|
27
|
+
const testRepo = join(sandboxRoot, 'repo');
|
|
28
|
+
test.before(() => {
|
|
29
|
+
mkdirSync(sandboxRoot, { recursive: true });
|
|
30
|
+
initRepo(testRepo);
|
|
31
|
+
});
|
|
32
|
+
test.after(() => {
|
|
33
|
+
// Clean up any worktrees first (git worktree remove) then the sandbox
|
|
34
|
+
try {
|
|
35
|
+
execSync('git worktree prune', { cwd: testRepo });
|
|
36
|
+
}
|
|
37
|
+
catch { /* ignore */ }
|
|
38
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
async function loadWorktree() {
|
|
41
|
+
return await import(new URL(`./worktree.js?v=${Date.now()}-${Math.random()}`, import.meta.url).href);
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// getWorktreePath
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
test('getWorktreePath returns correct path inside .worktrees/', async () => {
|
|
47
|
+
const m = await loadWorktree();
|
|
48
|
+
const result = m.getWorktreePath('/repo', 'kaylee', 42);
|
|
49
|
+
assert.equal(result, '/repo/.worktrees/kaylee-42');
|
|
50
|
+
});
|
|
51
|
+
test('getWorktreePath works with string issue number', async () => {
|
|
52
|
+
const m = await loadWorktree();
|
|
53
|
+
const result = m.getWorktreePath('/repo', 'wash', '17');
|
|
54
|
+
assert.equal(result, '/repo/.worktrees/wash-17');
|
|
55
|
+
});
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// getBranchName
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
test('getBranchName without slug uses squad/{issue} format', async () => {
|
|
60
|
+
const m = await loadWorktree();
|
|
61
|
+
assert.equal(m.getBranchName(49), 'squad/49');
|
|
62
|
+
});
|
|
63
|
+
test('getBranchName with slug appends kebab-case slug', async () => {
|
|
64
|
+
const m = await loadWorktree();
|
|
65
|
+
assert.equal(m.getBranchName(49, 'git worktrees'), 'squad/49-git-worktrees');
|
|
66
|
+
});
|
|
67
|
+
test('getBranchName normalises special characters in slug', async () => {
|
|
68
|
+
const m = await loadWorktree();
|
|
69
|
+
assert.equal(m.getBranchName(10, 'Fix Login!!'), 'squad/10-fix-login');
|
|
70
|
+
});
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// createWorktree
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
test('createWorktree creates a worktree at .worktrees/{agent}-{issue}/', async () => {
|
|
75
|
+
const m = await loadWorktree();
|
|
76
|
+
const { existsSync } = await import('node:fs');
|
|
77
|
+
const wt = m.createWorktree(testRepo, 'kaylee', 1);
|
|
78
|
+
assert.ok(existsSync(wt), `Worktree path should exist: ${wt}`);
|
|
79
|
+
assert.ok(wt.endsWith('.worktrees/kaylee-1'), `Path should end with .worktrees/kaylee-1, got: ${wt}`);
|
|
80
|
+
// Verify branch was created
|
|
81
|
+
const { spawnSync } = await import('node:child_process');
|
|
82
|
+
const r = spawnSync('git', ['branch', '--list', 'squad/1-kaylee'], { cwd: testRepo, encoding: 'utf-8' });
|
|
83
|
+
assert.ok(r.stdout.trim().length > 0, 'Branch squad/1-kaylee should exist');
|
|
84
|
+
});
|
|
85
|
+
test('createWorktree reuses existing worktree without error', async () => {
|
|
86
|
+
const m = await loadWorktree();
|
|
87
|
+
// create again (already exists from above)
|
|
88
|
+
assert.doesNotThrow(() => {
|
|
89
|
+
m.createWorktree(testRepo, 'kaylee', 1);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
test('createWorktree respects --base option', async () => {
|
|
93
|
+
const m = await loadWorktree();
|
|
94
|
+
const { existsSync } = await import('node:fs');
|
|
95
|
+
// We guarantee 'main' exists via --initial-branch=main in initRepo
|
|
96
|
+
const wt = m.createWorktree(testRepo, 'wash', 2, { baseBranch: 'main' });
|
|
97
|
+
assert.ok(existsSync(wt), `Worktree should be created at ${wt}`);
|
|
98
|
+
});
|
|
99
|
+
test('createWorktree with slug uses slug in branch name', async () => {
|
|
100
|
+
const m = await loadWorktree();
|
|
101
|
+
const wt = m.createWorktree(testRepo, 'zoe', 3, { slug: 'test-isolation' });
|
|
102
|
+
const { spawnSync } = await import('node:child_process');
|
|
103
|
+
const r = spawnSync('git', ['branch', '--list', 'squad/3-test-isolation'], { cwd: testRepo, encoding: 'utf-8' });
|
|
104
|
+
assert.ok(r.stdout.trim().length > 0, 'Branch squad/3-test-isolation should exist');
|
|
105
|
+
void wt;
|
|
106
|
+
});
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// listWorktrees
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
test('listWorktrees returns entries for squad worktrees', async () => {
|
|
111
|
+
const m = await loadWorktree();
|
|
112
|
+
const list = m.listWorktrees(testRepo);
|
|
113
|
+
// We created kaylee-1 above; at minimum it should appear
|
|
114
|
+
assert.ok(list.length >= 1, 'Should have at least 1 squad worktree');
|
|
115
|
+
const kaylee = list.find(w => w.agent === 'kaylee' && w.issueNum === '1');
|
|
116
|
+
assert.ok(kaylee, 'Should include kaylee-1 worktree');
|
|
117
|
+
assert.equal(kaylee?.branch, 'squad/1-kaylee');
|
|
118
|
+
assert.equal(typeof kaylee?.dirty, 'boolean');
|
|
119
|
+
});
|
|
120
|
+
test('listWorktrees does not include the main checkout', async () => {
|
|
121
|
+
const m = await loadWorktree();
|
|
122
|
+
const list = m.listWorktrees(testRepo);
|
|
123
|
+
const hasMain = list.some(w => !w.path.includes('/.worktrees/'));
|
|
124
|
+
assert.ok(!hasMain, 'Main checkout should not appear in squad worktree list');
|
|
125
|
+
});
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// removeWorktree
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
test('removeWorktree removes a clean worktree', async () => {
|
|
130
|
+
const m = await loadWorktree();
|
|
131
|
+
// Create a fresh worktree to remove
|
|
132
|
+
m.createWorktree(testRepo, 'scribe', 5);
|
|
133
|
+
const wtPath = m.getWorktreePath(testRepo, 'scribe', 5);
|
|
134
|
+
const { existsSync } = await import('node:fs');
|
|
135
|
+
assert.ok(existsSync(wtPath));
|
|
136
|
+
m.removeWorktree(testRepo, 'scribe', 5);
|
|
137
|
+
assert.ok(!existsSync(wtPath), 'Worktree should be removed');
|
|
138
|
+
});
|
|
139
|
+
test('removeWorktree is a no-op for non-existent worktree', async () => {
|
|
140
|
+
const m = await loadWorktree();
|
|
141
|
+
assert.doesNotThrow(() => {
|
|
142
|
+
m.removeWorktree(testRepo, 'ghost', 999);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
test('removeWorktree refuses to remove dirty worktree without --force', async () => {
|
|
146
|
+
const m = await loadWorktree();
|
|
147
|
+
const { writeFileSync } = await import('node:fs');
|
|
148
|
+
// Create worktree, then add an uncommitted file
|
|
149
|
+
m.createWorktree(testRepo, 'mal', 6);
|
|
150
|
+
const wtPath = m.getWorktreePath(testRepo, 'mal', 6);
|
|
151
|
+
writeFileSync(join(wtPath, 'uncommitted.txt'), 'dirty\n');
|
|
152
|
+
// Should set exitCode=1 but not throw
|
|
153
|
+
const prev = process.exitCode;
|
|
154
|
+
process.exitCode = 0;
|
|
155
|
+
m.removeWorktree(testRepo, 'mal', 6, { force: false });
|
|
156
|
+
const rejected = process.exitCode === 1;
|
|
157
|
+
process.exitCode = prev; // restore
|
|
158
|
+
assert.ok(rejected, 'Should set exitCode=1 for dirty worktree');
|
|
159
|
+
// cleanup with force
|
|
160
|
+
m.removeWorktree(testRepo, 'mal', 6, { force: true });
|
|
161
|
+
});
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// pruneWorktrees
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
test('pruneWorktrees dry-run lists worktrees that would be removed', async () => {
|
|
166
|
+
const m = await loadWorktree();
|
|
167
|
+
// Capture stdout (basic test — just ensure it doesn't throw)
|
|
168
|
+
assert.doesNotThrow(() => {
|
|
169
|
+
m.pruneWorktrees(testRepo, { dryRun: true });
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
test('pruneWorktrees skips worktrees on unmerged branches', async () => {
|
|
173
|
+
const m = await loadWorktree();
|
|
174
|
+
const { existsSync, writeFileSync } = await import('node:fs');
|
|
175
|
+
const { spawnSync } = await import('node:child_process');
|
|
176
|
+
const { join } = await import('node:path');
|
|
177
|
+
// zoe-3 worktree should exist (created in earlier test)
|
|
178
|
+
const wt = m.getWorktreePath(testRepo, 'zoe', 3);
|
|
179
|
+
assert.ok(existsSync(wt), 'zoe-3 worktree should exist');
|
|
180
|
+
// Add a commit so the branch diverges from base — otherwise git considers
|
|
181
|
+
// a branch with no extra commits as already merged into its parent.
|
|
182
|
+
writeFileSync(join(wt, 'feature.txt'), 'in progress\n');
|
|
183
|
+
spawnSync('git', ['add', 'feature.txt'], { cwd: wt, stdio: 'pipe' });
|
|
184
|
+
spawnSync('git', ['commit', '-m', 'feat: in-progress work'], { cwd: wt, stdio: 'pipe' });
|
|
185
|
+
// We guarantee 'main' via --initial-branch=main in initRepo
|
|
186
|
+
m.pruneWorktrees(testRepo, { base: 'main' });
|
|
187
|
+
assert.ok(existsSync(wt), 'Unmerged worktree should NOT be pruned');
|
|
188
|
+
});
|
|
189
|
+
//# sourceMappingURL=worktree.test.js.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chapterhouse",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Chapterhouse
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Chapterhouse — a team-level AI assistant for engineering teams, built on the GitHub Copilot SDK. Web UI only.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"chapterhouse": "dist/cli.js"
|
|
7
7
|
},
|
|
@@ -22,8 +22,12 @@
|
|
|
22
22
|
"dev:server": "tsx --watch src/daemon.ts",
|
|
23
23
|
"dev:web": "npm --prefix web run dev",
|
|
24
24
|
"dev": "tsx --watch src/daemon.ts",
|
|
25
|
+
"release:check": "if [ -n \"$(git status --porcelain)\" ]; then echo '❌ Working tree is not clean. Stage or stash changes before running npm version.'; git status --short; exit 1; fi",
|
|
26
|
+
"preversion": "npm run release:check",
|
|
27
|
+
"prepare": "husky",
|
|
25
28
|
"test": "npm run clean && npm run build:server && node --experimental-test-module-mocks --import ./dist/test/setup-env.js --test 'dist/**/*.test.js'",
|
|
26
|
-
"prepublishOnly": "npm run build"
|
|
29
|
+
"prepublishOnly": "npm run build",
|
|
30
|
+
"prepare": "husky"
|
|
27
31
|
},
|
|
28
32
|
"engines": {
|
|
29
33
|
"node": ">=22.5.0"
|
|
@@ -62,11 +66,14 @@
|
|
|
62
66
|
},
|
|
63
67
|
"devDependencies": {
|
|
64
68
|
"@bradygaster/squad-cli": "^0.9.4",
|
|
69
|
+
"@commitlint/cli": "^21.0.0",
|
|
70
|
+
"@commitlint/config-conventional": "^21.0.0",
|
|
65
71
|
"@types/better-sqlite3": "^7.6.13",
|
|
66
72
|
"@types/cors": "^2.8.19",
|
|
67
73
|
"@types/express": "^5.0.6",
|
|
68
74
|
"@types/jsonwebtoken": "^9.0.10",
|
|
69
75
|
"@types/node": "^25.6.0",
|
|
76
|
+
"husky": "^9.1.7",
|
|
70
77
|
"tsx": "^4.21.0",
|
|
71
78
|
"typescript": "^5.9.3"
|
|
72
79
|
}
|