@intentius/chant 0.86.0 → 0.88.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/handlers/misc.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +5 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/version.d.ts +8 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +13 -0
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/environments.d.ts +80 -0
- package/dist/workspace/environments.d.ts.map +1 -0
- package/dist/workspace/intent.d.ts +6 -1
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +20 -4
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +12 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +41 -0
- package/dist/workspace/records-close.d.ts.map +1 -0
- package/dist/workspace/records-since.d.ts +40 -1
- package/dist/workspace/records-since.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +117 -12
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +84 -14
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +6 -0
- package/dist/workspace/runtimes.d.ts.map +1 -1
- package/dist/workspace/session-kinds.d.ts +28 -0
- package/dist/workspace/session-kinds.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +2 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +127 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +7 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/work.d.ts +3 -3
- package/package.json +1 -1
- package/src/cli/handlers/misc.ts +1 -9
- package/src/cli/main.ts +29 -10
- package/src/cli/mcp/server.test.ts +14 -1
- package/src/cli/mcp/server.ts +3 -1
- package/src/cli/registry.ts +5 -0
- package/src/cli/version.ts +15 -0
- package/src/workspace/__fixtures__/sessions.ts +41 -0
- package/src/workspace/composites.schema.json +68 -3
- package/src/workspace/composites.test.ts +119 -5
- package/src/workspace/composites.ts +26 -7
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +23 -0
- package/src/workspace/environments.ts +165 -0
- package/src/workspace/intent-gaps.test.ts +217 -0
- package/src/workspace/intent.schema.json +23 -1
- package/src/workspace/intent.test.ts +2 -0
- package/src/workspace/intent.ts +35 -3
- package/src/workspace/read-contract.test.ts +3 -0
- package/src/workspace/reason-codes.test.ts +9 -3
- package/src/workspace/reason-codes.ts +24 -4
- package/src/workspace/record-assets.test.ts +4 -3
- package/src/workspace/records-amend.schema.json +30 -1
- package/src/workspace/records-cli.ts +113 -14
- package/src/workspace/records-close.schema.json +192 -0
- package/src/workspace/records-close.ts +129 -0
- package/src/workspace/records-contract.test.ts +4 -3
- package/src/workspace/records-new.schema.json +25 -0
- package/src/workspace/records-review.schema.json +81 -3
- package/src/workspace/records-sessions-write.test.ts +274 -0
- package/src/workspace/records-since.schema.json +27 -2
- package/src/workspace/records-since.ts +120 -6
- package/src/workspace/records-write-contract.test.ts +5 -1
- package/src/workspace/records-write.test.ts +4 -2
- package/src/workspace/records-write.ts +336 -43
- package/src/workspace/records.schema.json +37 -3
- package/src/workspace/records.ts +145 -25
- package/src/workspace/runtimes.ts +12 -3
- package/src/workspace/session-kinds.ts +79 -0
- package/src/workspace/status.ts +1 -1
- package/src/workspace/trust/record-seal.test.ts +315 -0
- package/src/workspace/trust/seal.test.ts +222 -0
- package/src/workspace/trust/seal.ts +289 -0
- package/src/workspace/trust/ssh-commit.ts +2 -2
- package/src/workspace/work.test.ts +3 -1
- package/src/workspace/work.ts +4 -4
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://intentius.io/chant/schemas/workspace/records-review/v1/records-review.schema.json",
|
|
4
4
|
"title": "chant workspace records review output",
|
|
5
|
-
"description": "What `chant workspace records review <id> --kind <kind file> --verdict <verdict> --by <principal>` prints (#2670): the record's path and id and the review entry appended, or the reason it wrote nothing. Version 1 of the write contract for records. The command writes one file or none and never commits. Readers ignore fields they do not know; a field is only ever added within a version. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Contract version 1 of this document is written by chant 0.86.0 and newer.",
|
|
5
|
+
"description": "What `chant workspace records review <id> --kind <kind file> --verdict <verdict> --by <principal>` prints (#2670): the record's path and id and the review entry appended, or the reason it wrote nothing. Version 1 of the write contract for records. The command writes one file or none, or with --session the record and the session (#2693), and never commits. Readers ignore fields they do not know; a field is only ever added within a version. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Contract version 1 of this document is written by chant 0.86.0 and newer.",
|
|
6
6
|
"oneOf": [
|
|
7
7
|
{
|
|
8
8
|
"$ref": "#/$defs/result"
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"reviewer": {
|
|
76
76
|
"type": "string",
|
|
77
77
|
"minLength": 1,
|
|
78
|
-
"description": "The principal given with --by, as given.
|
|
78
|
+
"description": "The principal given with --by, as given. The write does not check who it is. With --sign, the seal lets chant workspace records check it against the signers file at base (#2687)."
|
|
79
79
|
},
|
|
80
80
|
"verdict": {
|
|
81
81
|
"enum": [
|
|
@@ -101,7 +101,31 @@
|
|
|
101
101
|
"session": {
|
|
102
102
|
"type": "string",
|
|
103
103
|
"minLength": 1,
|
|
104
|
-
"description": "The session given with --session. Absent when none was given."
|
|
104
|
+
"description": "The session given with --session. Absent when none was given. Since #2693 it names an open session of a session kind whose subjects are this kind, found through the workspace declaration (session-unknown, session-not-open)."
|
|
105
|
+
},
|
|
106
|
+
"seal": {
|
|
107
|
+
"type": "object",
|
|
108
|
+
"description": "Added in contract 1 by #2687. With --sign only: an ssh signature by the key given (or git's user.signingkey) over <id>\\n<digest>\\n<verdict>\\n<reviewer>\\n<on>, in the ssh-keygen namespace chant-review. The write does not check the key against the signers file; chant workspace records reports whether the seal counts.",
|
|
109
|
+
"required": [
|
|
110
|
+
"signer",
|
|
111
|
+
"key",
|
|
112
|
+
"signature"
|
|
113
|
+
],
|
|
114
|
+
"properties": {
|
|
115
|
+
"signer": {
|
|
116
|
+
"type": "string",
|
|
117
|
+
"description": "The reviewer given with --by."
|
|
118
|
+
},
|
|
119
|
+
"key": {
|
|
120
|
+
"type": "string",
|
|
121
|
+
"pattern": "^SHA256:[A-Za-z0-9+/]+=*$",
|
|
122
|
+
"description": "The fingerprint of the key that signed, read back from the signature."
|
|
123
|
+
},
|
|
124
|
+
"signature": {
|
|
125
|
+
"type": "string",
|
|
126
|
+
"description": "The armored ssh signature."
|
|
127
|
+
}
|
|
128
|
+
}
|
|
105
129
|
}
|
|
106
130
|
}
|
|
107
131
|
},
|
|
@@ -120,6 +144,57 @@
|
|
|
120
144
|
"type": "string",
|
|
121
145
|
"description": "With --dry-run only: the whole text of the file the command would write."
|
|
122
146
|
},
|
|
147
|
+
"session": {
|
|
148
|
+
"type": "object",
|
|
149
|
+
"description": "Added in contract 1 by #2693, with --session only: the session record the verdict was also appended to, in the same command, so the session's verdicts list and the reviews that name the session never differ. Both files are validated before either is written.",
|
|
150
|
+
"required": [
|
|
151
|
+
"id",
|
|
152
|
+
"path",
|
|
153
|
+
"verdict"
|
|
154
|
+
],
|
|
155
|
+
"properties": {
|
|
156
|
+
"id": {
|
|
157
|
+
"type": "string"
|
|
158
|
+
},
|
|
159
|
+
"path": {
|
|
160
|
+
"type": "string",
|
|
161
|
+
"description": "The session file, relative to the repository root, with / separators."
|
|
162
|
+
},
|
|
163
|
+
"verdict": {
|
|
164
|
+
"type": "object",
|
|
165
|
+
"description": "The entry appended to the session's verdicts list (the field the session kind's session.verdicts names): the record's id, the principal given with --by, the verdict and the same digest as the review.",
|
|
166
|
+
"required": [
|
|
167
|
+
"record",
|
|
168
|
+
"principal",
|
|
169
|
+
"verdict",
|
|
170
|
+
"digest"
|
|
171
|
+
],
|
|
172
|
+
"properties": {
|
|
173
|
+
"record": {
|
|
174
|
+
"type": "string"
|
|
175
|
+
},
|
|
176
|
+
"principal": {
|
|
177
|
+
"type": "string"
|
|
178
|
+
},
|
|
179
|
+
"verdict": {
|
|
180
|
+
"enum": [
|
|
181
|
+
"agree",
|
|
182
|
+
"dissent",
|
|
183
|
+
"abstain"
|
|
184
|
+
]
|
|
185
|
+
},
|
|
186
|
+
"digest": {
|
|
187
|
+
"type": "string",
|
|
188
|
+
"pattern": "^[0-9a-f]{64}$"
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
"text": {
|
|
193
|
+
"type": "string",
|
|
194
|
+
"description": "With --dry-run only: the whole text the session file would hold."
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
},
|
|
123
198
|
"error": false
|
|
124
199
|
}
|
|
125
200
|
},
|
|
@@ -180,6 +255,9 @@
|
|
|
180
255
|
"review-unsupported",
|
|
181
256
|
"record-closed",
|
|
182
257
|
"review-note-required",
|
|
258
|
+
"review-sign-failed",
|
|
259
|
+
"session-unknown",
|
|
260
|
+
"session-not-open",
|
|
183
261
|
"record-unparseable",
|
|
184
262
|
"record-schema-invalid",
|
|
185
263
|
"record-id-duplicate",
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions from a UI (#2693): `records new` on a session kind records the
|
|
3
|
+
* commit it opened at, `records close` closes and seals a session in one
|
|
4
|
+
* write, `records review --session` refuses a session that does not exist or
|
|
5
|
+
* is not open and appends the verdict to the session too, and
|
|
6
|
+
* `records --since <session id>` compares the session's own revisions. The
|
|
7
|
+
* fixture is a copy of the reference workspace's decisions and design
|
|
8
|
+
* member with a declaration naming both kinds.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { cpSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
16
|
+
import { cleanScratch, commitAll, contract, git, REPO, scratchDir } from "./__fixtures__/contract-repo";
|
|
17
|
+
import { declareSessions, DECISIONS_KIND, newSessionFields, sessionsRepo, SESSIONS_KIND } from "./__fixtures__/sessions";
|
|
18
|
+
import { sessionSeal } from "./record-sessions";
|
|
19
|
+
import { parseFrontMatter, recordTextDigest } from "./records";
|
|
20
|
+
import { queryRecords } from "./records-cli";
|
|
21
|
+
import { closeRecord } from "./records-close";
|
|
22
|
+
import closeSchema from "./records-close.schema.json";
|
|
23
|
+
import { queryRecordsSince, type RecordsSinceDocument } from "./records-since";
|
|
24
|
+
import sinceSchema from "./records-since.schema.json";
|
|
25
|
+
import { amendRecord, newRecord, reviewRecord } from "./records-write";
|
|
26
|
+
import newSchema from "./records-new.schema.json";
|
|
27
|
+
import reviewSchema from "./records-review.schema.json";
|
|
28
|
+
|
|
29
|
+
afterAll(cleanScratch);
|
|
30
|
+
|
|
31
|
+
const close = contract(closeSchema);
|
|
32
|
+
const since = contract(sinceSchema);
|
|
33
|
+
const review = contract(reviewSchema);
|
|
34
|
+
const created = contract(newSchema);
|
|
35
|
+
|
|
36
|
+
const code = (doc: object) => ("error" in doc ? (doc as { error: { code: string } }).error.code : "ok");
|
|
37
|
+
const HEX = /^[0-9a-f]{40}$/;
|
|
38
|
+
|
|
39
|
+
/** The reference fixture with a declaration, committed. */
|
|
40
|
+
function declared(): string {
|
|
41
|
+
const root = sessionsRepo();
|
|
42
|
+
declareSessions(root);
|
|
43
|
+
commitAll(root, "declare");
|
|
44
|
+
return root;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function front(root: string, path: string): Record<string, unknown> {
|
|
48
|
+
const fm = parseFrontMatter(readFileSync(join(root, path), "utf-8"));
|
|
49
|
+
if (!fm.ok) throw new Error(fm.message);
|
|
50
|
+
return fm.value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Open S-0002 through records new, and commit it. */
|
|
54
|
+
async function opened(root: string): Promise<{ path: string; head: string }> {
|
|
55
|
+
const head = git(root, "rev-parse", "HEAD");
|
|
56
|
+
const doc = await newRecord({ kind: SESSIONS_KIND, fields: newSessionFields(), cwd: root });
|
|
57
|
+
created.expectValid(doc);
|
|
58
|
+
if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
|
|
59
|
+
expect(doc.id).toBe("S-0002");
|
|
60
|
+
commitAll(root, "open S-0002");
|
|
61
|
+
return { path: doc.path, head };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function sessions(root: string) {
|
|
65
|
+
const doc = await queryRecords({ kind: SESSIONS_KIND, cwd: root });
|
|
66
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
67
|
+
return doc;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("opening revisions (#2693)", () => {
|
|
71
|
+
test("records new on a session kind writes opened_rev, the commit HEAD names", async () => {
|
|
72
|
+
const root = declared();
|
|
73
|
+
const { path, head } = await opened(root);
|
|
74
|
+
expect(front(root, path).opened_rev).toBe(head);
|
|
75
|
+
expect((await sessions(root)).records.find((r) => r.id === "S-0002")).toMatchObject({ state: "open", valid: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("before the first commit it is null, and the next amend fills it", async () => {
|
|
79
|
+
const root = scratchDir("chant-sessions-nocommit-");
|
|
80
|
+
for (const d of ["decisions", "design"]) cpSync(join(REPO, "reference-workspace", d), join(root, d), { recursive: true });
|
|
81
|
+
declareSessions(root);
|
|
82
|
+
git(root, "init", "-q");
|
|
83
|
+
const doc = await newRecord({ kind: SESSIONS_KIND, fields: newSessionFields(), cwd: root });
|
|
84
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
85
|
+
expect(front(root, doc.path).opened_rev).toBeNull();
|
|
86
|
+
const head = commitAll(root, "first");
|
|
87
|
+
const amended = await amendRecord({ kind: SESSIONS_KIND, id: "S-0002", fields: "{}", cwd: root });
|
|
88
|
+
expect(amended).toMatchObject({ changed: ["opened_rev"] });
|
|
89
|
+
expect(front(root, doc.path).opened_rev).toBe(head);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a caller can't set the opening or closing revision", async () => {
|
|
93
|
+
const root = declared();
|
|
94
|
+
expect(code(await newRecord({ kind: SESSIONS_KIND, fields: newSessionFields({ opened_rev: "a".repeat(40) }), cwd: root }))).toBe("write-input-invalid");
|
|
95
|
+
await opened(root);
|
|
96
|
+
expect(code(await amendRecord({ kind: SESSIONS_KIND, id: "S-0002", fields: JSON.stringify({ opened_rev: "b".repeat(40) }), cwd: root }))).toBe("write-input-invalid");
|
|
97
|
+
expect(code(await amendRecord({ kind: SESSIONS_KIND, id: "S-0002", fields: JSON.stringify({ closed_rev: "b".repeat(40) }), cwd: root }))).toBe("write-input-invalid");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("records close (#2693)", () => {
|
|
102
|
+
test("sets the state, the close time, the closing revision and the seal in one write, and the session reads back sealed", async () => {
|
|
103
|
+
const root = declared();
|
|
104
|
+
const { path } = await opened(root);
|
|
105
|
+
const head = git(root, "rev-parse", "HEAD");
|
|
106
|
+
const dry = await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", dryRun: true, cwd: root, now: new Date("2026-09-25T10:30:00.123Z") });
|
|
107
|
+
close.expectValid(dry);
|
|
108
|
+
expect(dry).toMatchObject({ dryRun: true, closedRev: head, text: expect.stringContaining('state: "closed"') });
|
|
109
|
+
expect(front(root, path).state).toBe("open");
|
|
110
|
+
|
|
111
|
+
const doc = await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", cwd: root, now: new Date("2026-09-25T10:30:00.123Z") });
|
|
112
|
+
close.expectValid(doc);
|
|
113
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
114
|
+
expect(doc.changed.sort()).toEqual(["closed", "closed_digest", "closed_rev", "state"]);
|
|
115
|
+
const fm = front(root, path);
|
|
116
|
+
expect(fm).toMatchObject({ state: "closed", closed: "2026-09-25T10:30:00Z", closed_rev: head, opened_rev: expect.stringMatching(HEX) });
|
|
117
|
+
const text = readFileSync(join(root, path), "utf-8");
|
|
118
|
+
expect(fm.closed_digest).toBe(sessionSeal(text, "closed_digest"));
|
|
119
|
+
expect(doc.seal).toEqual({ field: "closed_digest", digest: fm.closed_digest });
|
|
120
|
+
expect((await sessions(root)).records.find((r) => r.id === "S-0002")).toMatchObject({ state: "closed", valid: true, reasons: [] });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("refuses a closed session, an unknown id and a verdict naming a record the subjects lack", async () => {
|
|
124
|
+
const root = declared();
|
|
125
|
+
const s1 = await closeRecord({ kind: SESSIONS_KIND, id: "S-0001", cwd: root });
|
|
126
|
+
close.expectValid(s1);
|
|
127
|
+
expect(code(s1)).toBe("record-closed");
|
|
128
|
+
expect(code(await closeRecord({ kind: SESSIONS_KIND, id: "S-0404", cwd: root }))).toBe("record-not-found");
|
|
129
|
+
expect(code(await closeRecord({ kind: DECISIONS_KIND, id: "ref-001", cwd: root }))).toBe("write-usage-invalid");
|
|
130
|
+
await opened(root);
|
|
131
|
+
const file = join(root, "design", "sessions", "S-0002-second-walk.md");
|
|
132
|
+
writeFileSync(file, readFileSync(file, "utf-8").replace("verdicts: []", 'verdicts:\n - record: "ref-999"\n principal: "alice"\n verdict: "agree"'));
|
|
133
|
+
const before = readFileSync(file, "utf-8");
|
|
134
|
+
const bad = await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", cwd: root });
|
|
135
|
+
close.expectValid(bad);
|
|
136
|
+
expect(code(bad)).toBe("session-verdict-unknown-record");
|
|
137
|
+
expect(readFileSync(file, "utf-8")).toBe(before);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("amend's refusal on a closed session does not advise a supersedes field the session schema lacks", async () => {
|
|
141
|
+
const root = declared();
|
|
142
|
+
const doc = await amendRecord({ kind: SESSIONS_KIND, id: "S-0001", fields: JSON.stringify({ title: "x" }), cwd: root });
|
|
143
|
+
expect(code(doc)).toBe("record-closed");
|
|
144
|
+
const message = "error" in doc ? doc.error.message : "";
|
|
145
|
+
expect(message).not.toContain("supersedes");
|
|
146
|
+
expect(message).toContain("open a new session");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("records review --session (#2693)", () => {
|
|
151
|
+
test("refuses a session that does not exist and one that is closed, writing nothing", async () => {
|
|
152
|
+
const root = declared();
|
|
153
|
+
const decision = join(root, "decisions", "ref-001-how-the-app-is-deployed.md");
|
|
154
|
+
const before = readFileSync(decision, "utf-8");
|
|
155
|
+
const unknown = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-9999", cwd: root });
|
|
156
|
+
review.expectValid(unknown);
|
|
157
|
+
expect(code(unknown)).toBe("session-unknown");
|
|
158
|
+
const closed = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0001", cwd: root });
|
|
159
|
+
review.expectValid(closed);
|
|
160
|
+
expect(code(closed)).toBe("session-not-open");
|
|
161
|
+
expect(readFileSync(decision, "utf-8")).toBe(before);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("a verdict given in a session shows on both the decision and the session, with the same digest", async () => {
|
|
165
|
+
const root = declared();
|
|
166
|
+
const { path } = await opened(root);
|
|
167
|
+
const decision = join(root, "decisions", "ref-001-how-the-app-is-deployed.md");
|
|
168
|
+
const digest = recordTextDigest(readFileSync(decision, "utf-8"));
|
|
169
|
+
|
|
170
|
+
const dry = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0002", dryRun: true, cwd: root });
|
|
171
|
+
review.expectValid(dry);
|
|
172
|
+
expect(dry).toMatchObject({ session: { id: "S-0002", path, text: expect.stringContaining('principal: "alice"') } });
|
|
173
|
+
expect(front(root, path).verdicts).toEqual([]);
|
|
174
|
+
|
|
175
|
+
const doc = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0002", on: "2026-09-25", cwd: root });
|
|
176
|
+
review.expectValid(doc);
|
|
177
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
178
|
+
const verdict = { record: "ref-001", principal: "alice", verdict: "agree", digest };
|
|
179
|
+
expect(doc.session).toEqual({ id: "S-0002", path, verdict });
|
|
180
|
+
expect(front(root, "decisions/ref-001-how-the-app-is-deployed.md").reviews).toEqual([{ reviewer: "alice", verdict: "agree", on: "2026-09-25", digest, session: "S-0002" }]);
|
|
181
|
+
expect(front(root, path).verdicts).toEqual([verdict]);
|
|
182
|
+
|
|
183
|
+
const s2 = (await sessions(root)).records.find((r) => r.id === "S-0002")!;
|
|
184
|
+
expect(s2).toMatchObject({ valid: true, citedBy: [{ id: "ref-001", index: 0, reviewer: "alice", verdict: "agree" }] });
|
|
185
|
+
expect(s2.data?.verdicts).toEqual([verdict]);
|
|
186
|
+
|
|
187
|
+
// The session closes over the verdict, and a later one is refused.
|
|
188
|
+
close.expectValid(await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", cwd: root }));
|
|
189
|
+
expect(code(await reviewRecord({ kind: DECISIONS_KIND, id: "ref-002", verdict: "agree", by: "bob", session: "S-0002", cwd: root }))).toBe("session-not-open");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("records --since <session id> (#2693)", () => {
|
|
194
|
+
async function sinceDoc(q: { kind: string; since: string; at?: string; cwd: string }): Promise<Extract<RecordsSinceDocument, { changes: unknown }>> {
|
|
195
|
+
const doc = await queryRecordsSince(q);
|
|
196
|
+
since.expectValid(doc);
|
|
197
|
+
if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
|
|
198
|
+
return doc;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
test("an open session compares its opening revision with the working tree, and a closed one its opening and closing revisions", async () => {
|
|
202
|
+
const root = declared();
|
|
203
|
+
const { head } = await opened(root);
|
|
204
|
+
await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0002", on: "2026-09-25", cwd: root });
|
|
205
|
+
|
|
206
|
+
const open = await sinceDoc({ kind: DECISIONS_KIND, since: "S-0002", cwd: root });
|
|
207
|
+
expect(open.since).toBe(head);
|
|
208
|
+
expect(open.at).toBeNull();
|
|
209
|
+
expect(open.session).toMatchObject({ id: "S-0002", state: "open", sinceFrom: "opened-rev", atFrom: "working-tree", reasons: [{ code: "since-session-open" }] });
|
|
210
|
+
expect(open.changes).toEqual([{ change: "verdict", id: "ref-001", principal: "alice", verdict: "agree", index: 0, session: "S-0002" }]);
|
|
211
|
+
|
|
212
|
+
// Closed but not committed: closed_rev is HEAD before the close, and the close is only in the working tree.
|
|
213
|
+
const closing = await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", cwd: root });
|
|
214
|
+
expect(closing).toMatchObject({ closedRev: git(root, "rev-parse", "HEAD") });
|
|
215
|
+
expect((await sinceDoc({ kind: DECISIONS_KIND, since: "S-0002", cwd: root })).session).toMatchObject({ state: "closed", atFrom: "working-tree", reasons: [] });
|
|
216
|
+
const closeCommit = commitAll(root, "the verdict and the close");
|
|
217
|
+
await reviewRecord({ kind: DECISIONS_KIND, id: "ref-002", verdict: "agree", by: "bob", cwd: root });
|
|
218
|
+
|
|
219
|
+
const done = await sinceDoc({ kind: DECISIONS_KIND, since: "S-0002", cwd: root });
|
|
220
|
+
expect(done).toMatchObject({ since: head, at: closeCommit, session: { state: "closed", sinceFrom: "opened-rev", atFrom: "close-commit", reasons: [] } });
|
|
221
|
+
// The later review of ref-002 came after the session closed, so it is not listed.
|
|
222
|
+
expect(done.changes).toEqual([{ change: "verdict", id: "ref-001", principal: "alice", verdict: "agree", index: 0, session: "S-0002" }]);
|
|
223
|
+
// The session was written after its opening commit, so it is new, with the verdict it produced.
|
|
224
|
+
const own = await sinceDoc({ kind: SESSIONS_KIND, since: "S-0002", cwd: root });
|
|
225
|
+
expect(own.changes).toEqual([
|
|
226
|
+
{ change: "new", id: "S-0002", path: "design/sessions/S-0002-second-walk.md", state: "closed" },
|
|
227
|
+
{ change: "verdict", id: "S-0002", principal: "alice", verdict: "agree", index: 0, record: "ref-001" },
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("a session without revision fields falls back to its file's history, and an unknown id is since-session-unknown", async () => {
|
|
232
|
+
const root = declared();
|
|
233
|
+
const added = git(root, "log", "--diff-filter=A", "--format=%H", "--", "design/sessions/S-0001-first-walk-of-the-reference-decisions.md");
|
|
234
|
+
const s1 = await sinceDoc({ kind: SESSIONS_KIND, since: "S-0001", cwd: root });
|
|
235
|
+
expect(s1).toMatchObject({ since: added, at: added, session: { sinceFrom: "history", atFrom: "history" }, changes: [] });
|
|
236
|
+
const unknown = await queryRecordsSince({ kind: DECISIONS_KIND, since: "S-9999", cwd: root });
|
|
237
|
+
since.expectValid(unknown);
|
|
238
|
+
expect(code(unknown)).toBe("since-session-unknown");
|
|
239
|
+
// An id-shaped value that names a commit, such as a tag, is still read as the commit.
|
|
240
|
+
git(root, "tag", "v-1");
|
|
241
|
+
expect((await sinceDoc({ kind: DECISIONS_KIND, since: "v-1", cwd: root })).session).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test(
|
|
245
|
+
"through the CLI: close with the declared session kind, and --since <session id> without --kind",
|
|
246
|
+
async () => {
|
|
247
|
+
const root = declared();
|
|
248
|
+
await opened(root);
|
|
249
|
+
const run = (...args: string[]) =>
|
|
250
|
+
spawnSync(process.execPath, ["--import", pathToFileURL(join(REPO, "node_modules/tsx/dist/loader.mjs")).href, join(REPO, "packages/core/src/cli/main.ts"), "workspace", "records", ...args], {
|
|
251
|
+
cwd: root,
|
|
252
|
+
encoding: "utf-8",
|
|
253
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
254
|
+
timeout: 60_000,
|
|
255
|
+
});
|
|
256
|
+
const closed = run("close", "S-0002");
|
|
257
|
+
expect(closed.status, closed.stderr).toBe(0);
|
|
258
|
+
close.expectValid(JSON.parse(closed.stdout));
|
|
259
|
+
const again = run("close", "S-0002");
|
|
260
|
+
expect(again.status).toBe(1);
|
|
261
|
+
expect(JSON.parse(again.stdout).error.code).toBe("record-closed");
|
|
262
|
+
commitAll(root, "close");
|
|
263
|
+
const set = run("--since", "S-0002", "--json");
|
|
264
|
+
expect(set.status, set.stderr).toBe(0);
|
|
265
|
+
const kinds = JSON.parse(set.stdout).kinds as RecordsSinceDocument[];
|
|
266
|
+
for (const k of kinds) since.expectValid(k);
|
|
267
|
+
expect(kinds.map((k) => ("session" in k ? k.session?.id : null))).toEqual(["S-0002", "S-0002"]);
|
|
268
|
+
const text = run("--kind", SESSIONS_KIND, "--since", "S-0002");
|
|
269
|
+
expect(text.stdout).toContain("session S-0002 (closed)");
|
|
270
|
+
expect(text.stdout).toContain("S-0002 new, closed");
|
|
271
|
+
},
|
|
272
|
+
120_000,
|
|
273
|
+
);
|
|
274
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://intentius.io/chant/schemas/workspace/records-since/v1/records-since.schema.json",
|
|
4
4
|
"title": "chant workspace records --since output",
|
|
5
|
-
"description": "What `chant workspace records --kind <kind file> --since <rev> --json` prints (#2673, #2650 C11): what changed in a kind's records between the commit --since names and the tree --at names, or the working tree without it. Records are matched by id; a record whose id could not be read is not compared. Between a review session's open and close commits, the changes are what the session did. Readers ignore fields they do not know; a field is only ever added within a version. The change kinds and the error codes are closed lists. Contract version 1 is written by chant 0.81.0 and newer, and this document was added to it by #2673; a chant without --since on records refuses the flag. Every code is in the one closed list of `reason-codes.ts`.",
|
|
5
|
+
"description": "What `chant workspace records --kind <kind file> --since <rev|session id> --json` prints (#2673, #2650 C11): what changed in a kind's records between the commit --since names and the tree --at names, or the working tree without it. Records are matched by id; a record whose id could not be read is not compared. Between a review session's open and close commits, the changes are what the session did. Readers ignore fields they do not know; a field is only ever added within a version. The change kinds and the error codes are closed lists. Contract version 1 is written by chant 0.81.0 and newer, and this document was added to it by #2673; a chant without --since on records refuses the flag. Every code is in the one closed list of `reason-codes.ts`.",
|
|
6
6
|
"oneOf": [{ "$ref": "#/$defs/result" }, { "$ref": "#/$defs/failure" }, { "$ref": "#/$defs/set" }],
|
|
7
7
|
"$defs": {
|
|
8
8
|
"set": {
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
},
|
|
60
60
|
"since": { "type": "string", "pattern": "^[0-9a-f]{40,64}$", "description": "The full commit id --since names." },
|
|
61
61
|
"at": { "type": ["string", "null"], "pattern": "^[0-9a-f]{40,64}$", "description": "The full commit id --at names, or null for the working tree." },
|
|
62
|
+
"session": { "$ref": "#/$defs/session" },
|
|
62
63
|
"error": false,
|
|
63
64
|
"changes": {
|
|
64
65
|
"description": "In the order new, removed, state, verdict, supersession, pin, and by id within each.",
|
|
@@ -80,6 +81,29 @@
|
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
},
|
|
84
|
+
"session": {
|
|
85
|
+
"description": "Added in contract 1 by #2693, with --since <session id> only: the session named, and where since and at came from. since is the commit the session opened at: its opening revision field (the kind's session.openedRev), or, for a session without one, the latest commit that added its file (history). at is the commit that carries the close: the first commit after its closing revision field (session.closedRev, HEAD when records close wrote the close) that changed the session file (close-commit), or, for a session without that field, the last commit that changed its file (history); --at overrides it (at). An open session, or one whose close is not committed yet, is compared with the working tree (working-tree), and an open one carries since-session-open.",
|
|
86
|
+
"type": "object",
|
|
87
|
+
"required": ["id", "path", "state", "sinceFrom", "atFrom", "reasons"],
|
|
88
|
+
"properties": {
|
|
89
|
+
"id": { "type": "string" },
|
|
90
|
+
"path": { "type": "string", "description": "The session file, from the repository root, with / separators." },
|
|
91
|
+
"state": { "type": ["string", "null"] },
|
|
92
|
+
"sinceFrom": { "enum": ["opened-rev", "history"] },
|
|
93
|
+
"atFrom": { "enum": ["close-commit", "history", "at", "working-tree"] },
|
|
94
|
+
"reasons": {
|
|
95
|
+
"type": "array",
|
|
96
|
+
"items": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"required": ["code", "message"],
|
|
99
|
+
"properties": {
|
|
100
|
+
"code": { "enum": ["since-session-open"] },
|
|
101
|
+
"message": { "type": "string" }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
83
107
|
"change": {
|
|
84
108
|
"oneOf": [
|
|
85
109
|
{ "$ref": "#/$defs/new" },
|
|
@@ -180,7 +204,8 @@
|
|
|
180
204
|
"location-missing",
|
|
181
205
|
"not-a-git-repository",
|
|
182
206
|
"revision-unknown",
|
|
183
|
-
"since-rev-unknown"
|
|
207
|
+
"since-rev-unknown",
|
|
208
|
+
"since-session-unknown"
|
|
184
209
|
]
|
|
185
210
|
},
|
|
186
211
|
"message": { "type": "string" }
|
|
@@ -9,6 +9,18 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Records are matched by id. A record whose id could not be read is left
|
|
11
11
|
* out of the comparison, since nothing says which record it was before.
|
|
12
|
+
*
|
|
13
|
+
* `--since <session id>` (#2693) compares the commit a review session
|
|
14
|
+
* opened at with the commit that carries its close. The first is the
|
|
15
|
+
* record's opening revision (the kind's `session.openedRev` field). The
|
|
16
|
+
* record's closing revision (`session.closedRev`) is HEAD when `records
|
|
17
|
+
* close` wrote the close, before the caller committed it, so the close and
|
|
18
|
+
* the verdicts committed with it are in the first commit after it that
|
|
19
|
+
* changed the session file. A session written before chant wrote these
|
|
20
|
+
* fields falls back to its file's history: the commit that added it and the
|
|
21
|
+
* last commit that changed it. An open session, or one whose close is not
|
|
22
|
+
* committed yet, is compared with the working tree, and an open one says so
|
|
23
|
+
* with `since-session-open`.
|
|
12
24
|
*/
|
|
13
25
|
|
|
14
26
|
import { realpathSync } from "node:fs";
|
|
@@ -16,8 +28,9 @@ import { relative } from "node:path";
|
|
|
16
28
|
import { pinEntries } from "./record-assets";
|
|
17
29
|
import { gitRoot, resolveRevision } from "./record-source";
|
|
18
30
|
import { READ_ERROR_CODES, RecordReadError, type LoadedRecordKind, type ReadErrorCode, type RecordEntry, type RecordKind } from "./records";
|
|
19
|
-
import { readRecordsFor, RECORDS_CONTRACT_VERSION } from "./records-cli";
|
|
31
|
+
import { readRecordsFor, realpathOr, RECORDS_CONTRACT_VERSION } from "./records-cli";
|
|
20
32
|
import type { ReasonCode } from "./reason-codes";
|
|
33
|
+
import { commitsTouching, findSessionKinds, SESSION_ID } from "./session-kinds";
|
|
21
34
|
|
|
22
35
|
/** `$id` of the JSON Schema for `records --since --json`, shipped beside this file. */
|
|
23
36
|
export const RECORDS_SINCE_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records-since/v1/records-since.schema.json";
|
|
@@ -27,9 +40,38 @@ export const RECORDS_SINCE_ERROR_CODES = [
|
|
|
27
40
|
...READ_ERROR_CODES,
|
|
28
41
|
/** `--since` names no commit. */
|
|
29
42
|
"since-rev-unknown",
|
|
43
|
+
/** `--since` has a session id's shape, names no commit, and no session has the id (#2693). */
|
|
44
|
+
"since-session-unknown",
|
|
30
45
|
] as const satisfies readonly ReasonCode[];
|
|
31
46
|
export type RecordsSinceErrorCode = (typeof RECORDS_SINCE_ERROR_CODES)[number];
|
|
32
47
|
|
|
48
|
+
/** Why a session's comparison is not the one between its open and close commits (#2693). Closed. */
|
|
49
|
+
export const RECORDS_SINCE_REASON_CODES = [
|
|
50
|
+
/** The session is open, so the comparison runs to the working tree. */
|
|
51
|
+
"since-session-open",
|
|
52
|
+
] as const satisfies readonly ReasonCode[];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The session `--since` named (#2693), and where the two revisions came
|
|
56
|
+
* from: the record's own fields, or git's history of its file for a session
|
|
57
|
+
* written before chant wrote them.
|
|
58
|
+
*/
|
|
59
|
+
export interface SinceSession {
|
|
60
|
+
id: string;
|
|
61
|
+
/** The session file, from the repository root, with / separators. */
|
|
62
|
+
path: string;
|
|
63
|
+
state: string | null;
|
|
64
|
+
/** Where `since` came from: the session's opening revision field, or the last commit that added its file. */
|
|
65
|
+
sinceFrom: "opened-rev" | "history";
|
|
66
|
+
/**
|
|
67
|
+
* Where `at` came from: the first commit after the closing revision that
|
|
68
|
+
* changed the session file (the commit carrying the close), the last
|
|
69
|
+
* commit that changed the file, --at, or the working tree.
|
|
70
|
+
*/
|
|
71
|
+
atFrom: "close-commit" | "history" | "at" | "working-tree";
|
|
72
|
+
reasons: { code: (typeof RECORDS_SINCE_REASON_CODES)[number]; message: string }[];
|
|
73
|
+
}
|
|
74
|
+
|
|
33
75
|
/** The kinds of change, in the order the output lists them. */
|
|
34
76
|
export const SINCE_CHANGE_KINDS = ["new", "removed", "state", "verdict", "supersession", "pin"] as const;
|
|
35
77
|
export type SinceChangeKind = (typeof SINCE_CHANGE_KINDS)[number];
|
|
@@ -52,6 +94,8 @@ export type RecordsSinceDocument =
|
|
|
52
94
|
kind: { name: string; schema: string; file: string };
|
|
53
95
|
since: string;
|
|
54
96
|
at: string | null;
|
|
97
|
+
/** With `--since <session id>` only. */
|
|
98
|
+
session?: SinceSession;
|
|
55
99
|
changes: SinceChange[];
|
|
56
100
|
summary: Record<SinceChangeKind, number>;
|
|
57
101
|
}
|
|
@@ -81,8 +125,10 @@ export async function queryRecordsSince(query: RecordsSinceQuery): Promise<Recor
|
|
|
81
125
|
error: { code, message },
|
|
82
126
|
});
|
|
83
127
|
try {
|
|
84
|
-
const
|
|
85
|
-
const
|
|
128
|
+
const found = await findSinceSession(query);
|
|
129
|
+
const since = resolveSince(query.cwd, found ? found.since : query.since, found?.session.id);
|
|
130
|
+
const at = found ? (found.at ?? undefined) : query.at;
|
|
131
|
+
const after = await readRecordsFor({ kind: query.kind, at, cwd: query.cwd });
|
|
86
132
|
let before: RecordEntry[];
|
|
87
133
|
try {
|
|
88
134
|
before = (await readRecordsFor({ kind: query.kind, at: since, cwd: query.cwd })).result.records;
|
|
@@ -99,6 +145,7 @@ export async function queryRecordsSince(query: RecordsSinceQuery): Promise<Recor
|
|
|
99
145
|
kind: kindView(after.loaded, after.root),
|
|
100
146
|
since,
|
|
101
147
|
at: after.at,
|
|
148
|
+
...(found ? { session: found.session } : {}),
|
|
102
149
|
changes,
|
|
103
150
|
summary,
|
|
104
151
|
};
|
|
@@ -113,8 +160,71 @@ function kindView(loaded: LoadedRecordKind, root: string): { name: string; schem
|
|
|
113
160
|
return { name: loaded.kind.name, schema: loaded.kind.schema.id, file: relative(root, loaded.file).split("\\").join("/") };
|
|
114
161
|
}
|
|
115
162
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
163
|
+
/**
|
|
164
|
+
* The session `query.since` names, with the revisions to compare, or null
|
|
165
|
+
* when it names none and is to be read as a revision. It is looked up only
|
|
166
|
+
* in a git repository, when it has a session id's shape, in the kind read
|
|
167
|
+
* when that is a session kind and in the session kinds the declaration
|
|
168
|
+
* names. An id-shaped value that is neither a session nor a commit is
|
|
169
|
+
* `since-session-unknown`.
|
|
170
|
+
*/
|
|
171
|
+
async function findSinceSession(query: RecordsSinceQuery): Promise<{ since: string; at: string | null; session: SinceSession } | null> {
|
|
172
|
+
const top = gitRoot(realpathOr(query.cwd));
|
|
173
|
+
if (!top || !SESSION_ID.test(query.since)) return null;
|
|
174
|
+
const kinds = await findSessionKinds(query.cwd, [query.kind]);
|
|
175
|
+
for (const k of kinds) {
|
|
176
|
+
let records: RecordEntry[];
|
|
177
|
+
try {
|
|
178
|
+
records = (await readRecordsFor({ kind: k.file, cwd: query.cwd })).result.records;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (err instanceof RecordReadError) continue;
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
const s = records.find((r) => r.id === query.since);
|
|
184
|
+
if (!s) continue;
|
|
185
|
+
const decl = k.kind.session!;
|
|
186
|
+
const field = (f: string | undefined): string | null => (f !== undefined && typeof s.data?.[f] === "string" ? (s.data[f] as string) : null);
|
|
187
|
+
const closed = s.state !== null && (k.kind.closedStates ?? []).includes(s.state);
|
|
188
|
+
const session: SinceSession = { id: s.id!, path: s.path, state: s.state, sinceFrom: "opened-rev", atFrom: "working-tree", reasons: [] };
|
|
189
|
+
let since = field(decl.openedRev);
|
|
190
|
+
if (since === null) {
|
|
191
|
+
since = commitsTouching(top, s.path, ["--diff-filter=A"])[0] ?? null;
|
|
192
|
+
session.sinceFrom = "history";
|
|
193
|
+
if (since === null) {
|
|
194
|
+
throw new SinceError("since-rev-unknown", `session ${s.id} (${s.path}) names no commit it opened at${decl.openedRev ? ` in ${decl.openedRev}` : ""}, and no commit added its file`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
let at: string | null = null;
|
|
198
|
+
if (query.at !== undefined) {
|
|
199
|
+
at = query.at;
|
|
200
|
+
session.atFrom = "at";
|
|
201
|
+
} else if (closed) {
|
|
202
|
+
const closedRev = field(decl.closedRev);
|
|
203
|
+
if (closedRev !== null && /^[0-9a-f]{40,64}$/.test(closedRev)) {
|
|
204
|
+
// The oldest of these is the commit that carried the close. None yet means the close is only in the working tree.
|
|
205
|
+
at = commitsTouching(top, s.path, [`${closedRev}..HEAD`]).slice(-1)[0] ?? null;
|
|
206
|
+
session.atFrom = at === null ? "working-tree" : "close-commit";
|
|
207
|
+
} else {
|
|
208
|
+
at = commitsTouching(top, s.path, ["-1"])[0] ?? null;
|
|
209
|
+
session.atFrom = at === null ? "working-tree" : "history";
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
session.reasons.push({ code: "since-session-open", message: `session ${s.id} is ${s.state ?? "not closed"}, so it has no closing revision, and the comparison runs to the working tree` });
|
|
213
|
+
}
|
|
214
|
+
return { since, at, session };
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
resolveRevision(top, query.since);
|
|
218
|
+
return null;
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if (!(err instanceof RecordReadError)) throw err;
|
|
221
|
+
}
|
|
222
|
+
if (kinds.length === 0) return null;
|
|
223
|
+
throw new SinceError("since-session-unknown", `--since ${query.since} names no commit, and no ${[...new Set(kinds.map((k) => k.kind.name))].join(" or ")} record has that id`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The full commit id `rev` names, from the repository holding `cwd`; `session` names the session it came from. */
|
|
227
|
+
function resolveSince(cwd: string, rev: string, session?: string): string {
|
|
118
228
|
let dir = cwd;
|
|
119
229
|
try {
|
|
120
230
|
dir = realpathSync(cwd);
|
|
@@ -127,7 +237,7 @@ function resolveSince(cwd: string, rev: string): string {
|
|
|
127
237
|
return resolveRevision(top, rev);
|
|
128
238
|
} catch (err) {
|
|
129
239
|
if (err instanceof RecordReadError && err.code === "revision-unknown") {
|
|
130
|
-
throw new SinceError("since-rev-unknown", `--since ${rev} names no commit in this repository`);
|
|
240
|
+
throw new SinceError("since-rev-unknown", session ? `session ${session} names ${rev}, which is no commit in this repository` : `--since ${rev} names no commit in this repository`);
|
|
131
241
|
}
|
|
132
242
|
throw err;
|
|
133
243
|
}
|
|
@@ -252,6 +362,10 @@ export function formatSince(doc: Extract<RecordsSinceDocument, { changes: SinceC
|
|
|
252
362
|
}
|
|
253
363
|
});
|
|
254
364
|
const s = doc.summary;
|
|
365
|
+
if (doc.session) {
|
|
366
|
+
const x = doc.session;
|
|
367
|
+
lines.unshift(`session ${x.id} (${x.state ?? "no state"}): opened at ${doc.since.slice(0, 8)} (${x.sinceFrom}), compared to ${doc.at ? `${doc.at.slice(0, 8)} (${x.atFrom})` : "the working tree"}`);
|
|
368
|
+
}
|
|
255
369
|
lines.push(
|
|
256
370
|
`${doc.changes.length} changes since ${doc.since.slice(0, 8)}${doc.at ? ` to ${doc.at.slice(0, 8)}` : " to the working tree"}: ${s.new} new, ${s.removed} removed, ${s.state} state, ${s.verdict} verdicts, ${s.supersession} supersessions, ${s.pin} pins`,
|
|
257
371
|
);
|