ultimate-brain 0.3.1 → 0.4.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/.agents/skills/brain-apply/SKILL.md +10 -6
- package/.agents/skills/brain-catch-up/SKILL.md +27 -14
- package/.agents/skills/brain-connect-notion/SKILL.md +2 -2
- package/.agents/skills/brain-howto/references/guide.md +44 -11
- package/.agents/skills/brain-ingest/SKILL.md +14 -8
- package/.agents/skills/brain-ingest/references/cli-inputs.md +19 -2
- package/.agents/skills/brain-ingest/references/extraction.md +11 -7
- package/.agents/skills/brain-ingest/references/notion.md +25 -11
- package/.agents/skills/brain-ingest/references/sources.md +24 -1
- package/.agents/skills/brain-notion-change/SKILL.md +16 -2
- package/.agents/skills/brain-query/SKILL.md +22 -2
- package/.agents/skills/brain-sync/SKILL.md +6 -4
- package/.agents/skills/brain-sync/references/formats.md +1 -1
- package/README.md +1 -1
- package/dist/cli.js +5 -2
- package/dist/commands/proposal.js +21 -1
- package/dist/commands/repo.js +110 -0
- package/dist/commands/report.js +115 -0
- package/dist/commands/source.js +56 -0
- package/dist/commands/status.js +4 -1
- package/dist/commands/views.js +30 -15
- package/dist/lib/apply.js +83 -10
- package/dist/lib/errors.js +3 -0
- package/dist/lib/frontmatter.js +9 -4
- package/dist/lib/lint.js +34 -5
- package/dist/lib/note-new.js +44 -0
- package/dist/lib/notes.js +1 -0
- package/dist/lib/parts.js +82 -0
- package/dist/lib/proposals.js +183 -8
- package/dist/lib/repos.js +129 -0
- package/dist/lib/sync.js +167 -28
- package/dist/lib/views.js +53 -15
- package/package.json +1 -1
- package/spec/schemas/brain.schema.json +46 -0
- package/spec/schemas/note.schema.json +343 -3
- package/spec/schemas/proposal.schema.json +393 -10
- package/templates/brain/AGENTS.md +4 -4
- package/templates/brain/brain.json +2 -1
package/dist/lib/proposals.js
CHANGED
|
@@ -8,15 +8,15 @@ import { compareCodePoints } from "./notes.js";
|
|
|
8
8
|
import { newNotePath } from "./paths.js";
|
|
9
9
|
import { loadSchemas } from "./schemas.js";
|
|
10
10
|
import { formatDate } from "./time.js";
|
|
11
|
-
export const PROPOSAL_KINDS = ["update_fact", "add_to_note", "add_link", "supersede", "contradiction", "merge", "new_item"];
|
|
11
|
+
export const PROPOSAL_KINDS = ["update_fact", "add_to_note", "add_link", "supersede", "contradiction", "merge", "new_item", "review_notes", "answer"];
|
|
12
12
|
export const PROPOSAL_STATUSES = ["pending", "approved", "rejected", "edited", "applied", "closed"];
|
|
13
13
|
/** Input keys of `proposal new`: the schema §11 fields the CLI does not set (spec/cli.md §5.6). */
|
|
14
|
-
const INPUT_KEYS = ["title", "kind", "target", "field", "before", "after", "from", "content", "note", "supersedes", "merge_into", "why", "source"];
|
|
14
|
+
const INPUT_KEYS = ["title", "kind", "target", "field", "notes", "answer", "answer_note", "before", "after", "from", "content", "note", "supersedes", "merge_into", "why", "source"];
|
|
15
15
|
const ID = /^[a-z]{2,4}-[0-9a-hjkmnp-tv-z]{8}$/;
|
|
16
16
|
const KNOWLEDGE_TYPES = new Set(["area", "goal", "project", "person", "org", "meeting", "decision", "question", "concept", "memory"]);
|
|
17
17
|
const ADD_LINK_FIELDS = new Set(["aliases", "tags", "projects", "people", "attendees", "deciders"]);
|
|
18
18
|
/** Fields that make two pending proposals the same change (dedupe, spec/cli.md §5.6). */
|
|
19
|
-
const DEDUPE_KEYS = ["kind", "target", "field", "after", "content", "note", "supersedes", "merge_into"];
|
|
19
|
+
const DEDUPE_KEYS = ["kind", "target", "field", "after", "content", "note", "supersedes", "merge_into", "answer", "answer_note"];
|
|
20
20
|
/**
|
|
21
21
|
* Stage proposals for `proposal new` (spec/cli.md §5.6) and validate them. Throws INPUT_INVALID,
|
|
22
22
|
* UNKNOWN_ID or BEFORE_MISMATCH for the whole batch; nothing is staged in that case.
|
|
@@ -40,18 +40,38 @@ export function stageProposals(workspace, items, now) {
|
|
|
40
40
|
if (item.kind === "new_item")
|
|
41
41
|
checkNewItem(workspace, item.note, `/proposals/${index}/note`, today);
|
|
42
42
|
});
|
|
43
|
+
// One review_notes per source; it takes in that source's add_link items (spec/cli.md §5.6, 0043).
|
|
44
|
+
const reviews = new Map();
|
|
45
|
+
list.forEach((item, index) => {
|
|
46
|
+
if (item.kind !== "review_notes")
|
|
47
|
+
return;
|
|
48
|
+
const source = String(item.source);
|
|
49
|
+
if (reviews.has(source))
|
|
50
|
+
assertNoProblems([{ pointer: `/proposals/${index}/source`, message: `a second review_notes for ${source} in one input` }]);
|
|
51
|
+
checkReviewNotes(workspace, item, `/proposals/${index}`);
|
|
52
|
+
reviews.set(source, { index, item, links: [] });
|
|
53
|
+
});
|
|
43
54
|
const start = workspace.checkpoint();
|
|
44
55
|
try {
|
|
45
56
|
list.forEach((item, index) => {
|
|
46
57
|
const pointer = `/proposals/${index}`;
|
|
58
|
+
if (item.kind === "review_notes")
|
|
59
|
+
return;
|
|
47
60
|
const fields = resolveFields(workspace, item, pointer, today);
|
|
61
|
+
if (item.kind === "answer" && workspace.get(String(fields.target)).attributes.answer !== undefined) {
|
|
62
|
+
skipped.push({ input_index: index, reason: "ALREADY_ANSWERED" });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const review = item.kind === "add_link" ? reviews.get(String(fields.source)) : undefined;
|
|
48
66
|
if (item.kind === "add_link" && listField(workspace.get(String(fields.target)), String(fields.field)).some((value) => isDeepStrictEqual(value, fields.after))) {
|
|
49
67
|
skipped.push({ input_index: index, reason: "ALREADY_LINKED" });
|
|
50
68
|
return;
|
|
51
69
|
}
|
|
52
70
|
const duplicate = workspace.notes.find((note) => note.type === "proposal" && note.attributes.status === "pending" &&
|
|
53
71
|
(item.kind === "add_link"
|
|
54
|
-
?
|
|
72
|
+
? (note.attributes.kind === "review_notes"
|
|
73
|
+
? review !== undefined && reviewLinks(note.attributes).some((link) => link.target === fields.target && link.field === fields.field && isDeepStrictEqual(link.value, fields.after))
|
|
74
|
+
: note.attributes.target === fields.target && proposalLinks(note.attributes).some((link) => link.field === fields.field && isDeepStrictEqual(link.value, fields.after)))
|
|
55
75
|
: DEDUPE_KEYS.every((key) => isDeepStrictEqual(note.attributes[key], fields[key]))));
|
|
56
76
|
if (duplicate !== undefined) {
|
|
57
77
|
skipped.push({ input_index: index, reason: "DUPLICATE_PENDING", existing: duplicate.id });
|
|
@@ -64,7 +84,10 @@ export function stageProposals(workspace, items, now) {
|
|
|
64
84
|
return;
|
|
65
85
|
}
|
|
66
86
|
linkKeys.set(key, index);
|
|
67
|
-
|
|
87
|
+
if (review !== undefined)
|
|
88
|
+
review.links.push({ index, fields });
|
|
89
|
+
else
|
|
90
|
+
links.push({ item, index, fields });
|
|
68
91
|
return;
|
|
69
92
|
}
|
|
70
93
|
createProposal(workspace, fields, [index], today, pointer, pointers, created);
|
|
@@ -86,6 +109,25 @@ export function stageProposals(workspace, items, now) {
|
|
|
86
109
|
for (const link of group)
|
|
87
110
|
createdLinks.set(JSON.stringify([link.fields.target, link.fields.source, link.fields.field, link.fields.after]), proposal.id);
|
|
88
111
|
}
|
|
112
|
+
for (const review of reviews.values()) {
|
|
113
|
+
const notes = review.item.notes;
|
|
114
|
+
if (notes.length === 0 && review.links.length === 0) {
|
|
115
|
+
skipped.push({ input_index: review.index, reason: "NOTHING_TO_REVIEW" });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const source = String(review.item.source);
|
|
119
|
+
const fields = {
|
|
120
|
+
kind: "review_notes",
|
|
121
|
+
title: review.item.title ?? reviewTitle(workspace, source, notes.length, review.links.length),
|
|
122
|
+
notes,
|
|
123
|
+
links: review.links.map(({ fields }) => ({ target: fields.target, field: fields.field, value: fields.after })),
|
|
124
|
+
why: review.item.why ?? `New notes and links from ${sourceTitle(workspace, source)}`,
|
|
125
|
+
source,
|
|
126
|
+
};
|
|
127
|
+
const proposal = createProposal(workspace, fields, [review.index, ...review.links.map(({ index }) => index)].sort((a, b) => a - b), today, `/proposals/${review.index}`, pointers, created);
|
|
128
|
+
for (const link of review.links)
|
|
129
|
+
createdLinks.set(JSON.stringify([link.fields.target, link.fields.source, link.fields.field, link.fields.after]), proposal.id);
|
|
130
|
+
}
|
|
89
131
|
for (const duplicate of duplicateLinks)
|
|
90
132
|
skipped.push({ input_index: duplicate.input_index, reason: "DUPLICATE_PENDING", existing: createdLinks.get(duplicate.key) });
|
|
91
133
|
assertStagedValid(workspace, pointers, "/proposals");
|
|
@@ -106,6 +148,60 @@ function createProposal(workspace, fields, inputIndexes, today, pointer, pointer
|
|
|
106
148
|
created.push(proposal);
|
|
107
149
|
return proposal;
|
|
108
150
|
}
|
|
151
|
+
/** The `{target, field, value}` links of a review_notes proposal. */
|
|
152
|
+
function reviewLinks(attributes) {
|
|
153
|
+
return Array.isArray(attributes.links) ? attributes.links.filter(isRecord).map((link) => ({ target: link.target, field: link.field, value: link.value })) : [];
|
|
154
|
+
}
|
|
155
|
+
/** Ids listed by every review_notes proposal, any status (spec/cli.md §5.6). */
|
|
156
|
+
function reviewedIds(workspace) {
|
|
157
|
+
const listed = new Map();
|
|
158
|
+
for (const note of workspace.notes) {
|
|
159
|
+
if (note.type !== "proposal" || note.attributes.kind !== "review_notes" || !Array.isArray(note.attributes.notes))
|
|
160
|
+
continue;
|
|
161
|
+
for (const id of note.attributes.notes)
|
|
162
|
+
if (typeof id === "string" && !listed.has(id))
|
|
163
|
+
listed.set(id, note.id);
|
|
164
|
+
}
|
|
165
|
+
return listed;
|
|
166
|
+
}
|
|
167
|
+
/** The meeting's title for a meeting id, else the source's (spec/cli.md §5.6). */
|
|
168
|
+
function sourceTitle(workspace, source) {
|
|
169
|
+
return String(workspace.get(source)?.attributes.title ?? source);
|
|
170
|
+
}
|
|
171
|
+
function reviewTitle(workspace, source, notes, links) {
|
|
172
|
+
const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
173
|
+
const from = sourceTitle(workspace, source);
|
|
174
|
+
const title = notes === 0
|
|
175
|
+
? `Accept ${count(links, "link", "links")} from ${from}`
|
|
176
|
+
: `Accept ${count(notes, "new note", "new notes")} from ${from}${links === 0 ? "" : `, and ${count(links, "link", "links")}`}`;
|
|
177
|
+
return title.length > 120 ? `${title.slice(0, 119)}…` : title;
|
|
178
|
+
}
|
|
179
|
+
/** A review_notes item's notes: existing knowledge notes, listed once here and by no other review_notes. */
|
|
180
|
+
function checkReviewNotes(workspace, item, pointer) {
|
|
181
|
+
const source = workspace.get(String(item.source));
|
|
182
|
+
if (source === undefined)
|
|
183
|
+
throw new BrainError("UNKNOWN_ID", `${pointer}/source: no note with id ${String(item.source)}`, [{ pointer: `${pointer}/source`, message: "does not exist" }]);
|
|
184
|
+
if (source.type !== "meeting" && source.type !== "source")
|
|
185
|
+
assertNoProblems([{ pointer: `${pointer}/source`, message: "must be a meeting or a source" }]);
|
|
186
|
+
const listed = reviewedIds(workspace);
|
|
187
|
+
const seen = new Set();
|
|
188
|
+
const problems = [];
|
|
189
|
+
item.notes.forEach((id, index) => {
|
|
190
|
+
const at = `${pointer}/notes/${index}`;
|
|
191
|
+
const note = typeof id === "string" ? workspace.get(id) : undefined;
|
|
192
|
+
if (note === undefined)
|
|
193
|
+
problems.push({ pointer: at, message: `no note with id ${String(id)}` });
|
|
194
|
+
else if (!KNOWLEDGE_TYPES.has(note.type))
|
|
195
|
+
problems.push({ pointer: at, message: `is a ${note.type}, not a knowledge note` });
|
|
196
|
+
else if (seen.has(note.id))
|
|
197
|
+
problems.push({ pointer: at, message: "is listed twice" });
|
|
198
|
+
else if (listed.has(note.id))
|
|
199
|
+
problems.push({ pointer: at, message: `is already listed by ${listed.get(note.id)}` });
|
|
200
|
+
if (typeof id === "string")
|
|
201
|
+
seen.add(id);
|
|
202
|
+
});
|
|
203
|
+
assertNoProblems(problems);
|
|
204
|
+
}
|
|
109
205
|
function proposalLinks(attributes) {
|
|
110
206
|
if (Array.isArray(attributes.links))
|
|
111
207
|
return attributes.links.filter(isRecord).map((link) => ({ field: link.field, value: link.value }));
|
|
@@ -133,11 +229,34 @@ function checkShape(item, pointer) {
|
|
|
133
229
|
const at = (key, message) => problems.push({ pointer: `${pointer}/${key}`, message });
|
|
134
230
|
if (!PROPOSAL_KINDS.includes(item.kind))
|
|
135
231
|
at("kind", `must be one of ${PROPOSAL_KINDS.join(", ")}`);
|
|
136
|
-
for (const key of ["title", "why", "source"]) {
|
|
232
|
+
for (const key of item.kind === "review_notes" ? ["source"] : ["title", "why", "source"]) {
|
|
137
233
|
if (typeof item[key] !== "string" || item[key] === "")
|
|
138
234
|
at(key, "must be a non-empty string");
|
|
139
235
|
}
|
|
140
|
-
|
|
236
|
+
for (const key of item.kind === "review_notes" ? ["title", "why"] : []) {
|
|
237
|
+
if (item[key] !== undefined && (typeof item[key] !== "string" || item[key] === ""))
|
|
238
|
+
at(key, "must be a non-empty string");
|
|
239
|
+
}
|
|
240
|
+
if (item.kind === "review_notes") {
|
|
241
|
+
if (item.target !== undefined)
|
|
242
|
+
at("target", "a review_notes has no target");
|
|
243
|
+
if (!Array.isArray(item.notes))
|
|
244
|
+
at("notes", "must be a list of note ids");
|
|
245
|
+
}
|
|
246
|
+
else if (item.notes !== undefined) {
|
|
247
|
+
at("notes", "is only allowed on review_notes");
|
|
248
|
+
}
|
|
249
|
+
if (item.kind === "answer" && (typeof item.answer !== "string" || item.answer.trim() === ""))
|
|
250
|
+
at("answer", "must be a non-empty string");
|
|
251
|
+
if (item.kind !== "answer") {
|
|
252
|
+
for (const key of ["answer", "answer_note"])
|
|
253
|
+
if (item[key] !== undefined)
|
|
254
|
+
at(key, "is only allowed on answer");
|
|
255
|
+
}
|
|
256
|
+
if (item.kind === "review_notes") {
|
|
257
|
+
// Checked against the brain in checkReviewNotes.
|
|
258
|
+
}
|
|
259
|
+
else if (item.kind === "new_item") {
|
|
141
260
|
if (item.target !== undefined)
|
|
142
261
|
at("target", "a new_item has no target");
|
|
143
262
|
problems.push(...checkNoteInput(item.note, `${pointer}/note`, false));
|
|
@@ -177,6 +296,15 @@ function resolveFields(workspace, item, pointer, today) {
|
|
|
177
296
|
const target = existing("target");
|
|
178
297
|
if (!KNOWLEDGE_TYPES.has(target.type))
|
|
179
298
|
invalid("target", `is a ${target.type}; proposals change knowledge notes only`);
|
|
299
|
+
if (kind === "answer") {
|
|
300
|
+
if (target.type !== "question")
|
|
301
|
+
invalid("target", `is a ${target.type}; an answer targets a question`);
|
|
302
|
+
if (item.answer_note !== undefined) {
|
|
303
|
+
const note = existing("answer_note");
|
|
304
|
+
if (!KNOWLEDGE_TYPES.has(note.type))
|
|
305
|
+
invalid("answer_note", `is a ${note.type}, not a knowledge note`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
180
308
|
if (kind === "update_fact" || kind === "contradiction") {
|
|
181
309
|
if (typeof item.field !== "string")
|
|
182
310
|
invalid("field", "is required for this kind");
|
|
@@ -274,7 +402,54 @@ export function listProposals(workspace, statuses, target) {
|
|
|
274
402
|
return workspace.notes
|
|
275
403
|
.filter((note) => note.type === "proposal")
|
|
276
404
|
.filter((note) => statuses.length === 0 || statuses.includes(String(note.attributes.status)))
|
|
277
|
-
.filter((note) => target === undefined || note.attributes.target === target
|
|
405
|
+
.filter((note) => target === undefined || note.attributes.target === target
|
|
406
|
+
|| (note.attributes.kind === "review_notes" && ((Array.isArray(note.attributes.notes) && note.attributes.notes.includes(target)) || reviewLinks(note.attributes).some((link) => link.target === target))))
|
|
278
407
|
.map((note) => ({ ...note.attributes, path: note.path }))
|
|
279
408
|
.sort((a, b) => compareCodePoints(String(a.created), String(b.created)) || compareCodePoints(String(a.id), String(b.id)));
|
|
280
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* `proposal backlog` (spec/cli.md §5.6, 0041): one review_notes per source over the `draft`
|
|
412
|
+
* notes nobody has accepted yet, for a brain made before 0.4.0. A second run writes nothing.
|
|
413
|
+
*/
|
|
414
|
+
export function stageBacklog(workspace, now) {
|
|
415
|
+
const today = formatDate(now);
|
|
416
|
+
const listed = reviewedIds(workspace);
|
|
417
|
+
const claimed = new Set();
|
|
418
|
+
const created = [];
|
|
419
|
+
const pointers = new Map();
|
|
420
|
+
const sources = workspace.notes
|
|
421
|
+
.filter((note) => note.type === "source")
|
|
422
|
+
.sort((a, b) => compareCodePoints(String(a.attributes.captured ?? ""), String(b.attributes.captured ?? "")) || compareCodePoints(a.id, b.id));
|
|
423
|
+
const start = workspace.checkpoint();
|
|
424
|
+
try {
|
|
425
|
+
for (const source of sources) {
|
|
426
|
+
const minutes = (source.attributes.kind ?? "minutes") === "minutes";
|
|
427
|
+
const meeting = minutes ? workspace.notes.find((note) => note.type === "meeting" && note.attributes.source === source.id) : undefined;
|
|
428
|
+
const provenance = meeting?.id ?? source.id;
|
|
429
|
+
const notes = workspace.notes
|
|
430
|
+
.filter((note) => KNOWLEDGE_TYPES.has(note.type) && note.attributes.lifecycle === "draft" && !listed.has(note.id) && !claimed.has(note.id)
|
|
431
|
+
&& (note.id === meeting?.id || (Array.isArray(note.attributes.sources) && note.attributes.sources.includes(provenance))))
|
|
432
|
+
.sort((a, b) => compareCodePoints(String(a.attributes.created), String(b.attributes.created)) || compareCodePoints(a.id, b.id));
|
|
433
|
+
if (notes.length === 0)
|
|
434
|
+
continue;
|
|
435
|
+
for (const note of notes)
|
|
436
|
+
claimed.add(note.id);
|
|
437
|
+
const fields = {
|
|
438
|
+
kind: "review_notes",
|
|
439
|
+
title: reviewTitle(workspace, provenance, notes.length, 0),
|
|
440
|
+
notes: notes.map((note) => note.id),
|
|
441
|
+
links: [],
|
|
442
|
+
why: `Notes from ${sourceTitle(workspace, provenance)} waiting for your review`,
|
|
443
|
+
source: provenance,
|
|
444
|
+
};
|
|
445
|
+
const { input_indexes: _unused, ...proposal } = createProposal(workspace, fields, [], today, "", pointers, []);
|
|
446
|
+
created.push(proposal);
|
|
447
|
+
}
|
|
448
|
+
assertStagedValid(workspace, pointers, "");
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
workspace.restore(start);
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
return created;
|
|
455
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { BrainError } from "./errors.js";
|
|
8
|
+
import { isRecord } from "./input.js";
|
|
9
|
+
import { formatTimestamp } from "./time.js";
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
/** The `repos` list of a brain.json, `[]` when absent. */
|
|
12
|
+
export function linkedRepos(config) {
|
|
13
|
+
return Array.isArray(config.repos) ? config.repos.filter(isRecord) : [];
|
|
14
|
+
}
|
|
15
|
+
/** The repo named `name`, or REPO_NOT_FOUND. */
|
|
16
|
+
export function findRepo(config, name) {
|
|
17
|
+
const repo = linkedRepos(config).find((entry) => entry.name === name);
|
|
18
|
+
if (repo === undefined)
|
|
19
|
+
throw new BrainError("REPO_NOT_FOUND", `No linked repo named ${name} in brain.json repos`);
|
|
20
|
+
return repo;
|
|
21
|
+
}
|
|
22
|
+
/** A stored path with `~/` expanded. */
|
|
23
|
+
export function expandPath(path, home = homedir()) {
|
|
24
|
+
return path === "~" ? home : path.startsWith("~/") ? join(home, path.slice(2)) : path;
|
|
25
|
+
}
|
|
26
|
+
async function git(path, args, input) {
|
|
27
|
+
const child = exec("git", ["-C", path, ...args], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
28
|
+
if (input !== undefined)
|
|
29
|
+
child.child.stdin?.end(input);
|
|
30
|
+
return (await child).stdout;
|
|
31
|
+
}
|
|
32
|
+
async function gitBytes(path, args) {
|
|
33
|
+
const { stdout } = await exec("git", ["-C", path, ...args], { encoding: "buffer", maxBuffer: 256 * 1024 * 1024 });
|
|
34
|
+
return stdout;
|
|
35
|
+
}
|
|
36
|
+
/** The real path of the top of the working tree at `path`, or null when it isn't one. */
|
|
37
|
+
export async function workTreeTop(path) {
|
|
38
|
+
try {
|
|
39
|
+
const top = (await git(path, ["rev-parse", "--show-toplevel"])).trim();
|
|
40
|
+
return await realpath(top);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** The repo's working tree, checked: REPO_NOT_FOUND when `path` isn't the top of one. */
|
|
47
|
+
export async function openRepo(repo, home) {
|
|
48
|
+
const path = expandPath(repo.path, home);
|
|
49
|
+
const top = await workTreeTop(path);
|
|
50
|
+
let real = null;
|
|
51
|
+
try {
|
|
52
|
+
real = await realpath(path);
|
|
53
|
+
}
|
|
54
|
+
catch { /* missing */ }
|
|
55
|
+
if (top === null || real !== top)
|
|
56
|
+
throw new BrainError("REPO_NOT_FOUND", `${repo.path} is not the top of a Git working tree`);
|
|
57
|
+
return top;
|
|
58
|
+
}
|
|
59
|
+
/** The full id of `rev` as a commit, or REPO_COMMIT_UNKNOWN. */
|
|
60
|
+
export async function resolveCommit(path, rev) {
|
|
61
|
+
try {
|
|
62
|
+
return (await git(path, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`])).trim();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
throw new BrainError("REPO_COMMIT_UNKNOWN", `${rev} is not a commit in this repo`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** REPO_COMMIT_UNKNOWN unless `ancestor` is an ancestor of (or equal to) `commit`. */
|
|
69
|
+
export async function assertAncestor(path, ancestor, commit) {
|
|
70
|
+
try {
|
|
71
|
+
await git(path, ["merge-base", "--is-ancestor", ancestor, commit]);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new BrainError("REPO_COMMIT_UNKNOWN", `${ancestor} is not an ancestor of ${commit}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const pathspecs = (patterns) => ["--", ...patterns.map((pattern) => `:(glob)${pattern}`)];
|
|
78
|
+
/**
|
|
79
|
+
* The files matching the patterns that changed from `from` (`""`: none) to `to` (spec/cli.md §5.16):
|
|
80
|
+
* added or modified ones with the last commit in the range that touched them, oldest first, and
|
|
81
|
+
* deleted ones apart. No rename detection.
|
|
82
|
+
*/
|
|
83
|
+
export async function repoChanges(path, patterns, from, to) {
|
|
84
|
+
const changed = new Map();
|
|
85
|
+
const deleted = [];
|
|
86
|
+
// A first ingest compares with the empty tree, so every matching file is added.
|
|
87
|
+
const base = from === "" ? (await git(path, ["hash-object", "-t", "tree", "--stdin"], "")).trim() : from;
|
|
88
|
+
const fields = (await git(path, ["diff", "--name-status", "-z", "--no-renames", "--diff-filter=AMDT", base, to, ...pathspecs(patterns)])).split("\0").filter(Boolean);
|
|
89
|
+
for (let i = 0; i + 1 < fields.length; i += 2) {
|
|
90
|
+
const status = fields[i][0];
|
|
91
|
+
const file = fields[i + 1];
|
|
92
|
+
if (status === "D")
|
|
93
|
+
deleted.push(file);
|
|
94
|
+
else
|
|
95
|
+
changed.set(file, status === "A" ? "added" : "modified");
|
|
96
|
+
}
|
|
97
|
+
// The newest commit in the range that touched each file: the first one `git log` names.
|
|
98
|
+
const last = new Map();
|
|
99
|
+
const range = from === "" ? [to] : [`${from}..${to}`];
|
|
100
|
+
const log = await git(path, ["log", "-z", "--no-renames", "--format=%x01%H %ct", "--name-only", ...range, ...pathspecs(patterns)]);
|
|
101
|
+
for (const block of log.split("\x01").filter(Boolean)) {
|
|
102
|
+
const [head, ...names] = block.split("\0");
|
|
103
|
+
const [header, firstName] = head.split("\n");
|
|
104
|
+
const [commit, time] = header.split(" ");
|
|
105
|
+
for (const name of [firstName, ...names].map((name) => name?.replace(/^\n/, "")).filter((name) => name !== undefined && name !== "")) {
|
|
106
|
+
if (!last.has(name))
|
|
107
|
+
last.set(name, { commit, time: Number(time) });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const files = [];
|
|
111
|
+
for (const [file, status] of changed) {
|
|
112
|
+
const bytes = await gitBytes(path, ["cat-file", "blob", `${to}:${file}`]);
|
|
113
|
+
const touched = last.get(file);
|
|
114
|
+
if (touched === undefined)
|
|
115
|
+
throw new Error(`git log names no commit for ${file}`);
|
|
116
|
+
files.push({ path: file, status, commit: touched.commit, committed_at: formatTimestamp(new Date(touched.time * 1000)), sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length });
|
|
117
|
+
}
|
|
118
|
+
files.sort((a, b) => a.committed_at.localeCompare(b.committed_at) || (last.get(a.path).time - last.get(b.path).time) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
119
|
+
deleted.sort();
|
|
120
|
+
return { files, deleted };
|
|
121
|
+
}
|
|
122
|
+
/** Whether the working tree has uncommitted changes (tracked or untracked). */
|
|
123
|
+
export async function isDirty(path) {
|
|
124
|
+
return (await git(path, ["status", "--porcelain"])).trim() !== "";
|
|
125
|
+
}
|
|
126
|
+
/** The repo's HEAD commit, or REPO_COMMIT_UNKNOWN for a repo with no commits. */
|
|
127
|
+
export async function headCommit(path) {
|
|
128
|
+
return resolveCommit(path, "HEAD");
|
|
129
|
+
}
|