@webappwiz/arbor 0.0.30 → 0.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/add.d.ts +23 -0
- package/age.d.ts +2 -0
- package/arbor.d.ts +11 -0
- package/attachments.d.ts +36 -0
- package/claim.d.ts +11 -0
- package/config.d.ts +55 -0
- package/config.js +7 -0
- package/dev/assets.d.ts +11 -0
- package/dev.d.ts +48 -0
- package/escalate.d.ts +28 -0
- package/exit.d.ts +42 -0
- package/git.d.ts +82 -0
- package/index.d.ts +2 -0
- package/index.js +4763 -0
- package/journal.d.ts +29 -0
- package/list.d.ts +18 -0
- package/load-config.d.ts +15 -0
- package/log.d.ts +14 -0
- package/merge.d.ts +24 -0
- package/package.json +15 -32
- package/path.d.ts +13 -0
- package/plan.d.ts +83 -0
- package/remove.d.ts +19 -0
- package/repository.d.ts +25 -0
- package/retry.d.ts +17 -0
- package/shell.d.ts +17 -0
- package/show.d.ts +53 -0
- package/snapshot.d.ts +36 -0
- package/table.d.ts +5 -0
- package/todo.d.ts +202 -0
- package/todos.d.ts +75 -0
- package/wait.d.ts +22 -0
- package/worktree-service.d.ts +51 -0
- package/worktree.d.ts +95 -0
- package/AGENTS.md +0 -7
- package/add.test.ts +0 -125
- package/add.ts +0 -192
- package/age.ts +0 -11
- package/arbor.test.ts +0 -145
- package/arbor.ts +0 -429
- package/assets.d.ts +0 -9
- package/attachments.ts +0 -87
- package/build.ts +0 -98
- package/claim.test.ts +0 -89
- package/claim.ts +0 -73
- package/components.json +0 -21
- package/config.ts +0 -58
- package/dev/api.ts +0 -120
- package/dev/app.test.tsx +0 -645
- package/dev/app.tsx +0 -80
- package/dev/assets.ts +0 -16
- package/dev/build/main.txt +0 -68
- package/dev/build/shell.txt +0 -21
- package/dev/build/styles.txt +0 -2263
- package/dev/components/ui/badge.tsx +0 -51
- package/dev/components/ui/button.tsx +0 -57
- package/dev/components/ui/dialog.tsx +0 -154
- package/dev/components/ui/empty.tsx +0 -100
- package/dev/components/ui/input-group.tsx +0 -155
- package/dev/components/ui/input.tsx +0 -19
- package/dev/components/ui/progress.tsx +0 -80
- package/dev/components/ui/textarea.tsx +0 -17
- package/dev/components/ui/toast.tsx +0 -227
- package/dev/feed.ts +0 -69
- package/dev/files.tsx +0 -191
- package/dev/index.html +0 -21
- package/dev/lib/utils.ts +0 -7
- package/dev/main.tsx +0 -22
- package/dev/markdown.test.tsx +0 -133
- package/dev/markdown.tsx +0 -124
- package/dev/mentions.tsx +0 -319
- package/dev/shadcn.css +0 -649
- package/dev/styles.css +0 -107
- package/dev/tags.tsx +0 -44
- package/dev/tasks.tsx +0 -151
- package/dev/todos.tsx +0 -759
- package/dev.test.ts +0 -439
- package/dev.ts +0 -386
- package/e2e.test.ts +0 -256
- package/escalate.test.ts +0 -76
- package/escalate.ts +0 -112
- package/exit.test.ts +0 -71
- package/exit.ts +0 -74
- package/git.ts +0 -294
- package/index.ts +0 -9
- package/journal.ts +0 -89
- package/list.test.ts +0 -82
- package/list.ts +0 -137
- package/load-config.test.ts +0 -121
- package/load-config.ts +0 -62
- package/log.test.ts +0 -65
- package/log.ts +0 -40
- package/merge.test.ts +0 -332
- package/merge.ts +0 -283
- package/path.test.ts +0 -35
- package/path.ts +0 -38
- package/plan.test.ts +0 -318
- package/plan.ts +0 -302
- package/progress.test.ts +0 -54
- package/progress.ts +0 -31
- package/remove.test.ts +0 -68
- package/remove.ts +0 -88
- package/repository.ts +0 -83
- package/retry.test.ts +0 -43
- package/retry.ts +0 -48
- package/shell.ts +0 -44
- package/show.test.ts +0 -91
- package/show.ts +0 -151
- package/snapshot.test.ts +0 -33
- package/snapshot.ts +0 -84
- package/table.ts +0 -20
- package/testing.ts +0 -229
- package/todo.test.ts +0 -490
- package/todo.ts +0 -834
- package/wait.test.ts +0 -61
- package/wait.ts +0 -77
- package/worktree-service.test.ts +0 -158
- package/worktree-service.ts +0 -193
- package/worktree.ts +0 -239
package/remove.test.ts
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { add } from "./add";
|
|
3
|
-
import { remove } from "./remove";
|
|
4
|
-
import { LIVE_PID, Testing } from "./testing";
|
|
5
|
-
|
|
6
|
-
describe("remove", () => {
|
|
7
|
-
let deps: Testing;
|
|
8
|
-
|
|
9
|
-
beforeEach(async () => {
|
|
10
|
-
deps = await Testing.open();
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
afterEach(() => deps.disposeAsync());
|
|
14
|
-
|
|
15
|
-
it("removes everything and says what was thrown away", async () => {
|
|
16
|
-
await add(deps, "alpha");
|
|
17
|
-
const worktree = (await deps.service.find("alpha")).path;
|
|
18
|
-
await deps.commit(worktree, "alpha.txt", "alpha\n", "unlanded work");
|
|
19
|
-
|
|
20
|
-
await remove(deps, "alpha");
|
|
21
|
-
|
|
22
|
-
expect(deps.out()).toContain("discarded 1 commit(s)");
|
|
23
|
-
expect(await deps.fs.exists(worktree)).toBe(false);
|
|
24
|
-
expect(await deps.fs.exists(deps.service.recordPath("alpha"))).toBe(false);
|
|
25
|
-
expect(await deps.gitCli(deps.root, "branch", "--list", "task/alpha")).toBe(
|
|
26
|
-
"",
|
|
27
|
-
);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("tells 'already removed' apart from 'never existed'", async () => {
|
|
31
|
-
await add(deps, "alpha");
|
|
32
|
-
await remove(deps, "alpha");
|
|
33
|
-
|
|
34
|
-
await expect(remove(deps, "alpha")).toBail("already_removed");
|
|
35
|
-
await expect(remove(deps, "never")).toBail("not_found");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("cleans up leftovers when the worktree directory is already gone", async () => {
|
|
39
|
-
await add(deps, "alpha");
|
|
40
|
-
const worktree = (await deps.service.find("alpha")).path;
|
|
41
|
-
await deps.fs.rm(worktree, { recursive: true, force: true });
|
|
42
|
-
|
|
43
|
-
await remove(deps, "alpha");
|
|
44
|
-
|
|
45
|
-
expect(deps.out()).toContain("already gone");
|
|
46
|
-
expect(await deps.gitCli(deps.root, "branch", "--list", "task/alpha")).toBe(
|
|
47
|
-
"",
|
|
48
|
-
);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("refuses a tree another agent is driving, unless forced", async () => {
|
|
52
|
-
await add(deps, "alpha");
|
|
53
|
-
const worktree = await (await deps.service.find("alpha")).save({
|
|
54
|
-
lease: {
|
|
55
|
-
pid: LIVE_PID,
|
|
56
|
-
hostname: deps.ps.hostname,
|
|
57
|
-
heartbeatAt: new Date().toISOString(),
|
|
58
|
-
},
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
await expect(remove(deps, "alpha")).toBail("lease_held", {
|
|
62
|
-
message: "--force",
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
await remove(deps, "alpha", { force: true });
|
|
66
|
-
expect(await deps.fs.exists(worktree.path)).toBe(false);
|
|
67
|
-
});
|
|
68
|
-
});
|
package/remove.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { color, type Logger } from "webappwiz/log";
|
|
2
|
-
import { fail } from "./exit";
|
|
3
|
-
import type { Todos } from "./todo";
|
|
4
|
-
import type { WorktreeService } from "./worktree-service";
|
|
5
|
-
|
|
6
|
-
export interface RemoveOptions {
|
|
7
|
-
/** Discard the tree even when another agent holds its lease. */
|
|
8
|
-
force?: boolean;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Discards a whole task: `git worktree remove` plus the branch and the
|
|
13
|
-
* record.
|
|
14
|
-
*
|
|
15
|
-
* Throwing a task away and redoing it against current trunk is usually cheaper
|
|
16
|
-
* than a hard rebase, so this is meant to be used freely.
|
|
17
|
-
*/
|
|
18
|
-
export async function remove(
|
|
19
|
-
{
|
|
20
|
-
service,
|
|
21
|
-
log,
|
|
22
|
-
todos,
|
|
23
|
-
}: { service: WorktreeService; log: Logger; todos: Todos },
|
|
24
|
-
task: string,
|
|
25
|
-
{ force = false }: RemoveOptions = {},
|
|
26
|
-
): Promise<void> {
|
|
27
|
-
const worktree = await service.find(task);
|
|
28
|
-
|
|
29
|
-
if (worktree.gone) {
|
|
30
|
-
const removed = worktree.status === "removed";
|
|
31
|
-
fail(
|
|
32
|
-
removed ? "already_removed" : "not_found",
|
|
33
|
-
removed
|
|
34
|
-
? `'${task}' was already removed (${worktree.removedAt}), nothing left to remove`
|
|
35
|
-
: `no worktree, branch or state file named '${task}', so it never existed here`,
|
|
36
|
-
{ task },
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
if (worktree.leaseHeldByOther) {
|
|
41
|
-
if (!force) {
|
|
42
|
-
fail(
|
|
43
|
-
"lease_held",
|
|
44
|
-
`'${task}' is held by pid ${worktree.lease?.pid} on ${worktree.lease?.hostname}: pass --force to discard it anyway`,
|
|
45
|
-
{ task, lease: worktree.lease },
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
log.error(
|
|
49
|
-
color.yellow(
|
|
50
|
-
`arbor: --force discarding a tree held by pid ${worktree.lease?.pid}`,
|
|
51
|
-
),
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Counted before the branch goes, so the report can say what was lost.
|
|
56
|
-
const unlanded = worktree.hasBranch ? await worktree.commitsAhead() : 0;
|
|
57
|
-
|
|
58
|
-
const discarded = await worktree.discard();
|
|
59
|
-
if (discarded.code !== 0) {
|
|
60
|
-
fail("usage", `discarding '${task}' failed: ${discarded.stderr}`, {
|
|
61
|
-
task,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// The work was thrown away, not done, so what it took up is open again.
|
|
66
|
-
const released = await todos.takenBy(task);
|
|
67
|
-
for (const todo of released) {
|
|
68
|
-
await todo.release();
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const lines = [
|
|
72
|
-
`${color.green("removed")} ${task}`,
|
|
73
|
-
` worktree: ${worktree.exists ? worktree.path : "already gone"}`,
|
|
74
|
-
` branch: ${worktree.hasBranch ? worktree.branch : "already gone"}`,
|
|
75
|
-
` state: ${worktree.state ? "removed" : "already gone"}`,
|
|
76
|
-
];
|
|
77
|
-
if (unlanded) {
|
|
78
|
-
lines.push(
|
|
79
|
-
color.yellow(
|
|
80
|
-
` discarded ${unlanded} commit(s) that were never on ${worktree.base}`,
|
|
81
|
-
),
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
for (const todo of released) {
|
|
85
|
-
lines.push(` todo ${todo.id} is open again: ${todo.subject}`);
|
|
86
|
-
}
|
|
87
|
-
log.info(lines.join("\n"));
|
|
88
|
-
}
|
package/repository.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { dirname } from "node:path";
|
|
2
|
-
import type { Deps, Middleware } from "webappwiz/cmd";
|
|
3
|
-
import { FileLock, type Fs, type Lock } from "webappwiz/system";
|
|
4
|
-
import type { Config } from "./config";
|
|
5
|
-
import { Git } from "./git";
|
|
6
|
-
import { Journal } from "./journal";
|
|
7
|
-
import { loadConfig } from "./load-config";
|
|
8
|
-
import { Shell } from "./shell";
|
|
9
|
-
import { Todos } from "./todo";
|
|
10
|
-
import { WorktreeService } from "./worktree-service";
|
|
11
|
-
|
|
12
|
-
/** What a command gets to work with, once there is a repository to work in. */
|
|
13
|
-
export interface Repository {
|
|
14
|
-
config: Config;
|
|
15
|
-
git: Git;
|
|
16
|
-
service: WorktreeService;
|
|
17
|
-
lock: Lock;
|
|
18
|
-
shell: Shell;
|
|
19
|
-
journal: Journal;
|
|
20
|
-
todos: Todos;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Runs the action against the repository `cwd` sits in, and refuses to run it
|
|
25
|
-
* at all when that is not a repository.
|
|
26
|
-
*/
|
|
27
|
-
export function repository<C extends Deps & { fs: Fs }>(
|
|
28
|
-
at?: string,
|
|
29
|
-
): Middleware<C, C & Repository> {
|
|
30
|
-
// Nothing below here touches `Fs` or `Ps`: this is the only place they are
|
|
31
|
-
// spoken.
|
|
32
|
-
return async (ctx, next) => {
|
|
33
|
-
const { fs, ps, log } = ctx;
|
|
34
|
-
const cwd = at ?? ps.cwd();
|
|
35
|
-
const { exitCode, stdout } = await ps.spawnCapture([
|
|
36
|
-
"git",
|
|
37
|
-
"-C",
|
|
38
|
-
cwd,
|
|
39
|
-
"rev-parse",
|
|
40
|
-
"--path-format=absolute",
|
|
41
|
-
"--git-common-dir",
|
|
42
|
-
]);
|
|
43
|
-
if (exitCode !== 0) {
|
|
44
|
-
throw new Error(`not a git repository: ${cwd}`);
|
|
45
|
-
}
|
|
46
|
-
// Every worktree shares one `.git`, so state kept under it is visible to
|
|
47
|
-
// every agent and is inherently untracked.
|
|
48
|
-
const gitDir = stdout.trim();
|
|
49
|
-
const arborDir = `${gitDir}/arbor`;
|
|
50
|
-
|
|
51
|
-
const git = new Git(dirname(gitDir), { ps, fs });
|
|
52
|
-
const config = await loadConfig(dirname(gitDir), { fs, git });
|
|
53
|
-
const service = new WorktreeService(git, config, arborDir, { fs, ps });
|
|
54
|
-
await service.init();
|
|
55
|
-
|
|
56
|
-
await next({
|
|
57
|
-
...ctx,
|
|
58
|
-
config,
|
|
59
|
-
git,
|
|
60
|
-
service,
|
|
61
|
-
lock: new FileLock(`${arborDir}/merge.lock`, {
|
|
62
|
-
fs,
|
|
63
|
-
ps,
|
|
64
|
-
log,
|
|
65
|
-
stalenessMs: config.leaseStalenessMs,
|
|
66
|
-
}),
|
|
67
|
-
shell: new Shell({ ps }),
|
|
68
|
-
journal: new Journal(`${arborDir}/log.jsonl`, config.logCapacity, {
|
|
69
|
-
fs,
|
|
70
|
-
}),
|
|
71
|
-
todos: new Todos(
|
|
72
|
-
`${arborDir}/todos`,
|
|
73
|
-
new FileLock(`${arborDir}/todos.lock`, {
|
|
74
|
-
fs,
|
|
75
|
-
ps,
|
|
76
|
-
log,
|
|
77
|
-
stalenessMs: config.leaseStalenessMs,
|
|
78
|
-
}),
|
|
79
|
-
{ fs },
|
|
80
|
-
),
|
|
81
|
-
});
|
|
82
|
-
};
|
|
83
|
-
}
|
package/retry.test.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { add } from "./add";
|
|
3
|
-
import { retry } from "./retry";
|
|
4
|
-
import { Testing } from "./testing";
|
|
5
|
-
|
|
6
|
-
describe("retry", () => {
|
|
7
|
-
let deps: Testing;
|
|
8
|
-
|
|
9
|
-
beforeEach(async () => {
|
|
10
|
-
deps = await Testing.open();
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
afterEach(() => deps.disposeAsync());
|
|
14
|
-
|
|
15
|
-
it("puts an escalated task back to working with a fresh budget", async () => {
|
|
16
|
-
await add(deps, "alpha");
|
|
17
|
-
const spent = await deps.service.find("alpha");
|
|
18
|
-
await spent.save({
|
|
19
|
-
status: "escalated",
|
|
20
|
-
mergeAttempts: deps.config.mergeRetryCount,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
await retry(deps, "alpha");
|
|
24
|
-
|
|
25
|
-
expect((await deps.service.find("alpha")).state).toMatchObject({
|
|
26
|
-
status: "working",
|
|
27
|
-
mergeAttempts: 0,
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("refuses a task nobody has escalated", async () => {
|
|
32
|
-
await add(deps, "alpha");
|
|
33
|
-
|
|
34
|
-
await expect(retry(deps, "alpha")).toBail("usage", {
|
|
35
|
-
message: "not escalated",
|
|
36
|
-
});
|
|
37
|
-
expect((await deps.service.find("alpha")).state?.status).toBe("working");
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("refuses a name with no task", async () => {
|
|
41
|
-
await expect(retry(deps, "nope")).toBail("not_found");
|
|
42
|
-
});
|
|
43
|
-
});
|
package/retry.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { color, type Logger } from "webappwiz/log";
|
|
2
|
-
import type { Config } from "./config";
|
|
3
|
-
import { fail } from "./exit";
|
|
4
|
-
import type { WorktreeService } from "./worktree-service";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The way back from `budget_exhausted`. A task that has spent its merge
|
|
8
|
-
* attempts gets another `mergeRetryCount` of them, instead of `remove` and redo.
|
|
9
|
-
*
|
|
10
|
-
* Only from `escalated`, and deliberately: the budget's whole job is to make an
|
|
11
|
-
* agent stop and hand the task over. An agent that could grant itself more
|
|
12
|
-
* would be back to grinding against a moving trunk forever, so the price of a
|
|
13
|
-
* fresh budget is that a human has looked at the tree first.
|
|
14
|
-
*/
|
|
15
|
-
export async function retry(
|
|
16
|
-
{
|
|
17
|
-
service,
|
|
18
|
-
config,
|
|
19
|
-
log,
|
|
20
|
-
}: { service: WorktreeService; config: Config; log: Logger },
|
|
21
|
-
task: string,
|
|
22
|
-
): Promise<void> {
|
|
23
|
-
const found = await service.find(task);
|
|
24
|
-
if (found.gone) {
|
|
25
|
-
fail("not_found", `no task '${task}'`, { task });
|
|
26
|
-
}
|
|
27
|
-
if (found.status !== "escalated") {
|
|
28
|
-
fail(
|
|
29
|
-
"usage",
|
|
30
|
-
`'${task}' is ${found.status}, not escalated: only a task handed to a human can be granted more merge attempts`,
|
|
31
|
-
{ task, status: found.status },
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// The lease stays as escalate left it: whoever picks the task up takes it
|
|
36
|
-
// with `claim`, the same as any other resume.
|
|
37
|
-
const worktree = await found.save({ status: "working", mergeAttempts: 0 });
|
|
38
|
-
|
|
39
|
-
log.info(
|
|
40
|
-
[
|
|
41
|
-
`${color.green("retry")} ${worktree.task}`,
|
|
42
|
-
` attempts: 0 of ${config.mergeRetryCount}`,
|
|
43
|
-
` status: working`,
|
|
44
|
-
"",
|
|
45
|
-
`Run \`arbor claim ${worktree.task}\` to pick it up.`,
|
|
46
|
-
].join("\n"),
|
|
47
|
-
);
|
|
48
|
-
}
|
package/shell.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
NodePs,
|
|
3
|
-
type Ps,
|
|
4
|
-
type SpawnCaptureResult,
|
|
5
|
-
type SpawnOptions,
|
|
6
|
-
type SpawnResult,
|
|
7
|
-
} from "webappwiz/system";
|
|
8
|
-
|
|
9
|
-
/** What a `Shell` spawns through; the real process by default. */
|
|
10
|
-
export interface ShellOptions {
|
|
11
|
-
ps?: Ps;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Runs the commands a repo configures (the lifecycle hooks) as shell strings
|
|
16
|
-
* rather than argv, since that is how a repo writes them.
|
|
17
|
-
*/
|
|
18
|
-
export class Shell {
|
|
19
|
-
private readonly ps: Ps;
|
|
20
|
-
|
|
21
|
-
// This is the whole of what `merge` and `add` are allowed to do to the
|
|
22
|
-
// process. Handing them a `Ps` instead would hand them `exit` and `cd` too.
|
|
23
|
-
constructor(opts: ShellOptions = {}) {
|
|
24
|
-
this.ps = opts.ps ?? new NodePs();
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Captures output, for commands whose failure has to be reported back. */
|
|
28
|
-
run(
|
|
29
|
-
command: string,
|
|
30
|
-
cwd: string,
|
|
31
|
-
opts: SpawnOptions = {},
|
|
32
|
-
): Promise<SpawnCaptureResult> {
|
|
33
|
-
return this.ps.spawnCapture(["sh", "-c", command], { ...opts, cwd });
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Inherits stdio, for commands the agent should watch as they run. */
|
|
37
|
-
stream(
|
|
38
|
-
command: string,
|
|
39
|
-
cwd: string,
|
|
40
|
-
opts: SpawnOptions = {},
|
|
41
|
-
): Promise<SpawnResult> {
|
|
42
|
-
return this.ps.spawn(["sh", "-c", command], { ...opts, cwd });
|
|
43
|
-
}
|
|
44
|
-
}
|
package/show.test.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { add } from "./add";
|
|
3
|
-
import { show } from "./show";
|
|
4
|
-
import { Testing } from "./testing";
|
|
5
|
-
|
|
6
|
-
describe("show", () => {
|
|
7
|
-
let deps: Testing;
|
|
8
|
-
|
|
9
|
-
beforeEach(async () => {
|
|
10
|
-
deps = await Testing.open();
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
afterEach(() => deps.disposeAsync());
|
|
14
|
-
|
|
15
|
-
it("reports a task and prints the ARBOR.md left in its worktree", async () => {
|
|
16
|
-
await add(deps, "alpha");
|
|
17
|
-
const alpha = (await deps.service.find("alpha")).path;
|
|
18
|
-
await deps.fs.write(
|
|
19
|
-
`${alpha}/ARBOR.md`,
|
|
20
|
-
"# alpha\n\n## Next\n- [ ] the rest\n",
|
|
21
|
-
);
|
|
22
|
-
deps.log.clear();
|
|
23
|
-
|
|
24
|
-
await show(deps, "alpha");
|
|
25
|
-
|
|
26
|
-
expect(deps.out()).toContain("task/alpha");
|
|
27
|
-
expect(deps.out()).toContain("working");
|
|
28
|
-
expect(deps.out()).toContain("- [ ] the rest");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("says how an ARBOR.md departs from the shape the skill prescribes", async () => {
|
|
32
|
-
await add(deps, "alpha");
|
|
33
|
-
const alpha = (await deps.service.find("alpha")).path;
|
|
34
|
-
await deps.fs.write(
|
|
35
|
-
`${alpha}/ARBOR.md`,
|
|
36
|
-
"# something else\n\n## Goal\nland it\n",
|
|
37
|
-
);
|
|
38
|
-
deps.log.clear();
|
|
39
|
-
|
|
40
|
-
await show(deps, "alpha");
|
|
41
|
-
|
|
42
|
-
expect(deps.out()).toContain('should be "# alpha"');
|
|
43
|
-
expect(deps.out()).toContain("no ## Next section");
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("says the ARBOR.md is missing rather than staying silent", async () => {
|
|
47
|
-
await add(deps, "alpha");
|
|
48
|
-
const alpha = (await deps.service.find("alpha")).path;
|
|
49
|
-
await deps.fs.rm(`${alpha}/ARBOR.md`);
|
|
50
|
-
deps.log.clear();
|
|
51
|
-
|
|
52
|
-
await show(deps, "alpha");
|
|
53
|
-
|
|
54
|
-
expect(deps.out()).toContain("no ARBOR.md");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("carries the task's fields and its ARBOR.md as JSON", async () => {
|
|
58
|
-
await add(deps, "alpha");
|
|
59
|
-
const alpha = (await deps.service.find("alpha")).path;
|
|
60
|
-
await deps.fs.write(`${alpha}/ARBOR.md`, "# alpha\n");
|
|
61
|
-
deps.log.clear();
|
|
62
|
-
|
|
63
|
-
await show(deps, "alpha", { json: true });
|
|
64
|
-
|
|
65
|
-
expect(JSON.parse(deps.out())).toMatchObject({
|
|
66
|
-
task: "alpha",
|
|
67
|
-
status: "working",
|
|
68
|
-
branch: "task/alpha",
|
|
69
|
-
ahead: 0,
|
|
70
|
-
plan: "# alpha\n",
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("refuses a task that was never created", async () => {
|
|
75
|
-
await expect(show(deps, "ghost")).toBail("not_found");
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("still describes a task whose worktree is gone", async () => {
|
|
79
|
-
await add(deps, "alpha");
|
|
80
|
-
await deps.fs.rm((await deps.service.find("alpha")).path, {
|
|
81
|
-
recursive: true,
|
|
82
|
-
force: true,
|
|
83
|
-
});
|
|
84
|
-
deps.log.clear();
|
|
85
|
-
|
|
86
|
-
await show(deps, "alpha");
|
|
87
|
-
|
|
88
|
-
expect(deps.out()).toContain("orphaned");
|
|
89
|
-
expect(deps.out()).not.toContain("no ARBOR.md");
|
|
90
|
-
});
|
|
91
|
-
});
|
package/show.ts
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
import { color, type Logger } from "webappwiz/log";
|
|
2
|
-
import { type Fs, NodeFs } from "webappwiz/system";
|
|
3
|
-
import { age } from "./age";
|
|
4
|
-
import { fail } from "./exit";
|
|
5
|
-
import { checkPlan, PLAN_FILE } from "./plan";
|
|
6
|
-
import type { Worktree } from "./worktree";
|
|
7
|
-
import type { WorktreeService } from "./worktree-service";
|
|
8
|
-
|
|
9
|
-
export interface Details {
|
|
10
|
-
task: string;
|
|
11
|
-
status: string;
|
|
12
|
-
branch: string;
|
|
13
|
-
base: string;
|
|
14
|
-
worktree: string;
|
|
15
|
-
lease: "held" | "stale" | "none";
|
|
16
|
-
ahead: number | null;
|
|
17
|
-
added: number | null;
|
|
18
|
-
removed: number | null;
|
|
19
|
-
age: string | null;
|
|
20
|
-
escalation: string | null;
|
|
21
|
-
/**
|
|
22
|
-
* While escalated to be approved, the question that asks for it, `4`;
|
|
23
|
-
* null otherwise.
|
|
24
|
-
*/
|
|
25
|
-
review: string | null;
|
|
26
|
-
plan: string | null;
|
|
27
|
-
/** How the `ARBOR.md` departs from the shape the skill prescribes. */
|
|
28
|
-
planProblems: string[];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface ShowOptions {
|
|
32
|
-
/** Print the details as JSON instead of prose. */
|
|
33
|
-
json?: boolean;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* One task in full: what `list` shows for it, plus the `ARBOR.md` its agent
|
|
38
|
-
* left at the worktree root. Reading a tree this way takes no lease, so it
|
|
39
|
-
* cannot knock the agent driving it off its own work.
|
|
40
|
-
*/
|
|
41
|
-
export async function show(
|
|
42
|
-
{ service, fs, log }: { service: WorktreeService; fs: Fs; log: Logger },
|
|
43
|
-
task: string,
|
|
44
|
-
opts: ShowOptions = {},
|
|
45
|
-
): Promise<void> {
|
|
46
|
-
const worktree = await service.find(task);
|
|
47
|
-
if (worktree.gone) {
|
|
48
|
-
fail(
|
|
49
|
-
"not_found",
|
|
50
|
-
`no task '${task}': run \`arbor list\` to see what there is`,
|
|
51
|
-
{ task },
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
const details = await new TaskDetails({ fs }).get(worktree);
|
|
55
|
-
|
|
56
|
-
if (opts.json ?? false) {
|
|
57
|
-
log.info(JSON.stringify(details, null, "\t"));
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
log.info(report(details));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Configures how {@link TaskDetails} gathers a task's info. */
|
|
64
|
-
export interface TaskDetailsOptions {
|
|
65
|
-
/** What the worktree is read through; the real filesystem by default. */
|
|
66
|
-
fs?: Fs;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Everything there is to say about one task. Split out so `dev` can render the
|
|
71
|
-
* same fields this prints, rather than assembling its own and drifting.
|
|
72
|
-
*/
|
|
73
|
-
export class TaskDetails {
|
|
74
|
-
private readonly fs: Fs;
|
|
75
|
-
|
|
76
|
-
constructor(opts: TaskDetailsOptions = {}) {
|
|
77
|
-
this.fs = opts.fs ?? new NodeFs();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async get(worktree: Worktree): Promise<Details> {
|
|
81
|
-
const { state } = worktree;
|
|
82
|
-
// A record whose worktree or branch is gone is still worth showing: the
|
|
83
|
-
// status names what is wrong, and asking git about a branch that is not
|
|
84
|
-
// there would only fill the fields with nulls.
|
|
85
|
-
const stat = worktree.hasBranch ? await worktree.diffStat() : null;
|
|
86
|
-
const plan = worktree.exists
|
|
87
|
-
? await this.fs.read(`${worktree.path}/${PLAN_FILE}`).catch(() => null)
|
|
88
|
-
: null;
|
|
89
|
-
return {
|
|
90
|
-
task: worktree.task,
|
|
91
|
-
status: worktree.status,
|
|
92
|
-
branch: worktree.branch,
|
|
93
|
-
base: worktree.base,
|
|
94
|
-
worktree: worktree.path,
|
|
95
|
-
lease: worktree.leaseStatus,
|
|
96
|
-
ahead: worktree.hasBranch ? await worktree.commitsAhead() : null,
|
|
97
|
-
added: stat?.added ?? null,
|
|
98
|
-
removed: stat?.removed ?? null,
|
|
99
|
-
age: state ? age(state.createdAt) : null,
|
|
100
|
-
escalation: state?.escalations?.at(-1)?.reason ?? null,
|
|
101
|
-
review:
|
|
102
|
-
state?.status === "escalated"
|
|
103
|
-
? (state.escalations?.at(-1)?.review ?? null)
|
|
104
|
-
: null,
|
|
105
|
-
plan,
|
|
106
|
-
planProblems:
|
|
107
|
-
plan === null
|
|
108
|
-
? []
|
|
109
|
-
: checkPlan(plan, {
|
|
110
|
-
task: worktree.task,
|
|
111
|
-
escalated: worktree.status === "escalated",
|
|
112
|
-
}),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function report(details: Details): string {
|
|
118
|
-
const lines = [
|
|
119
|
-
`${color.bold(details.task)} ${details.status}`,
|
|
120
|
-
` branch: ${details.branch}`,
|
|
121
|
-
` base: ${details.base}`,
|
|
122
|
-
` worktree: ${details.worktree}`,
|
|
123
|
-
` lease: ${details.lease}`,
|
|
124
|
-
` ahead: ${details.ahead === null ? "?" : details.ahead} ${diff(details)}`,
|
|
125
|
-
` age: ${details.age ?? "?"}`,
|
|
126
|
-
];
|
|
127
|
-
if (details.escalation !== null) {
|
|
128
|
-
lines.push(` ${color.yellow(`escalated: ${details.escalation}`)}`);
|
|
129
|
-
}
|
|
130
|
-
if (details.plan !== null) {
|
|
131
|
-
lines.push("", color.bold(PLAN_FILE), details.plan.trimEnd());
|
|
132
|
-
for (const problem of details.planProblems) {
|
|
133
|
-
lines.push(color.yellow(` ${problem}`));
|
|
134
|
-
}
|
|
135
|
-
} else if (details.status !== "orphaned") {
|
|
136
|
-
lines.push(
|
|
137
|
-
"",
|
|
138
|
-
color.yellow(
|
|
139
|
-
`no ${PLAN_FILE}: whoever picks this up starts from the diff`,
|
|
140
|
-
),
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
return lines.join("\n");
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function diff(details: Details): string {
|
|
147
|
-
if (details.added === null || details.removed === null) {
|
|
148
|
-
return "?";
|
|
149
|
-
}
|
|
150
|
-
return `${color.green(`+${details.added}`)} ${color.red(`-${details.removed}`)}`;
|
|
151
|
-
}
|
package/snapshot.test.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { basename, dirname } from "node:path";
|
|
3
|
-
import { snapshot } from "./snapshot";
|
|
4
|
-
import { Testing } from "./testing";
|
|
5
|
-
|
|
6
|
-
describe("snapshot", () => {
|
|
7
|
-
let deps: Testing;
|
|
8
|
-
|
|
9
|
-
beforeEach(async () => {
|
|
10
|
-
deps = await Testing.open();
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
afterEach(() => deps.disposeAsync());
|
|
14
|
-
|
|
15
|
-
it("writes a repo under home with ~", async () => {
|
|
16
|
-
const { path } = await snapshot({ ...deps, home: dirname(deps.root) });
|
|
17
|
-
|
|
18
|
-
expect(path).toBe(`~/${basename(deps.root)}`);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("writes home itself as ~", async () => {
|
|
22
|
-
const { path } = await snapshot({ ...deps, home: deps.root });
|
|
23
|
-
|
|
24
|
-
expect(path).toBe("~");
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("leaves a repo outside home, or one only sharing its prefix, whole", async () => {
|
|
28
|
-
const { path } = await snapshot({ ...deps, home: deps.root.slice(0, -1) });
|
|
29
|
-
|
|
30
|
-
expect(path).toBe(deps.root);
|
|
31
|
-
expect((await snapshot(deps)).path).toBe(deps.root);
|
|
32
|
-
});
|
|
33
|
-
});
|