@webappwiz/arbor 0.0.11 → 0.0.13
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 +21 -11
- package/arbor.d.ts +2 -2
- package/dev/assets.d.ts +1 -7
- package/dev.d.ts +9 -13
- package/escalate.d.ts +5 -1
- package/git.d.ts +7 -1
- package/index.js +111 -76
- package/{ls.d.ts → list.d.ts} +3 -3
- package/merge.d.ts +2 -1
- package/package.json +2 -2
- package/shell.d.ts +2 -6
- package/show.d.ts +11 -6
- package/snapshot.d.ts +2 -2
package/README.md
CHANGED
|
@@ -32,6 +32,11 @@ rebase, and that is what `rm` is for.
|
|
|
32
32
|
Creates the task: branch `task/<task>`, a worktree at
|
|
33
33
|
`../<repo>-arbor/<task>`, and a state record.
|
|
34
34
|
|
|
35
|
+
`--base <branch>` starts the task from that branch and lands it back there
|
|
36
|
+
instead of trunk. It takes another task's branch too: `--base task/<other>`
|
|
37
|
+
stacks this task on that one, and the work lands in that task's worktree
|
|
38
|
+
rather than on trunk.
|
|
39
|
+
|
|
35
40
|
A fresh worktree shares no untracked files with the repo (no `node_modules`,
|
|
36
41
|
no `.env`) which is what `postCheckout` is for.
|
|
37
42
|
|
|
@@ -53,10 +58,11 @@ holds the lease. A worktree with no record is rebuilt rather than rejected.
|
|
|
53
58
|
|
|
54
59
|
### `arbor merge`
|
|
55
60
|
|
|
56
|
-
Lands the current worktree's branch on trunk
|
|
61
|
+
Lands the current worktree's branch on its base, trunk unless the task was
|
|
62
|
+
created with `--base`. The core command.
|
|
57
63
|
|
|
58
|
-
**Never a merge commit.** It rebases onto
|
|
59
|
-
there, and fast-forwards
|
|
64
|
+
**Never a merge commit.** It rebases onto the base, runs the `preMerge` gate
|
|
65
|
+
there, and fast-forwards the base with `git merge --ff-only`. History stays
|
|
60
66
|
linear.
|
|
61
67
|
|
|
62
68
|
1. Refuses if the worktree is dirty, out of retry budget, or leased elsewhere.
|
|
@@ -67,10 +73,14 @@ linear.
|
|
|
67
73
|
passed before rebasing says nothing about whether it works against current
|
|
68
74
|
trunk; this is the only defense against semantic conflicts, where both sides
|
|
69
75
|
merge cleanly and the combination is broken.
|
|
70
|
-
4. Re-checks the lease, then
|
|
76
|
+
4. Re-checks the lease, then fast-forwards the base **in the worktree that has
|
|
77
|
+
it checked out**: the main tree for trunk, or the other task's worktree for
|
|
78
|
+
a `--base task/<other>` task. Git allows one worktree per branch, and that
|
|
79
|
+
tree is the only place the branch can move: advancing the ref behind its
|
|
80
|
+
back would leave its index and files on the old commit.
|
|
71
81
|
5. Discards the task (worktree, branch and record) exactly as `rm`
|
|
72
82
|
would. The work is on trunk, so the tree has nothing left to hold, and
|
|
73
|
-
`arbor
|
|
83
|
+
`arbor list` stays a list of live work rather than a graveyard of landed
|
|
74
84
|
tasks. The agent's own directory goes with it, so the success message
|
|
75
85
|
prints the main tree to `cd` back to.
|
|
76
86
|
|
|
@@ -95,7 +105,7 @@ Removal leaves a tombstone in `.git/arbor/removed/` so a second `rm` can say
|
|
|
95
105
|
and drops the oldest as new ones arrive, so a long-forgotten task reports
|
|
96
106
|
`not_found` again.
|
|
97
107
|
|
|
98
|
-
### `arbor
|
|
108
|
+
### `arbor list [--json]`
|
|
99
109
|
|
|
100
110
|
Every task: name, status, lease (`held`/`stale`/`none`), commits ahead of
|
|
101
111
|
trunk, age. A corrupt record shows as `unknown` instead of taking
|
|
@@ -103,7 +113,7 @@ down the listing; a record whose worktree vanished shows as `orphaned`.
|
|
|
103
113
|
|
|
104
114
|
### `arbor show <task> [--json]`
|
|
105
115
|
|
|
106
|
-
One task in full: the row `
|
|
116
|
+
One task in full: the row `list` would print for it, plus the `ARBOR.md`
|
|
107
117
|
its agent keeps at the worktree root and the reason behind an `escalated`
|
|
108
118
|
status.
|
|
109
119
|
|
|
@@ -120,7 +130,7 @@ ARBOR.md
|
|
|
120
130
|
...
|
|
121
131
|
```
|
|
122
132
|
|
|
123
|
-
`
|
|
133
|
+
`list` says a task exists; this says what it is doing. Like `path`, it takes no
|
|
124
134
|
lease, so reading another agent's tree cannot knock it off its own work the way
|
|
125
135
|
`claim` would. A task with no `ARBOR.md` is called out rather than passed over
|
|
126
136
|
in silence: it is the one thing that makes the work resumable.
|
|
@@ -161,14 +171,14 @@ WHEN ACTION TASK RESULT
|
|
|
161
171
|
1h merge alpha ok
|
|
162
172
|
```
|
|
163
173
|
|
|
164
|
-
`
|
|
174
|
+
`list` is what still exists; this is what happened. Entries outlive their tasks:
|
|
165
175
|
a successful `merge` and a `rm` both take the record with them, so this is
|
|
166
176
|
the only thing that remembers a task landed at all. The last 200 are kept
|
|
167
177
|
(`logCapacity`) in `.git/arbor/log.jsonl`.
|
|
168
178
|
|
|
169
179
|
### `arbor dev [--port 4269]`
|
|
170
180
|
|
|
171
|
-
`
|
|
181
|
+
`list`, `show` and `log` in a browser, on `http://localhost:4269`, reloading
|
|
172
182
|
themselves as tasks change. Read-only, and takes no lease.
|
|
173
183
|
|
|
174
184
|
### `arbor path [task]`
|
|
@@ -236,7 +246,7 @@ The agent's control flow runs on these.
|
|
|
236
246
|
| 9 | `hook_failed` | `postCheckout` failed (worktree still exists; fix and re-run the hook), or `postMerge` failed (the branch already landed; nothing rolled back). |
|
|
237
247
|
| 10 | `exists` | Task already exists. `arbor claim` it, or `arbor rm` first. |
|
|
238
248
|
| 11 | `orphaned` | Record with no worktree. `arbor rm` it. |
|
|
239
|
-
| 12 | `merge_failed` |
|
|
249
|
+
| 12 | `merge_failed` | The base could not be fast-forwarded (usually uncommitted changes in the worktree holding it). |
|
|
240
250
|
| 13 | `already_removed` | This task was removed earlier; nothing left to remove. |
|
|
241
251
|
| 14 | `timeout` | `arbor wait` gave up: the task is still working or merging. |
|
|
242
252
|
|
package/arbor.d.ts
CHANGED
|
@@ -6,9 +6,9 @@ import { type Repository } from "./repository.js";
|
|
|
6
6
|
/** Everything `arbor` is run with, before the repository middleware adds to it. */
|
|
7
7
|
export interface ArborDeps extends Deps {
|
|
8
8
|
fs: Fs;
|
|
9
|
-
/**
|
|
9
|
+
/** HTTP server `dev` listens on to serve its preview. */
|
|
10
10
|
http: HttpServer;
|
|
11
|
-
/**
|
|
11
|
+
/** The built page `dev` serves as its preview. */
|
|
12
12
|
assets: Assets;
|
|
13
13
|
}
|
|
14
14
|
export declare const arbor: import("webappwiz/cmd").Cli<ArborDeps, ArborDeps & Repository>;
|
package/dev/assets.d.ts
CHANGED
|
@@ -7,11 +7,5 @@ export interface Assets {
|
|
|
7
7
|
/** The stylesheet, with Tailwind already compiled. */
|
|
8
8
|
styles: string;
|
|
9
9
|
}
|
|
10
|
-
/**
|
|
11
|
-
* The page this package ships, built by `build.ts` and imported here as text so
|
|
12
|
-
* it travels inside the bundle a release publishes. Serving it costs a string
|
|
13
|
-
* lookup, and nothing is read off disk: a published package has no `dev/`
|
|
14
|
-
* directory to read from, and the CLI's own dependencies stay clear of React
|
|
15
|
-
* and Tailwind because neither is needed once the page is built.
|
|
16
|
-
*/
|
|
10
|
+
/** The page this package ships. Serve each field's contents as-is. */
|
|
17
11
|
export declare const assets: Assets;
|
package/dev.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AsyncResource } from "webappwiz/disposable";
|
|
1
2
|
import type { HttpServer } from "webappwiz/http";
|
|
2
3
|
import type { Logger } from "webappwiz/log";
|
|
3
4
|
import { type Fs, type PortProvider } from "webappwiz/system";
|
|
@@ -8,30 +9,19 @@ import type { WorktreeService } from "./worktree-service.js";
|
|
|
8
9
|
export declare const DEFAULT_PORT = 4269;
|
|
9
10
|
/** How far above the port asked for `dev` will look before giving up. */
|
|
10
11
|
export declare const PORT_SPAN = 20;
|
|
11
|
-
/**
|
|
12
|
-
* Where `dev` will listen, given the port asked for. A flag is outside input,
|
|
13
|
-
* so a port that cannot exist is a refusal with an exit code rather than the
|
|
14
|
-
* assertion `OpenPortProvider` would raise for a bug in here.
|
|
15
|
-
*/
|
|
16
|
-
export declare function devPorts(from: number): PortProvider;
|
|
17
12
|
/** What `dev` lets a caller choose. */
|
|
18
13
|
export interface DevOptions {
|
|
19
14
|
/** Where to listen; the port `--port` asked for, and the span above it. */
|
|
20
15
|
ports?: PortProvider;
|
|
21
16
|
}
|
|
22
17
|
/** A running server, and the one thing a caller ever wants to do with it. */
|
|
23
|
-
export interface DevServer {
|
|
18
|
+
export interface DevServer extends AsyncResource {
|
|
24
19
|
port: number;
|
|
25
|
-
stop(): Promise<void>;
|
|
26
20
|
}
|
|
27
21
|
/**
|
|
28
|
-
* Serves what `
|
|
22
|
+
* Serves what `list`, `show` and `log` print, as one page that refetches when the
|
|
29
23
|
* repo changes. Read-only on purpose: driving arbor is what the CLI is for, and
|
|
30
24
|
* a button that took a lease would fight the agent holding it.
|
|
31
|
-
*
|
|
32
|
-
* The page itself is a React app under `dev/`, built before publishing and
|
|
33
|
-
* carried in the bundle, so nothing here builds markup and nothing reads it
|
|
34
|
-
* off disk.
|
|
35
25
|
*/
|
|
36
26
|
export declare function dev({ service, fs, journal, log, http, assets, }: {
|
|
37
27
|
service: WorktreeService;
|
|
@@ -41,3 +31,9 @@ export declare function dev({ service, fs, journal, log, http, assets, }: {
|
|
|
41
31
|
http: HttpServer;
|
|
42
32
|
assets: Assets;
|
|
43
33
|
}, { ports }?: DevOptions): Promise<DevServer>;
|
|
34
|
+
/**
|
|
35
|
+
* Where `dev` will listen, given the port asked for. A flag is outside input,
|
|
36
|
+
* so a port that cannot exist is a refusal with an exit code rather than the
|
|
37
|
+
* assertion `OpenPortProvider` would raise for a bug in here.
|
|
38
|
+
*/
|
|
39
|
+
export declare function devPorts(from: number): PortProvider;
|
package/escalate.d.ts
CHANGED
|
@@ -2,6 +2,10 @@ import { type Logger } from "webappwiz/log";
|
|
|
2
2
|
import type { Lock } from "webappwiz/system";
|
|
3
3
|
import type { Git } from "./git.js";
|
|
4
4
|
import type { WorktreeService } from "./worktree-service.js";
|
|
5
|
+
export interface EscalateOptions {
|
|
6
|
+
/** Task to escalate. Defaults to the one `cwd` is a worktree for. */
|
|
7
|
+
task?: string;
|
|
8
|
+
}
|
|
5
9
|
/**
|
|
6
10
|
* The way out that is not "resolve the conflict badly to finish the task".
|
|
7
11
|
* When both sides restructured the same logic there is no correct merge, only
|
|
@@ -12,4 +16,4 @@ export declare function escalate({ service, git, lock, log, }: {
|
|
|
12
16
|
git: Git;
|
|
13
17
|
lock: Lock;
|
|
14
18
|
log: Logger;
|
|
15
|
-
}, reason: string, cwd: string, task?:
|
|
19
|
+
}, reason: string, cwd: string, { task }?: EscalateOptions): Promise<void>;
|
package/git.d.ts
CHANGED
|
@@ -44,7 +44,13 @@ export declare class Git {
|
|
|
44
44
|
rebase(cwd: string, onto: string): Promise<GitResult>;
|
|
45
45
|
resetHard(cwd: string, commit: string): Promise<GitResult>;
|
|
46
46
|
checkout(branch: string): Promise<GitResult>;
|
|
47
|
-
mergeFfOnly(branch: string): Promise<GitResult>;
|
|
47
|
+
mergeFfOnly(cwd: string, branch: string): Promise<GitResult>;
|
|
48
|
+
/**
|
|
49
|
+
* The worktree with `branch` checked out, or null when no worktree has it.
|
|
50
|
+
* Git allows one worktree per branch and that is the only place the branch
|
|
51
|
+
* can be advanced, so this is where a fast-forward has to happen.
|
|
52
|
+
*/
|
|
53
|
+
worktreeFor(branch: string): Promise<string | null>;
|
|
48
54
|
addWorktree(branch: string, path: string, from: string): Promise<GitResult>;
|
|
49
55
|
removeWorktree(path: string): Promise<GitResult>;
|
|
50
56
|
/** Drops metadata for worktrees whose directories are gone. */
|
package/index.js
CHANGED
|
@@ -62,7 +62,6 @@ function exits() {
|
|
|
62
62
|
// add.ts
|
|
63
63
|
var NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
64
64
|
var PLAN_FILE = "ARBOR.md";
|
|
65
|
-
var PLAN = (task) => new MarkdownWriter().heading(1, task).heading(2, "Goal").heading(2, "Files").heading(2, "Next").checklist("fill in Goal and list the steps here as `- [ ]` items").toString();
|
|
66
65
|
async function add({
|
|
67
66
|
service,
|
|
68
67
|
shell,
|
|
@@ -100,8 +99,7 @@ ${PLAN_FILE}
|
|
|
100
99
|
}
|
|
101
100
|
await fs.write(`${worktree.path}/${PLAN_FILE}`, PLAN(task));
|
|
102
101
|
if (config.postCheckout) {
|
|
103
|
-
const { exitCode } = await shell.stream(config.postCheckout, {
|
|
104
|
-
cwd: worktree.path,
|
|
102
|
+
const { exitCode } = await shell.stream(config.postCheckout, worktree.path, {
|
|
105
103
|
env: {
|
|
106
104
|
ARBOR_TASK: task,
|
|
107
105
|
ARBOR_WORKTREE: worktree.path,
|
|
@@ -117,6 +115,9 @@ ${PLAN_FILE}
|
|
|
117
115
|
branch: ${worktree.branch}
|
|
118
116
|
base: ${base}`);
|
|
119
117
|
}
|
|
118
|
+
function PLAN(task) {
|
|
119
|
+
return new MarkdownWriter().heading(1, task).heading(2, "Goal").heading(2, "Files").heading(2, "Next").checklist("fill in Goal and list the steps here as `- [ ]` items").toString();
|
|
120
|
+
}
|
|
120
121
|
|
|
121
122
|
// claim.ts
|
|
122
123
|
import { color as color2 } from "webappwiz/log";
|
|
@@ -161,6 +162,7 @@ ${changes.map((change) => ` ${change}`).join(`
|
|
|
161
162
|
}
|
|
162
163
|
|
|
163
164
|
// dev.ts
|
|
165
|
+
import { AsyncDisposer } from "webappwiz/disposable";
|
|
164
166
|
import {
|
|
165
167
|
MAX_PORT,
|
|
166
168
|
OpenPortProvider
|
|
@@ -271,41 +273,47 @@ function checkPlan(text, { task, escalated = false }) {
|
|
|
271
273
|
|
|
272
274
|
// show.ts
|
|
273
275
|
var FILE = "ARBOR.md";
|
|
274
|
-
async function show({ service, fs, log: log2 }, task,
|
|
276
|
+
async function show({ service, fs, log: log2 }, task, opts = {}) {
|
|
275
277
|
const worktree = await service.find(task);
|
|
276
278
|
if (worktree.gone) {
|
|
277
|
-
fail("not_found", `no task '${task}': run \`arbor
|
|
279
|
+
fail("not_found", `no task '${task}': run \`arbor list\` to see what there is`, { task });
|
|
278
280
|
}
|
|
279
|
-
const details = await
|
|
280
|
-
if (json) {
|
|
281
|
+
const details = await new TaskDetails({ fs }).get(worktree);
|
|
282
|
+
if (opts.json ?? false) {
|
|
281
283
|
log2.info(JSON.stringify(details, null, "\t"));
|
|
282
284
|
return;
|
|
283
285
|
}
|
|
284
286
|
log2.info(report(details));
|
|
285
287
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
lease: worktree.leaseStatus,
|
|
298
|
-
ahead: worktree.hasBranch ? await worktree.commitsAhead() : null,
|
|
299
|
-
added: stat?.added ?? null,
|
|
300
|
-
removed: stat?.removed ?? null,
|
|
301
|
-
age: state ? age(state.createdAt) : null,
|
|
302
|
-
escalation: state?.escalations?.at(-1)?.reason ?? null,
|
|
303
|
-
plan,
|
|
304
|
-
planProblems: plan === null ? [] : checkPlan(plan, {
|
|
288
|
+
|
|
289
|
+
class TaskDetails {
|
|
290
|
+
fs;
|
|
291
|
+
constructor(opts = {}) {
|
|
292
|
+
this.fs = opts.fs ?? new NodeFs;
|
|
293
|
+
}
|
|
294
|
+
async get(worktree) {
|
|
295
|
+
const { state } = worktree;
|
|
296
|
+
const stat = worktree.hasBranch ? await worktree.diffStat() : null;
|
|
297
|
+
const plan = worktree.exists ? await this.fs.read(`${worktree.path}/${FILE}`).catch(() => null) : null;
|
|
298
|
+
return {
|
|
305
299
|
task: worktree.task,
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
300
|
+
status: worktree.status,
|
|
301
|
+
branch: worktree.branch,
|
|
302
|
+
base: worktree.base,
|
|
303
|
+
worktree: worktree.path,
|
|
304
|
+
lease: worktree.leaseStatus,
|
|
305
|
+
ahead: worktree.hasBranch ? await worktree.commitsAhead() : null,
|
|
306
|
+
added: stat?.added ?? null,
|
|
307
|
+
removed: stat?.removed ?? null,
|
|
308
|
+
age: state ? age(state.createdAt) : null,
|
|
309
|
+
escalation: state?.escalations?.at(-1)?.reason ?? null,
|
|
310
|
+
plan,
|
|
311
|
+
planProblems: plan === null ? [] : checkPlan(plan, {
|
|
312
|
+
task: worktree.task,
|
|
313
|
+
escalated: worktree.status === "escalated"
|
|
314
|
+
})
|
|
315
|
+
};
|
|
316
|
+
}
|
|
309
317
|
}
|
|
310
318
|
function report(details) {
|
|
311
319
|
const lines = [
|
|
@@ -340,10 +348,10 @@ function diff(details) {
|
|
|
340
348
|
|
|
341
349
|
// snapshot.ts
|
|
342
350
|
async function snapshot(service, journal, opts = {}) {
|
|
343
|
-
const
|
|
351
|
+
const details = new TaskDetails({ fs: opts.fs ?? new NodeFs2 });
|
|
344
352
|
const tasks = [];
|
|
345
353
|
for (const worktree of await service.list()) {
|
|
346
|
-
tasks.push(await
|
|
354
|
+
tasks.push(await details.get(worktree));
|
|
347
355
|
}
|
|
348
356
|
return { tasks, entries: await journal.tail(DEFAULT_COUNT) };
|
|
349
357
|
}
|
|
@@ -368,14 +376,6 @@ function fingerprint({ tasks, entries }) {
|
|
|
368
376
|
// dev.ts
|
|
369
377
|
var DEFAULT_PORT = 4269;
|
|
370
378
|
var PORT_SPAN = 20;
|
|
371
|
-
function devPorts(from) {
|
|
372
|
-
if (!Number.isInteger(from) || from < 0 || from > MAX_PORT) {
|
|
373
|
-
fail("usage", `invalid port '${from}': use a whole number 0 to ${MAX_PORT}`, {
|
|
374
|
-
port: from
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
return OpenPortProvider.span({ from, span: PORT_SPAN });
|
|
378
|
-
}
|
|
379
379
|
var POLL_MS = 2000;
|
|
380
380
|
async function dev({
|
|
381
381
|
service,
|
|
@@ -448,14 +448,22 @@ async function dev({
|
|
|
448
448
|
}
|
|
449
449
|
}, { port: await ports.get(), idleTimeout: Duration.zero() });
|
|
450
450
|
log2.info(`arbor dev on http://localhost:${listening.port}`);
|
|
451
|
+
const disposer = new AsyncDisposer;
|
|
452
|
+
disposer.defer(() => listening.stop());
|
|
453
|
+
disposer.defer(async () => clearInterval(poll));
|
|
451
454
|
return {
|
|
452
455
|
port: listening.port,
|
|
453
|
-
|
|
454
|
-
clearInterval(poll);
|
|
455
|
-
await listening.stop();
|
|
456
|
-
}
|
|
456
|
+
disposeAsync: disposer.disposeAsync
|
|
457
457
|
};
|
|
458
458
|
}
|
|
459
|
+
function devPorts(from) {
|
|
460
|
+
if (!Number.isInteger(from) || from < 0 || from > MAX_PORT) {
|
|
461
|
+
fail("usage", `invalid port '${from}': use a whole number 0 to ${MAX_PORT}`, {
|
|
462
|
+
port: from
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
return OpenPortProvider.span({ from, span: PORT_SPAN });
|
|
466
|
+
}
|
|
459
467
|
|
|
460
468
|
// escalate.ts
|
|
461
469
|
import { color as color6 } from "webappwiz/log";
|
|
@@ -464,7 +472,7 @@ async function escalate({
|
|
|
464
472
|
git,
|
|
465
473
|
lock,
|
|
466
474
|
log: log2
|
|
467
|
-
}, reason, cwd, task) {
|
|
475
|
+
}, reason, cwd, { task } = {}) {
|
|
468
476
|
const branch = await git.currentBranch(cwd).catch(() => "");
|
|
469
477
|
const name = task || service.taskFor(branch);
|
|
470
478
|
if (!name) {
|
|
@@ -496,9 +504,9 @@ async function escalate({
|
|
|
496
504
|
`));
|
|
497
505
|
}
|
|
498
506
|
|
|
499
|
-
//
|
|
507
|
+
// list.ts
|
|
500
508
|
import { color as color7 } from "webappwiz/log";
|
|
501
|
-
async function
|
|
509
|
+
async function list({ service, log: log2 }, { json = false } = {}) {
|
|
502
510
|
const rows = [];
|
|
503
511
|
for (const worktree of await service.list()) {
|
|
504
512
|
rows.push(await row2(worktree));
|
|
@@ -605,8 +613,7 @@ async function merge({
|
|
|
605
613
|
`), { task, paths });
|
|
606
614
|
}
|
|
607
615
|
const gate = [config.postRewrite, config.preMerge].filter(Boolean).join(" && ");
|
|
608
|
-
const gated = gate ? await shell.run(gate, {
|
|
609
|
-
cwd: worktree.path,
|
|
616
|
+
const gated = gate ? await shell.run(gate, worktree.path, {
|
|
610
617
|
env: {
|
|
611
618
|
ARBOR_TASK: task,
|
|
612
619
|
ARBOR_WORKTREE: worktree.path
|
|
@@ -630,14 +637,25 @@ ${gated.stderr}`)
|
|
|
630
637
|
await lock.release();
|
|
631
638
|
fail("lease_lost", `the lease on '${task}' was taken by pid ${current.lease?.pid} during the merge, stopping without landing. Do not retry; another agent owns this tree.`, { task, lease: current.lease });
|
|
632
639
|
}
|
|
633
|
-
const
|
|
634
|
-
const
|
|
640
|
+
const holder = await git.worktreeFor(base);
|
|
641
|
+
const landing = holder ?? git.root;
|
|
642
|
+
const checkout = holder ? null : await git.checkout(base);
|
|
643
|
+
const merged = checkout && checkout.code !== 0 ? checkout : await git.mergeFfOnly(landing, branch);
|
|
635
644
|
if (merged.code !== 0) {
|
|
636
645
|
await lock.release();
|
|
637
646
|
await bump(worktree);
|
|
638
|
-
fail("merge_failed",
|
|
647
|
+
fail("merge_failed", [
|
|
648
|
+
`could not fast-forward ${base} in ${landing}: ${merged.stderr || merged.stdout}`,
|
|
649
|
+
...landing === git.root ? [] : [
|
|
650
|
+
"",
|
|
651
|
+
`${base} is checked out there, which is the only place it can move.`,
|
|
652
|
+
"Whoever is working in that tree has to commit or stash what collides,",
|
|
653
|
+
"then merge again."
|
|
654
|
+
]
|
|
655
|
+
].join(`
|
|
656
|
+
`), { task, base, landing });
|
|
639
657
|
}
|
|
640
|
-
const head = await git.shortHead(
|
|
658
|
+
const head = await git.shortHead(landing);
|
|
641
659
|
service.ps.cd(git.root);
|
|
642
660
|
const discarded = await current.discard();
|
|
643
661
|
await lock.release();
|
|
@@ -646,8 +664,7 @@ ${gated.stderr}`)
|
|
|
646
664
|
Run \`arbor rm ${task}\` to clean up.`, { task });
|
|
647
665
|
}
|
|
648
666
|
if (config.postMerge) {
|
|
649
|
-
const ran = await shell.run(config.postMerge, {
|
|
650
|
-
cwd: git.root,
|
|
667
|
+
const ran = await shell.run(config.postMerge, landing, {
|
|
651
668
|
env: { ARBOR_TASK: task }
|
|
652
669
|
});
|
|
653
670
|
if (ran.exitCode !== 0) {
|
|
@@ -660,7 +677,8 @@ ${ran.stderr}`)
|
|
|
660
677
|
`), { task, exitCode: ran.exitCode });
|
|
661
678
|
}
|
|
662
679
|
}
|
|
663
|
-
log2.info(`${color8.green("merged")} ${task} onto ${base} (${head})
|
|
680
|
+
log2.info(`${color8.green("merged")} ${task} onto ${base} (${head})${landing === git.root ? "" : `
|
|
681
|
+
landed in: ${landing}`}
|
|
664
682
|
worktree removed, cd ${git.root}`);
|
|
665
683
|
}
|
|
666
684
|
async function bump(worktree) {
|
|
@@ -683,7 +701,7 @@ async function path({ service, log: log2 }, task) {
|
|
|
683
701
|
}
|
|
684
702
|
const worktree = await service.find(task);
|
|
685
703
|
if (worktree.gone) {
|
|
686
|
-
fail("not_found", `no task '${task}': run \`arbor
|
|
704
|
+
fail("not_found", `no task '${task}': run \`arbor list\` to see what there is`, { task });
|
|
687
705
|
}
|
|
688
706
|
if (!worktree.exists) {
|
|
689
707
|
fail("orphaned", `'${task}' has no worktree at ${worktree.path}`, {
|
|
@@ -774,8 +792,21 @@ class Git {
|
|
|
774
792
|
checkout(branch) {
|
|
775
793
|
return this.run(this.root, "checkout", branch);
|
|
776
794
|
}
|
|
777
|
-
mergeFfOnly(branch) {
|
|
778
|
-
return this.run(
|
|
795
|
+
mergeFfOnly(cwd, branch) {
|
|
796
|
+
return this.run(cwd, "merge", "--ff-only", branch);
|
|
797
|
+
}
|
|
798
|
+
async worktreeFor(branch) {
|
|
799
|
+
const listing2 = await this.out(this.root, "worktree", "list", "--porcelain");
|
|
800
|
+
let path2 = null;
|
|
801
|
+
for (const line of listing2.split(`
|
|
802
|
+
`)) {
|
|
803
|
+
if (line.startsWith("worktree ")) {
|
|
804
|
+
path2 = line.slice("worktree ".length);
|
|
805
|
+
} else if (line === `branch refs/heads/${branch}`) {
|
|
806
|
+
return path2;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return null;
|
|
779
810
|
}
|
|
780
811
|
addWorktree(branch, path2, from) {
|
|
781
812
|
return this.run(this.root, "worktree", "add", "-b", branch, path2, from);
|
|
@@ -907,11 +938,11 @@ class Shell {
|
|
|
907
938
|
constructor(opts = {}) {
|
|
908
939
|
this.ps = opts.ps ?? new NodePs2;
|
|
909
940
|
}
|
|
910
|
-
run(command, opts) {
|
|
911
|
-
return this.ps.spawnCapture(["sh", "-c", command], opts);
|
|
941
|
+
run(command, cwd, opts = {}) {
|
|
942
|
+
return this.ps.spawnCapture(["sh", "-c", command], { ...opts, cwd });
|
|
912
943
|
}
|
|
913
|
-
stream(command, opts) {
|
|
914
|
-
return this.ps.spawn(["sh", "-c", command], opts);
|
|
944
|
+
stream(command, cwd, opts = {}) {
|
|
945
|
+
return this.ps.spawn(["sh", "-c", command], { ...opts, cwd });
|
|
915
946
|
}
|
|
916
947
|
}
|
|
917
948
|
|
|
@@ -1284,7 +1315,7 @@ async function wait({ service, log: log2 }, task, { timeout = DEFAULT_TIMEOUT, p
|
|
|
1284
1315
|
for (;; ) {
|
|
1285
1316
|
const worktree = await service.find(task);
|
|
1286
1317
|
if (worktree.status === "absent") {
|
|
1287
|
-
fail("not_found", `no task '${task}': run \`arbor
|
|
1318
|
+
fail("not_found", `no task '${task}': run \`arbor list\` to see what there is`, { task });
|
|
1288
1319
|
}
|
|
1289
1320
|
if (!RUNNING.includes(worktree.status)) {
|
|
1290
1321
|
log2.info(report2(worktree));
|
|
@@ -1311,24 +1342,19 @@ function report2(worktree) {
|
|
|
1311
1342
|
}
|
|
1312
1343
|
|
|
1313
1344
|
// arbor.ts
|
|
1314
|
-
var here = async ({
|
|
1315
|
-
service,
|
|
1316
|
-
git,
|
|
1317
|
-
ps
|
|
1318
|
-
}) => service.taskFor(await git.currentBranch(ps.cwd()).catch(() => ""));
|
|
1319
1345
|
var arbor = cli("arbor").use(exits()).use(repository());
|
|
1320
1346
|
arbor.command("add").description("start a new task: create branch task/<task>, a worktree at ../<repo>-arbor/<task> and a state record").arg("task", t.string(), { description: "task name (lowercase-with-dashes)" }).option("base", t.string(), {
|
|
1321
1347
|
default: "",
|
|
1322
|
-
description: "branch this task starts from and merges onto (default: trunk)"
|
|
1348
|
+
description: "branch this task starts from and merges onto (default: trunk); `task/<other>` stacks this task on that one and lands the work in its worktree"
|
|
1323
1349
|
}).action((opts, ctx) => ctx.journal.record("add", opts.task, () => add(ctx, opts.task, { base: opts.base || undefined })));
|
|
1324
1350
|
arbor.command("claim").description("resume an existing task: take ownership of its worktree and print its path, status and any half-finished rebase; refuses while another agent holds the lease, but takes a stale one silently, so `arbor show` first if the tree may not be abandoned").arg("task", t.string(), { description: "task name" }).action((opts, ctx) => ctx.journal.record("claim", opts.task, () => claim(ctx, opts.task)));
|
|
1325
|
-
arbor.command("merge").description("land this worktree's branch on its base (trunk unless created with --base): rebase onto it, run tests on the rebased code, fast-forward it, then discard the worktree, branch and record (linear history, never a merge commit, no flag to skip tests); requires committed work, refusing a dirty worktree").action(async (_opts, ctx) => ctx.journal.record("merge", await here(ctx), () => merge(ctx, ctx.ps.cwd())));
|
|
1351
|
+
arbor.command("merge").description("land this worktree's branch on its base (trunk unless created with --base): rebase onto it, run tests on the rebased code, fast-forward it in whichever worktree has it checked out, then discard the worktree, branch and record (linear history, never a merge commit, no flag to skip tests); requires committed work, refusing a dirty worktree").action(async (_opts, ctx) => ctx.journal.record("merge", await here(ctx), () => merge(ctx, ctx.ps.cwd())));
|
|
1326
1352
|
arbor.command("rm").description("discard a task: worktree, branch and state file; cheap and encouraged, since redoing a task against current trunk often beats a hard rebase").arg("task", t.string(), { description: "task name" }).option("force", t.boolean(), {
|
|
1327
1353
|
default: false,
|
|
1328
1354
|
description: "discard even when another agent holds the lease"
|
|
1329
1355
|
}).action((opts, ctx) => ctx.journal.record("rm", opts.task, () => rm(ctx, opts.task, { force: opts.force })));
|
|
1330
|
-
arbor.command("
|
|
1331
|
-
arbor.command("show").description("read one task without touching it: everything `
|
|
1356
|
+
arbor.command("list").description("list every task: name, status, lease (held: an agent is on it now; stale: gone quiet, normal for a task mid-edit; none), commits ahead of trunk, age").option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => list(ctx, { json: opts.json }));
|
|
1357
|
+
arbor.command("show").description("read one task without touching it: everything `list` shows for it, plus the ARBOR.md its agent left at the worktree root; takes no lease, so it cannot knock that agent off its own tree").arg("task", t.string(), { description: "task name" }).option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => show(ctx, opts.task, { json: opts.json }));
|
|
1332
1358
|
arbor.command("wait").description("block until a task stops moving: escalated, or gone (merged or removed), or broken; takes no lease, and gives up with `timeout` rather than waiting forever").arg("task", t.string(), { description: "task name" }).option("timeout-secs", t.number(), {
|
|
1333
1359
|
default: DEFAULT_TIMEOUT.secs,
|
|
1334
1360
|
description: "how long to wait before giving up"
|
|
@@ -1339,7 +1365,7 @@ arbor.command("log").description("show what has been done here recently: one lin
|
|
|
1339
1365
|
default: DEFAULT_COUNT,
|
|
1340
1366
|
description: "how many entries to show"
|
|
1341
1367
|
}).option("json", t.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => log(ctx, { count: opts.count, json: opts.json }));
|
|
1342
|
-
arbor.command("dev").description("serve `
|
|
1368
|
+
arbor.command("dev").description("serve `list`, `show` and `log` as a web page; read-only").option("port", t.number(), {
|
|
1343
1369
|
default: DEFAULT_PORT,
|
|
1344
1370
|
description: "port to listen on, or the next open one above it"
|
|
1345
1371
|
}).action((opts, ctx) => dev(ctx, { ports: devPorts(opts.port) }));
|
|
@@ -1350,8 +1376,17 @@ arbor.command("path").description("print a task's worktree path, or the main tre
|
|
|
1350
1376
|
arbor.command("escalate").description("hand this task to a human and stop: records the reason, drops the lease and leaves the worktree exactly as it is; use instead of resolving a genuine conflict badly just to finish").arg("reason", t.string(), { description: "why this needs a human" }).option("task", t.string(), {
|
|
1351
1377
|
default: "",
|
|
1352
1378
|
description: "task name, when run outside its worktree"
|
|
1353
|
-
}).action(async (opts, ctx) => ctx.journal.record("escalate", opts.task || await here(ctx), () => escalate(ctx, opts.reason, ctx.ps.cwd(),
|
|
1379
|
+
}).action(async (opts, ctx) => ctx.journal.record("escalate", opts.task || await here(ctx), () => escalate(ctx, opts.reason, ctx.ps.cwd(), {
|
|
1380
|
+
task: opts.task || undefined
|
|
1381
|
+
})));
|
|
1354
1382
|
arbor.command("retry").description("give an escalated task another mergeRetryCount merge attempts and put it back to working; the way out of `budget_exhausted` that is not rm and redo, and only from escalated, so a human has seen the tree first").arg("task", t.string(), { description: "task name" }).action((opts, ctx) => ctx.journal.record("retry", opts.task, () => retry(ctx, opts.task)));
|
|
1383
|
+
async function here({
|
|
1384
|
+
service,
|
|
1385
|
+
git,
|
|
1386
|
+
ps
|
|
1387
|
+
}) {
|
|
1388
|
+
return service.taskFor(await git.currentBranch(ps.cwd()).catch(() => ""));
|
|
1389
|
+
}
|
|
1355
1390
|
|
|
1356
1391
|
// dev/build/main.txt
|
|
1357
1392
|
var main_default = `var __create = Object.create;
|
|
@@ -18889,7 +18924,7 @@ class Feed {
|
|
|
18889
18924
|
stream.onerror = () => this.set({ offline: true });
|
|
18890
18925
|
this.load();
|
|
18891
18926
|
}
|
|
18892
|
-
|
|
18927
|
+
dispose() {
|
|
18893
18928
|
this.stream?.close();
|
|
18894
18929
|
this.stream = null;
|
|
18895
18930
|
}
|
|
@@ -19106,7 +19141,7 @@ function App() {
|
|
|
19106
19141
|
const [feed] = import_react6.useState(() => new Feed);
|
|
19107
19142
|
useDisposerEffect((disposer) => {
|
|
19108
19143
|
feed.start();
|
|
19109
|
-
disposer.
|
|
19144
|
+
disposer.use(feed);
|
|
19110
19145
|
}, [feed]);
|
|
19111
19146
|
const { snapshot, offline } = useReactive(feed, (feed2) => ({ snapshot: feed2.snapshot, offline: feed2.offline }), ["changed"]);
|
|
19112
19147
|
const [tab, setTab] = import_react6.useState("tasks");
|
package/{ls.d.ts → list.d.ts}
RENAMED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { type Logger } from "webappwiz/log";
|
|
2
2
|
import type { WorktreeService } from "./worktree-service.js";
|
|
3
|
-
export interface
|
|
3
|
+
export interface ListOptions {
|
|
4
4
|
/** Print the rows as JSON instead of a table. */
|
|
5
5
|
json?: boolean;
|
|
6
6
|
}
|
|
7
|
-
export declare function
|
|
7
|
+
export declare function list({ service, log }: {
|
|
8
8
|
service: WorktreeService;
|
|
9
9
|
log: Logger;
|
|
10
|
-
}, { json }?:
|
|
10
|
+
}, { json }?: ListOptions): Promise<void>;
|
package/merge.d.ts
CHANGED
|
@@ -8,7 +8,8 @@ import type { WorktreeService } from "./worktree-service.js";
|
|
|
8
8
|
* Lands the current worktree's branch on its base branch, trunk unless the
|
|
9
9
|
* task was created with `--base`. Never a merge commit: it rebases onto the
|
|
10
10
|
* base, runs the gate there, and fast-forwards the base with
|
|
11
|
-
* `git merge --ff-only
|
|
11
|
+
* `git merge --ff-only`, in whichever worktree has the base checked out.
|
|
12
|
+
* History stays linear.
|
|
12
13
|
*/
|
|
13
14
|
export declare function merge({ service, git, lock, shell, config, log, }: {
|
|
14
15
|
service: WorktreeService;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webappwiz/arbor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "Runs several AI coding agents on one repository at once, each in its own git worktree",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jared Johnson",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"webappwiz": "^0.0.
|
|
20
|
+
"webappwiz": "^0.0.13"
|
|
21
21
|
},
|
|
22
22
|
"main": "./index.js",
|
|
23
23
|
"types": "./index.d.ts",
|
package/shell.d.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
import { type Ps, type SpawnCaptureResult, type SpawnOptions, type SpawnResult } from "webappwiz/system";
|
|
2
|
-
/** Spawn options for a shell command. `cwd` is the worktree it runs against. */
|
|
3
|
-
export type ShellRunOptions = SpawnOptions & {
|
|
4
|
-
cwd: string;
|
|
5
|
-
};
|
|
6
2
|
/** What a `Shell` spawns through; the real process by default. */
|
|
7
3
|
export interface ShellOptions {
|
|
8
4
|
ps?: Ps;
|
|
@@ -15,7 +11,7 @@ export declare class Shell {
|
|
|
15
11
|
private readonly ps;
|
|
16
12
|
constructor(opts?: ShellOptions);
|
|
17
13
|
/** Captures output, for commands whose failure has to be reported back. */
|
|
18
|
-
run(command: string,
|
|
14
|
+
run(command: string, cwd: string, opts?: SpawnOptions): Promise<SpawnCaptureResult>;
|
|
19
15
|
/** Inherits stdio, for commands the agent should watch as they run. */
|
|
20
|
-
stream(command: string,
|
|
16
|
+
stream(command: string, cwd: string, opts?: SpawnOptions): Promise<SpawnResult>;
|
|
21
17
|
}
|
package/show.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface ShowOptions {
|
|
|
23
23
|
json?: boolean;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* One task in full: what `
|
|
26
|
+
* One task in full: what `list` shows for it, plus the `ARBOR.md` its agent
|
|
27
27
|
* left at the worktree root. Reading a tree this way takes no lease, so it
|
|
28
28
|
* cannot knock the agent driving it off its own work.
|
|
29
29
|
*/
|
|
@@ -31,13 +31,18 @@ export declare function show({ service, fs, log }: {
|
|
|
31
31
|
service: WorktreeService;
|
|
32
32
|
fs: Fs;
|
|
33
33
|
log: Logger;
|
|
34
|
-
}, task: string,
|
|
34
|
+
}, task: string, opts?: ShowOptions): Promise<void>;
|
|
35
|
+
/** Configures how {@link TaskDetails} gathers a task's info. */
|
|
36
|
+
export interface TaskDetailsOptions {
|
|
37
|
+
/** What the worktree is read through; the real filesystem by default. */
|
|
38
|
+
fs?: Fs;
|
|
39
|
+
}
|
|
35
40
|
/**
|
|
36
41
|
* Everything there is to say about one task. Split out so `dev` can render the
|
|
37
42
|
* same fields this prints, rather than assembling its own and drifting.
|
|
38
43
|
*/
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
export declare class TaskDetails {
|
|
45
|
+
private readonly fs;
|
|
46
|
+
constructor(opts?: TaskDetailsOptions);
|
|
47
|
+
get(worktree: Worktree): Promise<Details>;
|
|
42
48
|
}
|
|
43
|
-
export declare function taskDetails(worktree: Worktree, opts?: TaskDetailsOptions): Promise<Details>;
|
package/snapshot.d.ts
CHANGED
|
@@ -2,13 +2,13 @@ import { type Fs } from "webappwiz/system";
|
|
|
2
2
|
import type { Entry, Journal } from "./journal.js";
|
|
3
3
|
import { type Details } from "./show.js";
|
|
4
4
|
import type { WorktreeService } from "./worktree-service.js";
|
|
5
|
-
/** Everything one page shows: `
|
|
5
|
+
/** Everything one page shows: `list` and `show` for each task, plus `log`. */
|
|
6
6
|
export interface Snapshot {
|
|
7
7
|
tasks: Details[];
|
|
8
8
|
entries: Entry[];
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
* Reads the whole repo the way `
|
|
11
|
+
* Reads the whole repo the way `list`, `show` and `log` do. Takes no lease, so
|
|
12
12
|
* serving a page cannot knock an agent off the tree it is driving.
|
|
13
13
|
*/
|
|
14
14
|
export interface SnapshotOptions {
|