@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/journal.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { type Fs, NodeFs } from "webappwiz/system";
|
|
2
|
-
import { Exit } from "./exit";
|
|
3
|
-
|
|
4
|
-
/** One thing that was done to a task, and how it went. */
|
|
5
|
-
export interface Entry {
|
|
6
|
-
at: string;
|
|
7
|
-
action: string;
|
|
8
|
-
/** The task it was about, or null when the command names none. */
|
|
9
|
-
task: string | null;
|
|
10
|
-
/** The refusal reason, or null when the command succeeded. */
|
|
11
|
-
reason: string | null;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* What has been done in this repo, oldest first. Entries outlive their tasks:
|
|
16
|
-
* `merge` and `remove` take the record with them, so this is the only thing that
|
|
17
|
-
* remembers a task existed at all.
|
|
18
|
-
*/
|
|
19
|
-
/** What a `Journal` is stored through; the real filesystem by default. */
|
|
20
|
-
export interface JournalOptions {
|
|
21
|
-
fs?: Fs;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export class Journal {
|
|
25
|
-
private readonly fs: Fs;
|
|
26
|
-
|
|
27
|
-
constructor(
|
|
28
|
-
private readonly path: string,
|
|
29
|
-
private readonly capacity: number,
|
|
30
|
-
opts: JournalOptions = {},
|
|
31
|
-
) {
|
|
32
|
-
this.fs = opts.fs ?? new NodeFs();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async record<T>(
|
|
36
|
-
action: string,
|
|
37
|
-
task: string | null,
|
|
38
|
-
run: () => Promise<T>,
|
|
39
|
-
): Promise<T> {
|
|
40
|
-
try {
|
|
41
|
-
const out = await run();
|
|
42
|
-
await this.append(action, task, null);
|
|
43
|
-
return out;
|
|
44
|
-
} catch (entry) {
|
|
45
|
-
await this.append(
|
|
46
|
-
action,
|
|
47
|
-
task,
|
|
48
|
-
entry instanceof Exit ? entry.reason : "error",
|
|
49
|
-
);
|
|
50
|
-
throw entry;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async tail(count: number): Promise<Entry[]> {
|
|
55
|
-
// slice(-0) is slice(0), which would hand back everything.
|
|
56
|
-
return count <= 0 ? [] : (await this.entries()).slice(-count);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
private async append(
|
|
60
|
-
action: string,
|
|
61
|
-
task: string | null,
|
|
62
|
-
reason: string | null,
|
|
63
|
-
): Promise<void> {
|
|
64
|
-
// Rewriting the whole file is fine at a few hundred lines, and two agents
|
|
65
|
-
// finishing at the same instant lose an entry rather than corrupt one.
|
|
66
|
-
// Switch to an append + occasional compaction if `Fs` ever grows an append.
|
|
67
|
-
const entry: Entry = { at: new Date().toISOString(), action, task, reason };
|
|
68
|
-
const kept = [...(await this.entries()), entry].slice(-this.capacity);
|
|
69
|
-
await this.fs.write(
|
|
70
|
-
this.path,
|
|
71
|
-
kept.map((entry) => `${JSON.stringify(entry)}\n`).join(""),
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
private async entries(): Promise<Entry[]> {
|
|
76
|
-
const raw = await this.fs.read(this.path).catch(() => "");
|
|
77
|
-
return raw
|
|
78
|
-
.split("\n")
|
|
79
|
-
.filter(Boolean)
|
|
80
|
-
.flatMap((line) => {
|
|
81
|
-
try {
|
|
82
|
-
// A torn line loses one entry, not the whole log.
|
|
83
|
-
return [JSON.parse(line) as Entry];
|
|
84
|
-
} catch {
|
|
85
|
-
return [];
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
}
|
package/list.test.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { color } from "webappwiz/log";
|
|
3
|
-
import { add } from "./add";
|
|
4
|
-
import { list } from "./list";
|
|
5
|
-
import { Testing } from "./testing";
|
|
6
|
-
|
|
7
|
-
describe("list", () => {
|
|
8
|
-
let deps: Testing;
|
|
9
|
-
|
|
10
|
-
beforeEach(async () => {
|
|
11
|
-
deps = await Testing.open();
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
afterEach(() => deps.disposeAsync());
|
|
15
|
-
|
|
16
|
-
it("lists tasks, survives a corrupt record, and flags orphans", async () => {
|
|
17
|
-
await add(deps, "alpha");
|
|
18
|
-
await add(deps, "beta");
|
|
19
|
-
await deps.fs.write(deps.service.recordPath("broken"), "{not json");
|
|
20
|
-
const beta = (await deps.service.find("beta")).path;
|
|
21
|
-
await deps.fs.rm(beta, { recursive: true, force: true });
|
|
22
|
-
|
|
23
|
-
await list(deps);
|
|
24
|
-
|
|
25
|
-
expect(deps.out()).toContain("alpha");
|
|
26
|
-
expect(deps.out()).toContain("unknown"); // the corrupt row, not a crash
|
|
27
|
-
expect(deps.out()).toContain("orphaned");
|
|
28
|
-
expect(color.strip(deps.out())).toContain("+0 -0");
|
|
29
|
-
|
|
30
|
-
deps.log.clear();
|
|
31
|
-
await list(deps, { json: true });
|
|
32
|
-
const rows = JSON.parse(deps.out());
|
|
33
|
-
expect(rows.map((fixture: { task: string }) => fixture.task)).toEqual([
|
|
34
|
-
"alpha",
|
|
35
|
-
"beta",
|
|
36
|
-
"broken",
|
|
37
|
-
]);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("names each task's changed and planned files with --files", async () => {
|
|
41
|
-
await add(deps, "alpha");
|
|
42
|
-
const worktree = (await deps.service.find("alpha")).path;
|
|
43
|
-
await deps.commit(worktree, "committed.txt", "a\n", "add committed");
|
|
44
|
-
await deps.fs.write(`${worktree}/untracked.txt`, "b\n");
|
|
45
|
-
await deps.fs.write(
|
|
46
|
-
`${worktree}/ARBOR.md`,
|
|
47
|
-
"# alpha\n\n## Files\n\n- `committed.txt`\n- planned.txt\n\nprose, not a path\n",
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
deps.log.clear();
|
|
51
|
-
await list(deps, { files: true });
|
|
52
|
-
|
|
53
|
-
const out = color.strip(deps.out());
|
|
54
|
-
expect(out).toContain("changed committed.txt");
|
|
55
|
-
expect(out).toContain("changed untracked.txt");
|
|
56
|
-
expect(out).toContain("planned planned.txt");
|
|
57
|
-
expect(out).not.toContain("planned committed.txt");
|
|
58
|
-
expect(out).not.toContain("ARBOR.md"); // excluded, so never a change
|
|
59
|
-
|
|
60
|
-
deps.log.clear();
|
|
61
|
-
await list(deps, { json: true, files: true });
|
|
62
|
-
const [row] = JSON.parse(deps.out());
|
|
63
|
-
expect(row.changed).toEqual(["committed.txt", "untracked.txt"]);
|
|
64
|
-
expect(row.planned).toEqual(["committed.txt", "planned.txt"]);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("leaves files out unless asked", async () => {
|
|
68
|
-
await add(deps, "alpha");
|
|
69
|
-
deps.log.clear();
|
|
70
|
-
|
|
71
|
-
await list(deps, { json: true });
|
|
72
|
-
|
|
73
|
-
const [row] = JSON.parse(deps.out());
|
|
74
|
-
expect(row).not.toHaveProperty("changed");
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("says so plainly when there is nothing to list", async () => {
|
|
78
|
-
await list(deps);
|
|
79
|
-
|
|
80
|
-
expect(deps.out()).toContain("no tasks");
|
|
81
|
-
});
|
|
82
|
-
});
|
package/list.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import { color, type Logger } from "webappwiz/log";
|
|
2
|
-
import type { Fs } from "webappwiz/system";
|
|
3
|
-
import { age } from "./age";
|
|
4
|
-
import { PLAN_FILE, plannedFiles } from "./plan";
|
|
5
|
-
import { table } from "./table";
|
|
6
|
-
import type { Worktree } from "./worktree";
|
|
7
|
-
import type { WorktreeService } from "./worktree-service";
|
|
8
|
-
|
|
9
|
-
interface Row {
|
|
10
|
-
task: string;
|
|
11
|
-
status: string;
|
|
12
|
-
lease: "held" | "stale" | "none";
|
|
13
|
-
ahead: number | null;
|
|
14
|
-
added: number | null;
|
|
15
|
-
removed: number | null;
|
|
16
|
-
age: string;
|
|
17
|
-
worktree: string | null;
|
|
18
|
-
/** Paths the task has touched, committed or not; only with `files`. */
|
|
19
|
-
changed?: string[] | null;
|
|
20
|
-
/** Paths its `ARBOR.md` plans to touch; only with `files`. */
|
|
21
|
-
planned?: string[];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface ListOptions {
|
|
25
|
-
/** Print the rows as JSON instead of a table. */
|
|
26
|
-
json?: boolean;
|
|
27
|
-
/**
|
|
28
|
-
* Add each task's changed and planned files, which is what an agent checks
|
|
29
|
-
* its own plan against before starting. Off by default: it costs git calls
|
|
30
|
-
* and a file read per task.
|
|
31
|
-
*/
|
|
32
|
-
files?: boolean;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function list(
|
|
36
|
-
{ service, log, fs }: { service: WorktreeService; log: Logger; fs: Fs },
|
|
37
|
-
{ json = false, files = false }: ListOptions = {},
|
|
38
|
-
): Promise<void> {
|
|
39
|
-
const rows: Row[] = [];
|
|
40
|
-
for (const worktree of await service.list()) {
|
|
41
|
-
const base = await row(worktree);
|
|
42
|
-
rows.push(files ? { ...base, ...(await paths(worktree, fs)) } : base);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (json) {
|
|
46
|
-
log.info(JSON.stringify(rows, null, "\t"));
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
if (rows.length === 0) {
|
|
50
|
-
log.info("no tasks: run `arbor add <task>` to start one");
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
log.info(listing(rows));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function row(worktree: Worktree): Promise<Row> {
|
|
57
|
-
const { state } = worktree;
|
|
58
|
-
const diff = state ? await worktree.diffStat() : null;
|
|
59
|
-
return {
|
|
60
|
-
task: worktree.task,
|
|
61
|
-
// One unreadable record shows as `unknown` instead of taking down the
|
|
62
|
-
// whole listing.
|
|
63
|
-
status: worktree.status,
|
|
64
|
-
lease: worktree.leaseStatus,
|
|
65
|
-
ahead: state ? await worktree.commitsAhead() : null,
|
|
66
|
-
added: diff?.added ?? null,
|
|
67
|
-
removed: diff?.removed ?? null,
|
|
68
|
-
age: state ? age(state.createdAt) : "?",
|
|
69
|
-
worktree: state ? worktree.path : null,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function paths(
|
|
74
|
-
worktree: Worktree,
|
|
75
|
-
fs: Fs,
|
|
76
|
-
): Promise<Pick<Row, "changed" | "planned">> {
|
|
77
|
-
const plan = worktree.exists
|
|
78
|
-
? await fs.read(`${worktree.path}/${PLAN_FILE}`).catch(() => null)
|
|
79
|
-
: null;
|
|
80
|
-
return {
|
|
81
|
-
changed: worktree.hasBranch ? await worktree.changedFiles() : null,
|
|
82
|
-
planned: plan === null ? [] : plannedFiles(plan),
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function diff(row: Row): string {
|
|
87
|
-
if (row.added === null || row.removed === null) {
|
|
88
|
-
return "?";
|
|
89
|
-
}
|
|
90
|
-
return `${color.green(`+${row.added}`)} ${color.red(`-${row.removed}`)}`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function listing(rows: Row[]): string {
|
|
94
|
-
const cells = rows.map((row) => [
|
|
95
|
-
row.task,
|
|
96
|
-
row.status,
|
|
97
|
-
row.lease,
|
|
98
|
-
row.ahead === null ? "?" : String(row.ahead),
|
|
99
|
-
diff(row),
|
|
100
|
-
row.age,
|
|
101
|
-
]);
|
|
102
|
-
const out = [
|
|
103
|
-
table(["TASK", "STATUS", "LEASE", "AHEAD", "DIFF", "AGE"], cells),
|
|
104
|
-
];
|
|
105
|
-
for (const row of rows) {
|
|
106
|
-
if (row.changed !== undefined) {
|
|
107
|
-
out.push("", ...files(row));
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
const orphaned = rows.filter((row) => row.status === "orphaned");
|
|
111
|
-
if (orphaned.length > 0) {
|
|
112
|
-
out.push(
|
|
113
|
-
"",
|
|
114
|
-
color.yellow(
|
|
115
|
-
`${orphaned.length} orphaned record(s): run \`arbor remove ${orphaned[0]?.task}\``,
|
|
116
|
-
),
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
return out.join("\n");
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* One task's paths, each once: `changed` when the task has touched it,
|
|
124
|
-
* `planned` when only its `ARBOR.md` names it so far.
|
|
125
|
-
*/
|
|
126
|
-
function files(row: Row): string[] {
|
|
127
|
-
const changed = row.changed ?? [];
|
|
128
|
-
const planned = (row.planned ?? []).filter((path) => !changed.includes(path));
|
|
129
|
-
const lines = [
|
|
130
|
-
...changed.map((path) => ` changed ${path}`),
|
|
131
|
-
...planned.map((path) => ` ${color.dim("planned")} ${path}`),
|
|
132
|
-
];
|
|
133
|
-
return [
|
|
134
|
-
color.bold(row.task),
|
|
135
|
-
...(lines.length > 0 ? lines : [color.dim(" no files yet")]),
|
|
136
|
-
];
|
|
137
|
-
}
|
package/load-config.test.ts
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { NodeFs } from "webappwiz/system";
|
|
6
|
-
import { FakePs } from "webappwiz/system/testing";
|
|
7
|
-
import { Git } from "./git";
|
|
8
|
-
import { loadConfig } from "./load-config";
|
|
9
|
-
import { Testing } from "./testing";
|
|
10
|
-
|
|
11
|
-
describe("loadConfig", () => {
|
|
12
|
-
const fs = new NodeFs();
|
|
13
|
-
let root: string;
|
|
14
|
-
let git: Git;
|
|
15
|
-
|
|
16
|
-
beforeEach(async () => {
|
|
17
|
-
root = await mkdtemp(join(tmpdir(), "arbor-config-"));
|
|
18
|
-
const ps = new FakePs();
|
|
19
|
-
// Stands in for a repo with no `origin/HEAD` to read.
|
|
20
|
-
ps.simulate(() => Promise.resolve(1));
|
|
21
|
-
git = new Git(root, { ps });
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
afterEach(async () => {
|
|
25
|
-
await rm(root, { recursive: true, force: true });
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("defaults the worktree root to a sibling of the repo, named after it", async () => {
|
|
29
|
-
const config = await loadConfig(root, { fs, git });
|
|
30
|
-
|
|
31
|
-
expect(config.worktreeRoot).toBe(`${root}-arbor`);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("leaves every hook unset, so a repo runs only what it asks for", async () => {
|
|
35
|
-
const config = await loadConfig(root, { fs, git });
|
|
36
|
-
|
|
37
|
-
expect(config.postCheckout).toBeNull();
|
|
38
|
-
expect(config.postRewrite).toBeNull();
|
|
39
|
-
expect(config.preMerge).toBeNull();
|
|
40
|
-
expect(config.postMerge).toBeNull();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("overrides only the defaults arbor.config.ts names", async () => {
|
|
44
|
-
await fs.write(
|
|
45
|
-
join(root, "arbor.config.ts"),
|
|
46
|
-
`export default { trunk: "trunk", mergeRetryCount: 7 };\n`,
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
const config = await loadConfig(root, { fs, git });
|
|
50
|
-
|
|
51
|
-
expect(config.trunk).toBe("trunk");
|
|
52
|
-
expect(config.mergeRetryCount).toBe(7);
|
|
53
|
-
expect(config.leaseStalenessMs).toBe(90_000);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("falls back to main when the repo has no origin/HEAD", async () => {
|
|
57
|
-
const config = await loadConfig(root, { fs, git });
|
|
58
|
-
|
|
59
|
-
expect(config.trunk).toBe("main");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("reports the config path and the remedy when the file will not import", async () => {
|
|
63
|
-
await fs.write(
|
|
64
|
-
join(root, "arbor.config.ts"),
|
|
65
|
-
`import "./nowhere-at-all";\nexport default {};\n`,
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
const failure = await loadConfig(root, { fs, git }).catch(
|
|
69
|
-
(entry: Error) => entry,
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
expect(failure).toBeInstanceOf(Error);
|
|
73
|
-
expect((failure as Error).message).toContain("arbor.config.ts");
|
|
74
|
-
expect((failure as Error).message).toContain("bun install");
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("takes the trunk from what origin/HEAD points at", async () => {
|
|
78
|
-
await using fixture = await Testing.open();
|
|
79
|
-
await originHead(fixture, "master");
|
|
80
|
-
|
|
81
|
-
const config = await loadConfig(fixture.root, {
|
|
82
|
-
fs,
|
|
83
|
-
git: new Git(fixture.root, { ps: fixture.ps }),
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
expect(config.trunk).toBe("master");
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("prefers the trunk arbor.config.ts names over what it detects", async () => {
|
|
90
|
-
await using fixture = await Testing.open();
|
|
91
|
-
await originHead(fixture, "master");
|
|
92
|
-
await fixture.fs.write(
|
|
93
|
-
join(fixture.root, "arbor.config.ts"),
|
|
94
|
-
`export default { trunk: "release" };\n`,
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
const config = await loadConfig(fixture.root, {
|
|
98
|
-
fs,
|
|
99
|
-
git: new Git(fixture.root, { ps: fixture.ps }),
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
expect(config.trunk).toBe("release");
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
/** Fabricates the ref a clone would fetch, without a remote to fetch from. */
|
|
107
|
-
async function originHead(fixture: Testing, branch: string): Promise<void> {
|
|
108
|
-
await fixture.gitCli(
|
|
109
|
-
fixture.root,
|
|
110
|
-
"remote",
|
|
111
|
-
"add",
|
|
112
|
-
"origin",
|
|
113
|
-
"https://example.invalid/repo.git",
|
|
114
|
-
);
|
|
115
|
-
await fixture.gitCli(
|
|
116
|
-
fixture.root,
|
|
117
|
-
"symbolic-ref",
|
|
118
|
-
"refs/remotes/origin/HEAD",
|
|
119
|
-
`refs/remotes/origin/${branch}`,
|
|
120
|
-
);
|
|
121
|
-
}
|
package/load-config.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { basename, resolve } from "node:path";
|
|
2
|
-
import { type Fs, NodeFs } from "webappwiz/system";
|
|
3
|
-
import type { Config } from "./config";
|
|
4
|
-
import { Git } from "./git";
|
|
5
|
-
|
|
6
|
-
export interface LoadConfigOptions {
|
|
7
|
-
/** What `arbor.config.ts` is looked for through; the real one by default. */
|
|
8
|
-
fs?: Fs;
|
|
9
|
-
/** What the trunk is detected through; the repo's own git by default. */
|
|
10
|
-
git?: Git;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* The settings arbor runs a repo with: what `arbor.config.ts` names, then the
|
|
15
|
-
* branch `origin/HEAD` points at for the trunk, then arbor's own defaults.
|
|
16
|
-
* Throws when the config file is there but cannot be imported.
|
|
17
|
-
*/
|
|
18
|
-
export async function loadConfig(
|
|
19
|
-
root: string,
|
|
20
|
-
opts: LoadConfigOptions = {},
|
|
21
|
-
): Promise<Config> {
|
|
22
|
-
const found = await file(opts.fs ?? new NodeFs(), root);
|
|
23
|
-
// Only a repo that has not said which branch is its trunk pays for the
|
|
24
|
-
// detecting spawn.
|
|
25
|
-
const trunk =
|
|
26
|
-
found.trunk ??
|
|
27
|
-
(await (opts.git ?? new Git(root)).defaultBranch()) ??
|
|
28
|
-
"main";
|
|
29
|
-
return { ...defaults(root, trunk), ...found };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function defaults(root: string, trunk: string): Config {
|
|
33
|
-
return {
|
|
34
|
-
trunk,
|
|
35
|
-
worktreeRoot: resolve(root, "..", `${basename(root)}-arbor`),
|
|
36
|
-
postCheckout: null,
|
|
37
|
-
postRewrite: null,
|
|
38
|
-
preMerge: null,
|
|
39
|
-
postMerge: null,
|
|
40
|
-
leaseStalenessMs: 90_000,
|
|
41
|
-
mergeRetryCount: 2,
|
|
42
|
-
removedCapacity: 50,
|
|
43
|
-
logCapacity: 1000,
|
|
44
|
-
todoStalenessMs: 30 * 24 * 60 * 60 * 1000,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async function file(fs: Fs, root: string): Promise<Partial<Config>> {
|
|
49
|
-
const path = `${root}/arbor.config.ts`;
|
|
50
|
-
if (!(await fs.exists(path))) {
|
|
51
|
-
return {};
|
|
52
|
-
}
|
|
53
|
-
// Falling back to the defaults would be worse than failing: a config arbor
|
|
54
|
-
// silently ignored is a `preMerge` gate that silently stopped running.
|
|
55
|
-
const mod = (await import(path).catch((cause: unknown) => {
|
|
56
|
-
throw new Error(
|
|
57
|
-
`could not load ${path}: ${cause}. Its imports may be stale: try \`bun install\` in ${root}.`,
|
|
58
|
-
{ cause },
|
|
59
|
-
);
|
|
60
|
-
})) as { default?: Partial<Config> };
|
|
61
|
-
return mod.default ?? {};
|
|
62
|
-
}
|
package/log.test.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { Exit } from "./exit";
|
|
4
|
-
import { Journal } from "./journal";
|
|
5
|
-
import { log } from "./log";
|
|
6
|
-
import { Testing } from "./testing";
|
|
7
|
-
|
|
8
|
-
describe("log", () => {
|
|
9
|
-
let deps: Testing;
|
|
10
|
-
|
|
11
|
-
beforeEach(async () => {
|
|
12
|
-
deps = await Testing.open();
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
afterEach(() => deps.disposeAsync());
|
|
16
|
-
|
|
17
|
-
it("records how each command ended, refusals included", async () => {
|
|
18
|
-
await deps.journal.record("create", "alpha", async () => {});
|
|
19
|
-
await expect(
|
|
20
|
-
deps.journal.record("merge", "alpha", () =>
|
|
21
|
-
Promise.reject(new Exit("tests_failed", "nope")),
|
|
22
|
-
),
|
|
23
|
-
).toBail("tests_failed");
|
|
24
|
-
|
|
25
|
-
await log(deps);
|
|
26
|
-
|
|
27
|
-
const out = deps.out();
|
|
28
|
-
expect(out).toContain("create");
|
|
29
|
-
expect(out).toContain("alpha");
|
|
30
|
-
expect(out).toContain("ok");
|
|
31
|
-
expect(out).toContain("tests_failed");
|
|
32
|
-
|
|
33
|
-
// Newest last, and --count trims from the front.
|
|
34
|
-
deps.log.clear();
|
|
35
|
-
await log(deps, { count: 1, json: true });
|
|
36
|
-
expect(JSON.parse(deps.out())).toMatchObject([
|
|
37
|
-
{ action: "merge", task: "alpha", reason: "tests_failed" },
|
|
38
|
-
]);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("keeps the log capped and asking for none returns none", async () => {
|
|
42
|
-
// Room for two, so a third entry has to push the first off.
|
|
43
|
-
const journal = new Journal(join(deps.arborDir, "log.jsonl"), 2, {
|
|
44
|
-
fs: deps.fs,
|
|
45
|
-
});
|
|
46
|
-
await journal.record("create", "a", async () => {});
|
|
47
|
-
await journal.record("create", "b", async () => {});
|
|
48
|
-
await journal.record("create", "c", async () => {});
|
|
49
|
-
|
|
50
|
-
expect((await journal.tail(10)).map((entry) => entry.task)).toEqual([
|
|
51
|
-
"b",
|
|
52
|
-
"c",
|
|
53
|
-
]);
|
|
54
|
-
expect(await journal.tail(0)).toEqual([]);
|
|
55
|
-
|
|
56
|
-
await log({ journal, log: deps.log }, { json: true });
|
|
57
|
-
expect(JSON.parse(deps.out())).toHaveLength(2);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("says so plainly when nothing has happened", async () => {
|
|
61
|
-
await log(deps);
|
|
62
|
-
|
|
63
|
-
expect(deps.out()).toContain("nothing recorded yet");
|
|
64
|
-
});
|
|
65
|
-
});
|
package/log.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { color, type Logger } from "webappwiz/log";
|
|
2
|
-
import { age } from "./age";
|
|
3
|
-
import type { Entry, Journal } from "./journal";
|
|
4
|
-
import { table } from "./table";
|
|
5
|
-
|
|
6
|
-
/** Enough to cover a session's worth of work without scrolling. */
|
|
7
|
-
export const DEFAULT_COUNT = 20;
|
|
8
|
-
|
|
9
|
-
export interface LogOptions {
|
|
10
|
-
/** How many of the most recent entries to show. */
|
|
11
|
-
count?: number;
|
|
12
|
-
/** Print the entries as JSON instead of a table. */
|
|
13
|
-
json?: boolean;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export async function log(
|
|
17
|
-
{ journal, log }: { journal: Journal; log: Logger },
|
|
18
|
-
{ count = DEFAULT_COUNT, json = false }: LogOptions = {},
|
|
19
|
-
): Promise<void> {
|
|
20
|
-
const entries = await journal.tail(count);
|
|
21
|
-
|
|
22
|
-
if (json) {
|
|
23
|
-
log.info(JSON.stringify(entries, null, "\t"));
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
if (entries.length === 0) {
|
|
27
|
-
log.info("nothing recorded yet");
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
log.info(table(["WHEN", "ACTION", "TASK", "RESULT"], entries.map(row)));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function row(entry: Entry): string[] {
|
|
34
|
-
return [
|
|
35
|
-
age(entry.at),
|
|
36
|
-
entry.action,
|
|
37
|
-
entry.task ?? "-",
|
|
38
|
-
entry.reason === null ? color.green("ok") : color.red(entry.reason),
|
|
39
|
-
];
|
|
40
|
-
}
|