@intentius/chant 0.95.1 → 0.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +9 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.d.ts +2 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/index.d.ts +2 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/run-live.d.ts +127 -0
- package/dist/op/run-live.d.ts.map +1 -0
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/workspace/checks/diagrams.d.ts +5 -0
- package/dist/workspace/checks/diagrams.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +5 -5
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +50 -7
- package/dist/workspace/intent-cli.d.ts +6 -0
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-record.d.ts +145 -0
- package/dist/workspace/intent-record.d.ts.map +1 -0
- package/dist/workspace/intent.d.ts +46 -1
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +2 -2
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/patch.d.ts +122 -0
- package/dist/workspace/patch.d.ts.map +1 -0
- package/dist/workspace/points-cli.d.ts +8 -0
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +2 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-decided.d.ts +36 -0
- package/dist/workspace/record-decided.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +8 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +79 -12
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/main.test.ts +16 -0
- package/src/cli/main.ts +34 -3
- package/src/cli/registry.ts +9 -1
- package/src/lifecycle/run-ledger.ts +7 -1
- package/src/op/activities/shell.ts +4 -1
- package/src/op/index.ts +2 -0
- package/src/op/local-executor.ts +51 -3
- package/src/op/operator.ts +2 -11
- package/src/op/run-live.test.ts +111 -0
- package/src/op/run-live.ts +305 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/steward-points.test.ts +81 -0
- package/src/workspace/checks/diagrams.test.ts +24 -0
- package/src/workspace/checks/diagrams.ts +10 -3
- package/src/workspace/declaration.schema.json +50 -7
- package/src/workspace/declaration.test.ts +18 -0
- package/src/workspace/declaration.ts +7 -7
- package/src/workspace/intent-cli.ts +47 -1
- package/src/workspace/intent-record.schema.json +529 -0
- package/src/workspace/intent-record.test.ts +257 -0
- package/src/workspace/intent-record.ts +371 -0
- package/src/workspace/intent.schema.json +33 -1
- package/src/workspace/intent.ts +29 -12
- package/src/workspace/ls-contract.test.ts +14 -0
- package/src/workspace/ls.schema.json +3 -3
- package/src/workspace/ls.ts +3 -3
- package/src/workspace/patch.schema.json +321 -0
- package/src/workspace/patch.test.ts +198 -0
- package/src/workspace/patch.ts +377 -0
- package/src/workspace/points-cli.ts +18 -0
- package/src/workspace/read-contract.test.ts +29 -1
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +3 -0
- package/src/workspace/record-decided.ts +107 -0
- package/src/workspace/records-cli.ts +12 -0
- package/src/workspace/records.schema.json +15 -0
- package/src/workspace/status-contract.test.ts +76 -2
- package/src/workspace/status-stewards.ts +149 -27
- package/src/workspace/status.schema.json +69 -6
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The patch read, on a workspace one directory down in its repository, with a
|
|
3
|
+
* work branch the way the factory leaves one (`chant/work/<id>`) and a main
|
|
4
|
+
* branch that moved on after the branch was cut:
|
|
5
|
+
*
|
|
6
|
+
* - m0, the root commit, adds the workspace, app/server.mjs, app/old.mjs,
|
|
7
|
+
* docs/claims.md and a file outside the workspace.
|
|
8
|
+
* - w1 and w2, on chant/work/W-001, edit app/server.mjs, rename app/old.mjs
|
|
9
|
+
* to app/new.mjs, add a binary file and add docs/more.md.
|
|
10
|
+
* - m1, on main after the cut, edits docs/claims.md.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
15
|
+
import { cleanScratch, contract, git, repo, writeFiles } from "./__fixtures__/contract-repo";
|
|
16
|
+
import { formatPatch, workspacePatch, type PatchDocument, type PatchFile } from "./patch";
|
|
17
|
+
import patchSchema from "./patch.schema.json";
|
|
18
|
+
|
|
19
|
+
let top: string;
|
|
20
|
+
let ws: string;
|
|
21
|
+
const sha: Record<string, string> = {};
|
|
22
|
+
|
|
23
|
+
const SERVER = Array.from({ length: 40 }, (_, i) => `export const line${i} = ${i};`).join("\n") + "\n";
|
|
24
|
+
const SERVER_2 = SERVER.replace("line3 = 3", "line3 = 33").replace("line30 = 30", "line30 = 300");
|
|
25
|
+
const OLD = "// moved, not changed\nexport const a = 1;\nexport const b = 2;\nexport const c = 3;\n";
|
|
26
|
+
|
|
27
|
+
function commit(message: string): string {
|
|
28
|
+
git(top, "add", "-A");
|
|
29
|
+
git(top, "commit", "-q", "-m", message);
|
|
30
|
+
return git(top, "rev-parse", "HEAD");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
top = repo({
|
|
35
|
+
"ws/chant.workspace.json": JSON.stringify({ name: "studio", schema: 1, members: [{ name: "app", dir: "app", kind: "other", because: "a server" }] }),
|
|
36
|
+
"ws/app/server.mjs": SERVER,
|
|
37
|
+
"ws/app/old.mjs": OLD,
|
|
38
|
+
"ws/docs/claims.md": "# Claims\n",
|
|
39
|
+
"outside.txt": "not in the workspace\n",
|
|
40
|
+
});
|
|
41
|
+
ws = join(top, "ws");
|
|
42
|
+
git(top, "symbolic-ref", "HEAD", "refs/heads/main");
|
|
43
|
+
sha.m0 = commit("the workspace");
|
|
44
|
+
git(top, "checkout", "-q", "-b", "chant/work/W-001");
|
|
45
|
+
writeFiles(top, { "ws/app/server.mjs": SERVER_2, "outside.txt": "changed outside\n" });
|
|
46
|
+
git(top, "mv", "ws/app/old.mjs", "ws/app/new.mjs");
|
|
47
|
+
sha.w1 = commit("edit the server and move old to new");
|
|
48
|
+
writeFiles(top, { "ws/app/logo.bin": { text: "\u0000\u0001\u0002binary\u0000", mode: 0o644 }, "ws/docs/more.md": "More.\n" });
|
|
49
|
+
sha.w2 = commit("a logo and more docs");
|
|
50
|
+
git(top, "checkout", "-q", "main");
|
|
51
|
+
writeFiles(top, { "ws/docs/claims.md": "# Claims\n\nOne.\n" });
|
|
52
|
+
sha.m1 = commit("a claim on main");
|
|
53
|
+
});
|
|
54
|
+
afterAll(cleanScratch);
|
|
55
|
+
|
|
56
|
+
const { expectValid } = contract(patchSchema);
|
|
57
|
+
type Result = Exclude<PatchDocument, { error: unknown }>;
|
|
58
|
+
|
|
59
|
+
async function patch(range: string, options: { paths?: string[]; maxBytes?: number; worktree?: boolean } = {}): Promise<Result> {
|
|
60
|
+
const { doc } = await workspacePatch({ cwd: ws, range, ...options });
|
|
61
|
+
expectValid(doc);
|
|
62
|
+
if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
|
|
63
|
+
return doc;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const summary = (f: PatchFile) => [f.change, f.path, f.from, f.additions, f.deletions, f.hunkCount, f.truncated];
|
|
67
|
+
|
|
68
|
+
describe("workspace patch", () => {
|
|
69
|
+
test("a work branch from its merge base: each file under the workspace root, with renames, binaries and hunks", async () => {
|
|
70
|
+
const doc = await patch("main...chant/work/W-001");
|
|
71
|
+
expect(doc.range).toEqual({ spec: "main...chant/work/W-001", form: "merge-base", base: sha.m0, head: sha.w2 });
|
|
72
|
+
expect(doc.workspace).toEqual({ name: "studio", root: "ws" });
|
|
73
|
+
expect(doc.paths).toEqual([]);
|
|
74
|
+
expect(doc.limits).toEqual({ fileBytes: 65536, totalBytes: 1048576 });
|
|
75
|
+
expect(doc.files.map(summary)).toEqual([
|
|
76
|
+
["added", "app/logo.bin", null, null, null, 0, false],
|
|
77
|
+
["renamed", "app/new.mjs", "app/old.mjs", 0, 0, 0, false],
|
|
78
|
+
["modified", "app/server.mjs", null, 2, 2, 2, false],
|
|
79
|
+
["added", "docs/more.md", null, 1, 0, 1, false],
|
|
80
|
+
]);
|
|
81
|
+
expect(doc.files[0].binary).toBe(true);
|
|
82
|
+
const server = doc.files[2];
|
|
83
|
+
expect(server.hunks.map((h) => [h.oldStart, h.oldLines, h.newStart, h.newLines])).toEqual([
|
|
84
|
+
[1, 7, 1, 7],
|
|
85
|
+
[28, 7, 28, 7],
|
|
86
|
+
]);
|
|
87
|
+
expect(server.hunks[0].header).toBe("@@ -1,7 +1,7 @@");
|
|
88
|
+
expect(server.hunks[0].lines.filter((l) => /^[+-]/.test(l))).toEqual(["-export const line3 = 3;", "+export const line3 = 33;"]);
|
|
89
|
+
expect(doc.files[3].hunks).toEqual([{ header: "@@ -0,0 +1 @@", oldStart: 0, oldLines: 0, newStart: 1, newLines: 1, lines: ["+More."] }]);
|
|
90
|
+
expect(doc.summary).toEqual({ files: 4, additions: 3, deletions: 2, truncated: false });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("two dots compare the trees as they are, so main's later edit shows as undone", async () => {
|
|
94
|
+
const doc = await patch("main..chant/work/W-001");
|
|
95
|
+
expect(doc.range.form).toBe("range");
|
|
96
|
+
expect(doc.range.base).toBe(sha.m1);
|
|
97
|
+
const claims = doc.files.find((f) => f.path === "docs/claims.md")!;
|
|
98
|
+
expect(claims.hunks[0].lines).toEqual([" # Claims", "-", "-One."]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("one commit reads against its first parent, and a root commit against the empty tree", async () => {
|
|
102
|
+
const w2 = await patch(sha.w2);
|
|
103
|
+
expect(w2.range).toEqual({ spec: sha.w2, form: "commit", base: sha.w1, head: sha.w2 });
|
|
104
|
+
expect(w2.files.map((f) => f.path)).toEqual(["app/logo.bin", "docs/more.md"]);
|
|
105
|
+
const m0 = await patch(sha.m0);
|
|
106
|
+
expect(m0.range.base).toBe("4b825dc642cb6eb9a060e54bf8d69288fbee4904");
|
|
107
|
+
expect(m0.files.map((f) => [f.change, f.path])).toEqual([
|
|
108
|
+
["added", "app/old.mjs"],
|
|
109
|
+
["added", "app/server.mjs"],
|
|
110
|
+
["added", "chant.workspace.json"],
|
|
111
|
+
["added", "docs/claims.md"],
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("--path keeps a file, or everything under a directory", async () => {
|
|
116
|
+
expect((await patch("main...chant/work/W-001", { paths: ["docs"] })).files.map((f) => f.path)).toEqual(["docs/more.md"]);
|
|
117
|
+
const one = await patch("main...chant/work/W-001", { paths: ["app/server.mjs", "docs/"] });
|
|
118
|
+
expect(one.paths).toEqual(["app/server.mjs", "docs"]);
|
|
119
|
+
expect(one.files.map((f) => f.path)).toEqual(["app/server.mjs", "docs/more.md"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a file's hunk text stops at maxBytes, mid-hunk if it must, and says so", async () => {
|
|
123
|
+
const doc = await patch(sha.w1, { paths: ["app/server.mjs"], maxBytes: 200 });
|
|
124
|
+
const f = doc.files[0];
|
|
125
|
+
expect(f.truncated).toBe(true);
|
|
126
|
+
expect(f.hunkCount).toBe(2);
|
|
127
|
+
expect(f.bytes).toBeGreaterThan(200);
|
|
128
|
+
const kept = f.hunks.reduce((n, h) => n + Buffer.byteLength(h.header) + 1 + h.lines.reduce((m, l) => m + Buffer.byteLength(l) + 1, 0), 0);
|
|
129
|
+
expect(kept).toBeLessThanOrEqual(200);
|
|
130
|
+
expect(f.hunks.length).toBe(1);
|
|
131
|
+
expect(f.hunks[0].lines.length).toBeLessThan(9);
|
|
132
|
+
expect(doc.summary.truncated).toBe(true);
|
|
133
|
+
expect(formatPatch(doc)).toContain("modified app/server.mjs +2 -2; truncated, 1 of 2 hunks shown");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("once every file's text passes the total, the files after it keep their counts and no hunks", async () => {
|
|
137
|
+
// Each file's hunk is 46 bytes: its 14-byte header and one 32-byte line. 800 bytes hold 17,
|
|
138
|
+
// then the 18th file's header, and nothing after.
|
|
139
|
+
const files = Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`ws/many/f${String(i).padStart(2, "0")}.txt`, `${"x".repeat(30)}\n`]));
|
|
140
|
+
git(top, "checkout", "-q", "-b", "many", "main");
|
|
141
|
+
writeFiles(top, files);
|
|
142
|
+
const many = commit("twenty files");
|
|
143
|
+
git(top, "checkout", "-q", "main");
|
|
144
|
+
const doc = await patch(many, { maxBytes: 50 });
|
|
145
|
+
expect(doc.limits).toEqual({ fileBytes: 50, totalBytes: 800 });
|
|
146
|
+
expect(doc.files.map((f) => [f.hunks.length, f.truncated])).toEqual([...Array(17).fill([1, false]), [1, true], [0, true], [0, true]]);
|
|
147
|
+
expect(doc.files[17].hunks[0]).toMatchObject({ header: "@@ -0,0 +1 @@", lines: [] });
|
|
148
|
+
expect(doc.files.every((f) => f.additions === 1 && f.hunkCount === 1 && f.bytes === 46)).toBe(true);
|
|
149
|
+
expect(doc.summary.truncated).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("--worktree reads the working tree against HEAD: edits staged or not, a deletion, and untracked files not ignored as added", async () => {
|
|
153
|
+
git(top, "checkout", "-q", "-b", "worktree", "main");
|
|
154
|
+
writeFiles(top, {
|
|
155
|
+
"ws/app/server.mjs": SERVER.replace("line5 = 5", "line5 = 55"),
|
|
156
|
+
"ws/docs/new.md": "# New\n\nTwo lines.\n",
|
|
157
|
+
"ws/.gitignore": "*.log\n",
|
|
158
|
+
"ws/debug.log": "ignored\n",
|
|
159
|
+
"outside-new.txt": "outside the workspace\n",
|
|
160
|
+
});
|
|
161
|
+
git(top, "add", "ws/.gitignore");
|
|
162
|
+
git(top, "rm", "-q", "ws/app/old.mjs");
|
|
163
|
+
try {
|
|
164
|
+
const doc = await patch("", { worktree: true });
|
|
165
|
+
expect(doc.range).toEqual({ spec: "", form: "worktree", base: sha.m1, head: null });
|
|
166
|
+
expect(doc.files.map(summary)).toEqual([
|
|
167
|
+
["added", ".gitignore", null, 1, 0, 1, false],
|
|
168
|
+
["deleted", "app/old.mjs", null, 0, 4, 1, false],
|
|
169
|
+
["modified", "app/server.mjs", null, 1, 1, 1, false],
|
|
170
|
+
["added", "docs/new.md", null, 3, 0, 1, false],
|
|
171
|
+
]);
|
|
172
|
+
expect(doc.files[3].hunks).toEqual([{ header: "@@ -0,0 +1,3 @@", oldStart: 0, oldLines: 0, newStart: 1, newLines: 3, lines: ["+# New", "+", "+Two lines."] }]);
|
|
173
|
+
expect(doc.files[2].hunks[0].lines.filter((l) => /^[+-]/.test(l))).toEqual(["-export const line5 = 5;", "+export const line5 = 55;"]);
|
|
174
|
+
const one = await patch("HEAD", { worktree: true, paths: ["docs/new.md"] });
|
|
175
|
+
expect(one.files.map((f) => f.path)).toEqual(["docs/new.md"]);
|
|
176
|
+
expect(one.summary).toEqual({ files: 1, additions: 3, deletions: 0, truncated: false });
|
|
177
|
+
expect(formatPatch(one)).toContain("..worktree (HEAD, worktree)");
|
|
178
|
+
expect((await patch(sha.m0, { worktree: true, paths: ["docs/claims.md"] })).files.map((f) => [f.path, f.additions])).toEqual([["docs/claims.md", 2]]);
|
|
179
|
+
} finally {
|
|
180
|
+
git(top, "reset", "-q", "--hard");
|
|
181
|
+
git(top, "clean", "-qfdx");
|
|
182
|
+
git(top, "checkout", "-q", "main");
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a revision that is no commit is revision-unknown, and a path outside the workspace patch-path-invalid", async () => {
|
|
187
|
+
for (const [range, paths, code] of [
|
|
188
|
+
["main..nope", undefined, "revision-unknown"],
|
|
189
|
+
["main...chant/work/W-001", ["../outside.txt"], "patch-path-invalid"],
|
|
190
|
+
["main...chant/work/W-001", ["/etc"], "patch-path-invalid"],
|
|
191
|
+
] as const) {
|
|
192
|
+
const { doc, failed } = await workspacePatch({ cwd: ws, range, paths: paths ? [...paths] : undefined });
|
|
193
|
+
expectValid(doc);
|
|
194
|
+
expect(failed).toBe(true);
|
|
195
|
+
expect("error" in doc && doc.error.code).toBe(code);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hunks of a diff: `chant workspace patch <base>..<head>|<base>...<head>|<commit>
|
|
3
|
+
* [--path <p>...] [--max-bytes <n>] [--json]`, or `chant workspace patch [<commit>]
|
|
4
|
+
* --worktree` for the working tree's changes against a commit (HEAD unless given).
|
|
5
|
+
*
|
|
6
|
+
* `check --changes` maps the paths a diff changes to records and prints no
|
|
7
|
+
* lines of it. A reader showing what a work branch or a commit did needs the
|
|
8
|
+
* lines, and a reader that runs no git of its own (hud) gets them here. The
|
|
9
|
+
* range is read as `check --changes` reads one, except that a lone revision
|
|
10
|
+
* is that commit against its first parent, or against the empty tree for a
|
|
11
|
+
* root commit, since a reader drawing one commit wants that commit's change.
|
|
12
|
+
* With --worktree the head is the working tree as it is on disk: staged or
|
|
13
|
+
* not, and each untracked file git does not ignore, as added.
|
|
14
|
+
*
|
|
15
|
+
* Each changed file under the workspace root is listed, with renames
|
|
16
|
+
* followed, its added and deleted line counts, and its hunks. A file's hunk
|
|
17
|
+
* text stops at `maxBytes` (64 KiB unless given), and all files' together at
|
|
18
|
+
* sixteen times that; a file cut short, or left without hunks once the total
|
|
19
|
+
* is spent, says `truncated`. A binary file has counts of null and no hunks.
|
|
20
|
+
* Git is read through a local `git` subprocess only: no fetch, no network.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
import { formatError } from "../cli/format";
|
|
25
|
+
import type { CommandContext } from "../cli/registry";
|
|
26
|
+
import { readDeclaration, readerVersion, WORKSPACE_ERROR_CODES, WorkspaceReadError } from "./declaration";
|
|
27
|
+
import { isWorkspacePath } from "./record-assets";
|
|
28
|
+
import type { ReasonCode } from "./reason-codes";
|
|
29
|
+
import { joinPath } from "./tree";
|
|
30
|
+
import { locateWorkspace } from "./which-chant";
|
|
31
|
+
|
|
32
|
+
/** The version of the `patch` document this chant writes. */
|
|
33
|
+
export const PATCH_CONTRACT_VERSION = 1;
|
|
34
|
+
|
|
35
|
+
/** `$id` of the JSON Schema for the document, shipped beside this file. */
|
|
36
|
+
export const PATCH_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/patch/v1/patch.schema.json";
|
|
37
|
+
|
|
38
|
+
/** Why the read failed as a whole. */
|
|
39
|
+
export const PATCH_ERROR_CODES = [
|
|
40
|
+
...WORKSPACE_ERROR_CODES,
|
|
41
|
+
/** A --path is not a relative path inside the workspace. */
|
|
42
|
+
"patch-path-invalid",
|
|
43
|
+
] as const satisfies readonly ReasonCode[];
|
|
44
|
+
export type PatchErrorCode = (typeof PATCH_ERROR_CODES)[number];
|
|
45
|
+
|
|
46
|
+
/** The hunk text one file gets unless `maxBytes` says otherwise. */
|
|
47
|
+
export const PATCH_FILE_BYTES = 64 * 1024;
|
|
48
|
+
|
|
49
|
+
/** The hunk text every file gets together, as a multiple of the per-file cap. */
|
|
50
|
+
export const PATCH_TOTAL_FACTOR = 16;
|
|
51
|
+
|
|
52
|
+
/** Git's empty tree, the base of a root commit. */
|
|
53
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
54
|
+
|
|
55
|
+
class PatchError extends Error {
|
|
56
|
+
constructor(
|
|
57
|
+
readonly code: PatchErrorCode,
|
|
58
|
+
message: string,
|
|
59
|
+
) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = "PatchError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface PatchHunk {
|
|
66
|
+
/** The `@@` line as git writes it. */
|
|
67
|
+
header: string;
|
|
68
|
+
oldStart: number;
|
|
69
|
+
oldLines: number;
|
|
70
|
+
newStart: number;
|
|
71
|
+
newLines: number;
|
|
72
|
+
/** Each line with its leading ` `, `+`, `-` or `\`, without its newline. Cut short when the file is truncated. */
|
|
73
|
+
lines: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PatchFile {
|
|
77
|
+
/** From the workspace root. */
|
|
78
|
+
path: string;
|
|
79
|
+
change: "added" | "modified" | "deleted" | "renamed" | "copied";
|
|
80
|
+
/** For a rename or a copy, the path it came from. */
|
|
81
|
+
from: string | null;
|
|
82
|
+
binary: boolean;
|
|
83
|
+
/** Lines added and deleted, as git counts them; null for a binary file. */
|
|
84
|
+
additions: number | null;
|
|
85
|
+
deletions: number | null;
|
|
86
|
+
/** How many hunks git wrote for the file; hunks may hold fewer when truncated. */
|
|
87
|
+
hunkCount: number;
|
|
88
|
+
/** The bytes of hunk text git wrote for the file, headers included, before any cut. */
|
|
89
|
+
bytes: number;
|
|
90
|
+
hunks: PatchHunk[];
|
|
91
|
+
/** Whether hunks holds less than git wrote. */
|
|
92
|
+
truncated: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface Head {
|
|
96
|
+
$schema: string;
|
|
97
|
+
contract: number;
|
|
98
|
+
chant: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type PatchDocument =
|
|
102
|
+
| (Head & {
|
|
103
|
+
workspace: { name: string; root: string };
|
|
104
|
+
/** What was asked, and the two commits compared. `form` says how the spec was read. */
|
|
105
|
+
/** head is null for the working tree (--worktree). */
|
|
106
|
+
range: { spec: string; form: "range" | "merge-base" | "commit" | "worktree"; base: string; head: string | null };
|
|
107
|
+
/** The --path filters, from the workspace root; empty for the whole workspace. */
|
|
108
|
+
paths: string[];
|
|
109
|
+
limits: { fileBytes: number; totalBytes: number };
|
|
110
|
+
files: PatchFile[];
|
|
111
|
+
summary: { files: number; additions: number; deletions: number; truncated: boolean };
|
|
112
|
+
})
|
|
113
|
+
| (Head & { error: { code: PatchErrorCode; message: string } });
|
|
114
|
+
|
|
115
|
+
export interface PatchQuery {
|
|
116
|
+
cwd: string;
|
|
117
|
+
/** `<base>..<head>`, `<base>...<head>`, or one commit. */
|
|
118
|
+
range: string;
|
|
119
|
+
/** Paths from the workspace root: a file, or a directory for everything under it. */
|
|
120
|
+
paths?: string[];
|
|
121
|
+
/** The hunk text one file gets. */
|
|
122
|
+
maxBytes?: number;
|
|
123
|
+
/** Compare `range` (one commit, HEAD when empty) with the working tree. */
|
|
124
|
+
worktree?: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface PatchResult {
|
|
128
|
+
doc: PatchDocument;
|
|
129
|
+
failed: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function git(top: string, args: string[]): string {
|
|
133
|
+
return execFileSync("git", args, { cwd: top, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 1024 * 1024 * 1024 });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function commitOf(top: string, rev: string): string {
|
|
137
|
+
if (rev === "" || rev.startsWith("-")) throw new PatchError("revision-unknown", `the patch needs <base>..<head>, <base>...<head> or a commit, and ${JSON.stringify(rev)} is not a revision`);
|
|
138
|
+
try {
|
|
139
|
+
return git(top, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]).trim();
|
|
140
|
+
} catch {
|
|
141
|
+
throw new PatchError("revision-unknown", `${rev} is no commit in this repository`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveRange(top: string, spec: string): { form: "range" | "merge-base" | "commit"; base: string; head: string } {
|
|
146
|
+
const three = spec.indexOf("...");
|
|
147
|
+
if (three >= 0) {
|
|
148
|
+
const head = commitOf(top, spec.slice(three + 3) || "HEAD");
|
|
149
|
+
const other = commitOf(top, spec.slice(0, three));
|
|
150
|
+
try {
|
|
151
|
+
return { form: "merge-base", base: git(top, ["merge-base", other, head]).trim(), head };
|
|
152
|
+
} catch {
|
|
153
|
+
throw new PatchError("revision-unknown", `${spec}: the two revisions have no merge base`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const two = spec.indexOf("..");
|
|
157
|
+
if (two >= 0) return { form: "range", base: commitOf(top, spec.slice(0, two)), head: commitOf(top, spec.slice(two + 2) || "HEAD") };
|
|
158
|
+
const head = commitOf(top, spec);
|
|
159
|
+
let base = EMPTY_TREE;
|
|
160
|
+
try {
|
|
161
|
+
base = git(top, ["rev-parse", "--verify", "--quiet", `${head}^1`]).trim();
|
|
162
|
+
} catch {
|
|
163
|
+
// A root commit: against the empty tree.
|
|
164
|
+
}
|
|
165
|
+
return { form: "commit", base, head };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Each changed file, from the repository root, with its counts; renames and copies followed. */
|
|
169
|
+
function changedFiles(top: string, revs: string[], pathspecs: string[]): Omit<PatchFile, "hunkCount" | "bytes" | "hunks" | "truncated">[] {
|
|
170
|
+
const spec = ["--", ...pathspecs];
|
|
171
|
+
const status = git(top, ["diff", "--name-status", "-z", "-M", "--no-color", ...revs, ...spec]).split("\0");
|
|
172
|
+
const counts = git(top, ["diff", "--numstat", "-z", "-M", "--no-color", ...revs, ...spec]).split("\0");
|
|
173
|
+
const numstat = new Map<string, { additions: number | null; deletions: number | null }>();
|
|
174
|
+
for (let i = 0; i < counts.length && counts[i] !== ""; ) {
|
|
175
|
+
const [a, d, path] = counts[i].split("\t");
|
|
176
|
+
const n = { additions: a === "-" ? null : Number(a), deletions: d === "-" ? null : Number(d) };
|
|
177
|
+
if (path === "") {
|
|
178
|
+
// A rename or a copy: the old path, then the new.
|
|
179
|
+
numstat.set(counts[i + 2], n);
|
|
180
|
+
i += 3;
|
|
181
|
+
} else {
|
|
182
|
+
numstat.set(path, n);
|
|
183
|
+
i += 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const out: Omit<PatchFile, "hunkCount" | "bytes" | "hunks" | "truncated">[] = [];
|
|
187
|
+
for (let i = 0; i < status.length && status[i] !== ""; ) {
|
|
188
|
+
const s = status[i];
|
|
189
|
+
const paired = s.startsWith("R") || s.startsWith("C");
|
|
190
|
+
const path = paired ? status[i + 2] : status[i + 1];
|
|
191
|
+
const from = paired ? status[i + 1] : null;
|
|
192
|
+
const change = s.startsWith("R") ? "renamed" : s.startsWith("C") ? "copied" : s === "A" ? "added" : s === "D" ? "deleted" : "modified";
|
|
193
|
+
const n = numstat.get(path) ?? { additions: null, deletions: null };
|
|
194
|
+
out.push({ path, change, from, binary: n.additions === null, ...n });
|
|
195
|
+
i += paired ? 3 : 2;
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The hunks of one file's diff text, and the bytes of hunk text, before any cut. */
|
|
201
|
+
function parseHunks(text: string): { hunks: PatchHunk[]; bytes: number } {
|
|
202
|
+
const hunks: PatchHunk[] = [];
|
|
203
|
+
let bytes = 0;
|
|
204
|
+
let current: PatchHunk | null = null;
|
|
205
|
+
const lines = text.split("\n");
|
|
206
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
207
|
+
for (const line of lines) {
|
|
208
|
+
const m = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
209
|
+
if (m) {
|
|
210
|
+
current = { header: line, oldStart: Number(m[1]), oldLines: m[2] === undefined ? 1 : Number(m[2]), newStart: Number(m[3]), newLines: m[4] === undefined ? 1 : Number(m[4]), lines: [] };
|
|
211
|
+
hunks.push(current);
|
|
212
|
+
bytes += Buffer.byteLength(line) + 1;
|
|
213
|
+
} else if (current && /^[ +\-\\]/.test(line)) {
|
|
214
|
+
current.lines.push(line);
|
|
215
|
+
bytes += Buffer.byteLength(line) + 1;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { hunks, bytes };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Each file's section of the diff, in the order `--name-status` lists the
|
|
223
|
+
* files: one `git diff` for the whole range, split at its `diff --git` lines.
|
|
224
|
+
* When the count of sections is not the count of files, each file's diff is
|
|
225
|
+
* read on its own instead.
|
|
226
|
+
*/
|
|
227
|
+
function fileSections(top: string, revs: string[], pathspecs: string[], files: { path: string; from: string | null }[]): string[] {
|
|
228
|
+
const diff = (spec: string[]) => git(top, ["diff", "--no-color", "--no-ext-diff", "-M", ...revs, "--", ...spec]);
|
|
229
|
+
const text = diff(pathspecs);
|
|
230
|
+
const sections = text === "" ? [] : text.split(/^(?=diff --git )/m).filter((x) => x.startsWith("diff --git "));
|
|
231
|
+
if (sections.length === files.length) return sections;
|
|
232
|
+
return files.map((f) => diff(f.from ? [f.from, f.path] : [f.path]));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** `git diff --no-index`, which exits 1 when the two differ: its output either way. */
|
|
236
|
+
function gitNoIndex(top: string, args: string[]): string {
|
|
237
|
+
try {
|
|
238
|
+
return git(top, ["diff", "--no-index", "--no-color", "--no-ext-diff", ...args]);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
const out = (err as { status?: number; stdout?: string }).stdout;
|
|
241
|
+
if ((err as { status?: number }).status === 1 && typeof out === "string") return out;
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Each untracked file git does not ignore, under the pathspecs, as an added file with its diff text. */
|
|
247
|
+
function untrackedFiles(top: string, pathspecs: string[]): { file: Omit<PatchFile, "hunkCount" | "bytes" | "hunks" | "truncated">; section: string }[] {
|
|
248
|
+
const listed = git(top, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...pathspecs]).split("\0").filter((p) => p !== "");
|
|
249
|
+
return listed.map((path) => {
|
|
250
|
+
const [a, d] = gitNoIndex(top, ["--numstat", "--", "/dev/null", path]).split("\t");
|
|
251
|
+
const binary = a === "-";
|
|
252
|
+
return {
|
|
253
|
+
file: { path, change: "added" as const, from: null, binary, additions: binary ? null : Number(a) || 0, deletions: binary ? null : Number(d) || 0 },
|
|
254
|
+
section: binary ? "" : gitNoIndex(top, ["--", "/dev/null", path]),
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The hunks cut to `budget` bytes: whole hunks while they fit, then the lines of the next that fit. */
|
|
260
|
+
function cut(hunks: PatchHunk[], budget: number): { hunks: PatchHunk[]; used: number; truncated: boolean } {
|
|
261
|
+
const out: PatchHunk[] = [];
|
|
262
|
+
let used = 0;
|
|
263
|
+
for (const h of hunks) {
|
|
264
|
+
const header = Buffer.byteLength(h.header) + 1;
|
|
265
|
+
if (used + header > budget) return { hunks: out, used, truncated: true };
|
|
266
|
+
const kept: string[] = [];
|
|
267
|
+
used += header;
|
|
268
|
+
for (const l of h.lines) {
|
|
269
|
+
const n = Buffer.byteLength(l) + 1;
|
|
270
|
+
if (used + n > budget) {
|
|
271
|
+
out.push({ ...h, lines: kept });
|
|
272
|
+
return { hunks: out, used, truncated: true };
|
|
273
|
+
}
|
|
274
|
+
kept.push(l);
|
|
275
|
+
used += n;
|
|
276
|
+
}
|
|
277
|
+
out.push(h);
|
|
278
|
+
}
|
|
279
|
+
return { hunks: out, used, truncated: false };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Build the document. Never throws a {@link WorkspaceReadError}. */
|
|
283
|
+
export async function workspacePatch(query: PatchQuery): Promise<PatchResult> {
|
|
284
|
+
const head: Head = { $schema: PATCH_OUTPUT_SCHEMA_ID, contract: PATCH_CONTRACT_VERSION, chant: readerVersion() };
|
|
285
|
+
try {
|
|
286
|
+
return { doc: run(query, head), failed: false };
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if (err instanceof PatchError || err instanceof WorkspaceReadError) {
|
|
289
|
+
return { doc: { ...head, error: { code: err.code as PatchErrorCode, message: err.message } }, failed: true };
|
|
290
|
+
}
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function run(query: PatchQuery, head: Head): Exclude<PatchDocument, { error: unknown }> {
|
|
296
|
+
const located = locateWorkspace(query.cwd);
|
|
297
|
+
const top = located.top;
|
|
298
|
+
if (!top) throw new PatchError("not-a-git-repository", "the patch read reads a git diff, and this directory is not in a git repository");
|
|
299
|
+
const declaration = readDeclaration(located.tree, "", { rootChant: true });
|
|
300
|
+
const prefix = located.root === "." ? "" : located.root;
|
|
301
|
+
const paths = (query.paths ?? []).map((p) => p.replace(/\/+$/, ""));
|
|
302
|
+
for (const p of paths) if (p !== "." && !isWorkspacePath(p)) throw new PatchError("patch-path-invalid", `--path ${p} is not a relative path inside the workspace`);
|
|
303
|
+
const pathspecs = paths.length === 0 ? [prefix === "" ? "." : prefix] : paths.map((p) => (joinPath(prefix, p) === "" ? "." : joinPath(prefix, p)));
|
|
304
|
+
const range = query.worktree
|
|
305
|
+
? { form: "worktree" as const, base: commitOf(top, query.range === "" ? "HEAD" : query.range), head: null }
|
|
306
|
+
: resolveRange(top, query.range);
|
|
307
|
+
const revs = range.head === null ? [range.base] : [range.base, range.head];
|
|
308
|
+
const fileBytes = query.maxBytes ?? PATCH_FILE_BYTES;
|
|
309
|
+
const totalBytes = fileBytes * PATCH_TOTAL_FACTOR;
|
|
310
|
+
const fromWorkspace = (full: string) => (prefix === "" ? full : full.slice(prefix.length + 1));
|
|
311
|
+
|
|
312
|
+
let left = totalBytes;
|
|
313
|
+
const files: PatchFile[] = [];
|
|
314
|
+
const tracked = changedFiles(top, revs, pathspecs);
|
|
315
|
+
const trackedSections = tracked.length > 0 ? fileSections(top, revs, pathspecs, tracked) : [];
|
|
316
|
+
const fresh = range.head === null ? untrackedFiles(top, pathspecs) : [];
|
|
317
|
+
const both = [...tracked.map((file, i) => ({ file, section: trackedSections[i] })), ...fresh].sort((a, b) => (a.file.path < b.file.path ? -1 : a.file.path > b.file.path ? 1 : 0));
|
|
318
|
+
const changed = both.map((x) => x.file);
|
|
319
|
+
const sections = both.map((x) => x.section);
|
|
320
|
+
for (const [i, f] of changed.entries()) {
|
|
321
|
+
const entry = { ...f, path: fromWorkspace(f.path), from: f.from === null ? null : fromWorkspace(f.from) };
|
|
322
|
+
if (f.binary) {
|
|
323
|
+
files.push({ ...entry, hunkCount: 0, bytes: 0, hunks: [], truncated: false });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const all = parseHunks(sections[i]);
|
|
327
|
+
const kept = cut(all.hunks, Math.min(fileBytes, left));
|
|
328
|
+
left -= kept.used;
|
|
329
|
+
files.push({ ...entry, hunkCount: all.hunks.length, bytes: all.bytes, hunks: kept.hunks, truncated: kept.truncated });
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
...head,
|
|
333
|
+
workspace: { name: declaration.name, root: located.root },
|
|
334
|
+
range: { spec: query.range, ...range },
|
|
335
|
+
paths,
|
|
336
|
+
limits: { fileBytes, totalBytes },
|
|
337
|
+
files,
|
|
338
|
+
summary: {
|
|
339
|
+
files: files.length,
|
|
340
|
+
additions: files.reduce((n, f) => n + (f.additions ?? 0), 0),
|
|
341
|
+
deletions: files.reduce((n, f) => n + (f.deletions ?? 0), 0),
|
|
342
|
+
truncated: files.some((f) => f.truncated),
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const USAGE = "chant workspace patch <base>..<head>|<base>...<head>|<commit> [--path <p>...] [--max-bytes <n>] [--json], or chant workspace patch [<commit>] --worktree [--path <p>...] [--max-bytes <n>] [--json]";
|
|
348
|
+
|
|
349
|
+
/** Each file's line, then its hunks as git writes them, then a summary. */
|
|
350
|
+
export function formatPatch(doc: Exclude<PatchDocument, { error: unknown }>): string {
|
|
351
|
+
const out = [`patch ${doc.range.base.slice(0, 8)}..${doc.range.head === null ? "worktree" : doc.range.head.slice(0, 8)} (${doc.range.spec}, ${doc.range.form})`];
|
|
352
|
+
for (const f of doc.files) {
|
|
353
|
+
const counts = f.binary ? "binary" : `+${f.additions} -${f.deletions}`;
|
|
354
|
+
out.push(`${f.change.padEnd(9)} ${f.path}${f.from ? ` (from ${f.from})` : ""} ${counts}${f.truncated ? `; truncated, ${f.hunks.length} of ${f.hunkCount} hunks shown` : ""}`);
|
|
355
|
+
for (const h of f.hunks) out.push(h.header, ...h.lines);
|
|
356
|
+
}
|
|
357
|
+
const s = doc.summary;
|
|
358
|
+
out.push(`${s.files} files, +${s.additions} -${s.deletions}${s.truncated ? `; truncated at ${doc.limits.fileBytes} bytes a file, ${doc.limits.totalBytes} in all` : ""}`);
|
|
359
|
+
return out.join("\n");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function runWorkspacePatch(ctx: CommandContext): Promise<number> {
|
|
363
|
+
const { args } = ctx;
|
|
364
|
+
const range = args.extraPositional ?? (args.worktree ? "" : undefined);
|
|
365
|
+
if (range === undefined) {
|
|
366
|
+
console.error(formatError({ message: "chant workspace patch needs a range or a commit", hint: USAGE }));
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
369
|
+
const { doc, failed } = await workspacePatch({ cwd: process.cwd(), range, paths: args.paths, maxBytes: args.maxBytes, worktree: args.worktree === true });
|
|
370
|
+
if (args.json) console.log(JSON.stringify(doc, null, 2));
|
|
371
|
+
if ("error" in doc) {
|
|
372
|
+
console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
|
|
373
|
+
return 1;
|
|
374
|
+
}
|
|
375
|
+
if (!args.json) console.log(formatPatch(doc));
|
|
376
|
+
return failed ? 1 : 0;
|
|
377
|
+
}
|
|
@@ -193,6 +193,24 @@ export async function workspacePoints(query: PointsQuery): Promise<PointsDocumen
|
|
|
193
193
|
};
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Every question's state in the workspace, by answer record id, read the way
|
|
198
|
+
* `points` reads them (the lifecycle ledger's questions included), or null
|
|
199
|
+
* when they can't all be read: an answer kind that could not be read might
|
|
200
|
+
* hold the question. The operator reads it to resume a waiting run, and
|
|
201
|
+
* `workspace status` to list a steward's waits.
|
|
202
|
+
*/
|
|
203
|
+
export async function readQuestionStates(cwd: string): Promise<Map<string, QuestionView["state"]> | null> {
|
|
204
|
+
try {
|
|
205
|
+
const doc = await workspacePoints({ cwd });
|
|
206
|
+
if (!("questions" in doc)) return null;
|
|
207
|
+
if (doc.sources.some((s) => s.reason !== null)) return null;
|
|
208
|
+
return new Map(doc.questions.map((q) => [q.id, q.state]));
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
196
214
|
/** The document as lines for a person. */
|
|
197
215
|
export function formatPoints(doc: Extract<PointsDocument, { points: unknown }>): string {
|
|
198
216
|
const lines: string[] = [];
|
|
@@ -27,6 +27,10 @@ import { workspaceGraph } from "./graph-cli";
|
|
|
27
27
|
import graphSchema from "./graph.schema.json";
|
|
28
28
|
import { intentGraph } from "./intent";
|
|
29
29
|
import intentSchema from "./intent.schema.json";
|
|
30
|
+
import { intentRecord } from "./intent-record";
|
|
31
|
+
import intentRecordSchema from "./intent-record.schema.json";
|
|
32
|
+
import { workspacePatch } from "./patch";
|
|
33
|
+
import patchSchema from "./patch.schema.json";
|
|
30
34
|
import { runChecks } from "./lineage-check";
|
|
31
35
|
import { listWorkspace } from "./ls";
|
|
32
36
|
import lsSchema from "./ls.schema.json";
|
|
@@ -44,7 +48,7 @@ import statusSchema from "./status.schema.json";
|
|
|
44
48
|
const FIXTURE = join(REPO, "reference-workspace");
|
|
45
49
|
const TIMEOUT = 240_000;
|
|
46
50
|
|
|
47
|
-
const SCHEMAS = { ls: lsSchema, graph: graphSchema, check: checkSchema, status: statusSchema, records: recordsSchema, "records-since": recordsSinceSchema, intent: intentSchema, composites: compositesSchema, points: pointsSchema };
|
|
51
|
+
const SCHEMAS = { ls: lsSchema, graph: graphSchema, check: checkSchema, status: statusSchema, records: recordsSchema, "records-since": recordsSinceSchema, intent: intentSchema, "intent-record": intentRecordSchema, composites: compositesSchema, points: pointsSchema, patch: patchSchema };
|
|
48
52
|
|
|
49
53
|
/** This checkout's chant, started the way the CLI starts it, for members with no toolchain of their own. */
|
|
50
54
|
const reader: Toolchain = {
|
|
@@ -138,6 +142,30 @@ describe("every schema against the reference workspace (#2543)", () => {
|
|
|
138
142
|
}
|
|
139
143
|
});
|
|
140
144
|
|
|
145
|
+
test("graph --intent --record, in the working tree and at HEAD", async () => {
|
|
146
|
+
const { expectValid } = contract(intentRecordSchema);
|
|
147
|
+
for (const at of [undefined, "HEAD"]) {
|
|
148
|
+
const { doc, failed } = await intentRecord({ cwd: FIXTURE, record: "ref-002", at, kinds: ["decisions/decision.kind.mjs", "work/work.kind.mjs"] });
|
|
149
|
+
expectValid(doc);
|
|
150
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
151
|
+
expect(failed).toBe(false);
|
|
152
|
+
expect(doc.at).toBe(at ? head : null);
|
|
153
|
+
expect(doc.record.id).toBe("record:decision/ref-002");
|
|
154
|
+
expect(doc.record.decidedIn?.sha).toMatch(/^[0-9a-f]{40}$/);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("patch, for the last commit that changed the reference workspace", async () => {
|
|
159
|
+
const { expectValid } = contract(patchSchema);
|
|
160
|
+
const last = git(REPO, "log", "-1", "--format=%H", "--", "reference-workspace");
|
|
161
|
+
const { doc, failed } = await workspacePatch({ cwd: FIXTURE, range: last });
|
|
162
|
+
expectValid(doc);
|
|
163
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
164
|
+
expect(failed).toBe(false);
|
|
165
|
+
expect(doc.workspace).toEqual({ name: "reference", root: "reference-workspace" });
|
|
166
|
+
expect(doc.files.length).toBeGreaterThan(0);
|
|
167
|
+
});
|
|
168
|
+
|
|
141
169
|
test(
|
|
142
170
|
"graph --composites runs delivery's own component graph, and lists its app with the component that deploys it (#2662)",
|
|
143
171
|
async () => {
|
|
@@ -34,6 +34,7 @@ import { RECORD_FINDING_CODES } from "./checks/records";
|
|
|
34
34
|
import { ANSWER_WARNING_CODES } from "./points";
|
|
35
35
|
import { POINTS_ERROR_CODES, POINTS_SOURCE_REASON_CODES } from "./points-cli";
|
|
36
36
|
import { POINTS_WRITE_ERROR_CODES } from "./decide";
|
|
37
|
+
import { PATCH_ERROR_CODES } from "./patch";
|
|
37
38
|
|
|
38
39
|
const HERE = import.meta.dirname;
|
|
39
40
|
|
|
@@ -82,6 +83,7 @@ const PER_COMMAND: Record<string, readonly string[]> = {
|
|
|
82
83
|
POINTS_ERROR_CODES,
|
|
83
84
|
POINTS_SOURCE_REASON_CODES,
|
|
84
85
|
POINTS_WRITE_ERROR_CODES,
|
|
86
|
+
PATCH_ERROR_CODES,
|
|
85
87
|
};
|
|
86
88
|
|
|
87
89
|
/** Every string in an `enum` under a property named `code`, anywhere in a schema. */
|