clavarium 0.2.0-next.1 → 0.2.0-next.2

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.0-next.1",
3
+ "version": "0.2.0-next.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.2.0-next.1",
9
+ "version": "0.2.0-next.2",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.0-next.1",
3
+ "version": "0.2.0-next.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
package/server/api.mjs CHANGED
@@ -37,6 +37,8 @@
37
37
  * POST /api/updates/apply { version }: update to the version that is out (in the background)
38
38
  * POST /api/updates/back { version }: go back to a release that is kept (in the background)
39
39
  * GET /api/git/<name>/outgoing?target=<branch>&branch=<branch> what would go from the agent's branch, to that branch of the remote
40
+ * GET /api/git/<name>/graph the branch diagram: branches and their last commits
41
+ * GET /api/git/<name>/commit?id=<sha> one commit, as a review shows it
40
42
  * POST /api/git/<name>/push {head, target?, sure?}: exactly that reviewed commit, to exactly that branch
41
43
  * GET /api/git/<name>/keep origin kept up to date, checks, automatic sending, log
42
44
  * POST /api/git/<name>/keep {refreshMinutes?, alert?, checkCommand?, checkSeconds?, runHook?, requireForSend?, fsck?}
@@ -215,6 +217,8 @@ export async function handleApi(request, response, conversations, headers, resou
215
217
  if (answered && answerer) return send(200, answerer.log(decodeURIComponent(answered[1]))), true;
216
218
  /* A repository: where its commits go, and what would go now. */
217
219
  const repo = /^\/api\/git\/([a-z0-9-]{1,32})(\/outgoing|\/keep)?$/.exec(pathname);
220
+ const drawn = /^\/api\/git\/([a-z0-9-]{1,32})\/(graph|commit)$/.exec(pathname);
221
+ if (drawn && gitReview) return send(200, drawn[2] === 'graph' ? await gitReview.graph(drawn[1]) : await gitReview.commit(drawn[1], query.get('id'))), true;
218
222
  if (repo && repo[2] === '/keep' && gitKeeper) return send(200, gitKeeper.about(repo[1])), true;
219
223
  if (repo && gitReview) return send(200, repo[2] ? await gitReview.outgoing(repo[1], { target: query.get('target') ?? undefined, branch: query.get('branch') ?? undefined }) : gitReview.settings(repo[1])), true;
220
224
  const watched = /^\/api\/conversations\/([^/]+)\/signin$/.exec(pathname);
@@ -40,6 +40,9 @@ export const UNCHANGED_MAX = 50;
40
40
  /* A commit message is shown up to this many characters, and the cut is said. */
41
41
  export const MESSAGE_MAX = 4000;
42
42
  export const REVIEWS_KEPT = 20;
43
+ /* The branch diagram: so many commits, so many of the remote's branches. */
44
+ export const GRAPH_COMMITS = 40;
45
+ export const GRAPH_BRANCHES = 20;
43
46
  /* How long a review waits for the reference, and a send or a refresh. */
44
47
  export const REVIEW_WAIT_MS = 10_000;
45
48
  export const SEND_WAIT_MS = 60_000;
@@ -408,6 +411,66 @@ export class GitReview {
408
411
  } finally { got.remove(); }
409
412
  }
410
413
 
414
+ /* The workspace of one read, with the remote's history lent to it when
415
+ there is one, for `work(ws, remoteTips)`; deleted after. */
416
+ async #withRead(r, work) {
417
+ const got = await this.#read(r);
418
+ try {
419
+ let release = null; let remoteTips = new Map();
420
+ if (this.#remoteOf(r) && this.#resources.hasOrigin(r.name)) {
421
+ release = await this.#runner.lock(this.#reference(r.name), REVIEW_WAIT_MS);
422
+ this.#runner.borrow(got.path, this.#reference(r.name));
423
+ remoteTips = await this.#tipsIn(this.#reference(r.name));
424
+ }
425
+ try { return await work(got, remoteTips); } finally { release?.(); }
426
+ } finally { got.remove(); }
427
+ }
428
+
429
+ /* THE BRANCH DIAGRAM: the agent's branches, the remote's, and their
430
+ last commits, newest first in topological order: { branches: [{name,
431
+ id, side: 'agent'|'remote'}], commits: [{id, parents, subject, date,
432
+ refs: [{name, side}]}] }. What the agent wrote is escaped as everywhere. */
433
+ async graph(name) {
434
+ const r = this.#repository(name);
435
+ try {
436
+ return await this.#withRead(r, async (got, remoteTips) => {
437
+ const branches = got.branches.map((b) => ({ name: escaped(b.name), id: b.id, side: 'agent' }));
438
+ for (const [b, id] of [...remoteTips].sort(([a], [c]) => a.localeCompare(c)).slice(0, GRAPH_BRANCHES)) branches.push({ name: escaped(b), id, side: 'remote' });
439
+ const tips = [...new Set(branches.map((b) => b.id))];
440
+ if (tips.length === 0) return { branches, commits: [] };
441
+ const log = await this.#runner.in(got.path, ['log', '--topo-order', `--max-count=${GRAPH_COMMITS}`, '-z', '--format=%H%x00%P%x00%aI%x00%s', '--stdin', '--end-of-options'], { input: `${tips.join('\n')}\n`, deadlineMs: 60_000, maxBytes: 4 * 1024 * 1024 });
442
+ const fields = log.stdout.toString('utf8').split('\0');
443
+ const commits = [];
444
+ for (let i = 0; i + 3 < fields.length; i += 4) {
445
+ const id = fields[i].replace(/^\n/, '');
446
+ if (!isId(id)) break;
447
+ commits.push({ id, parents: fields[i + 1].split(' ').filter(isId), date: fields[i + 2], subject: escaped(fields[i + 3]).slice(0, 300),
448
+ refs: branches.filter((b) => b.id === id).map((b) => ({ name: b.name, side: b.side })) });
449
+ }
450
+ return { branches, commits };
451
+ });
452
+ } catch (error) {
453
+ if (error instanceof GitFailure && error.kind === 'empty') return { branches: [], commits: [] };
454
+ throw error;
455
+ }
456
+ }
457
+
458
+ /* ONE COMMIT, as a review shows it: against its first parent (or
459
+ nothing, for the first commit). Never sent from here. */
460
+ async commit(name, id) {
461
+ const r = this.#repository(name);
462
+ const asked = String(id ?? '');
463
+ if (!isId(asked)) throw new Refusal(400, 'A commit is named by its full identifier.');
464
+ return this.#withRead(r, async (got) => {
465
+ const p = await this.#runner.in(got.path, ['rev-list', '--parents', '--max-count=1', '--end-of-options', asked], { deadlineMs: 60_000 });
466
+ const ids = p.stdout.toString('latin1').trim().split(' ');
467
+ if (p.code !== 0 || ids[0] !== asked) throw new Refusal(404, 'This commit is not in the repository.');
468
+ const parents = ids.slice(1).filter(isId);
469
+ const shown = await this.#compare(got.path, { head: asked, base: parents[0] ?? EMPTY_TREE, range: [asked], negatives: parents });
470
+ return { ...shown, id: asked, parents, head: asked, state: 'commit', message: parents.length > 1 ? 'A merge: its changes against its first parent.' : '' };
471
+ });
472
+ }
473
+
411
474
  /* What a review shows of head against base, in a workspace: the commits
412
475
  (with their whole messages), the true count, the files, the changes,
413
476
  and what the changes alone could hide. `range` and `negatives` say
package/server/main.mjs CHANGED
@@ -70,6 +70,10 @@ export const FILES = {
70
70
  '/voice-core.js': ['web/voice-core.js', 'text/javascript; charset=utf-8'],
71
71
  '/icons.js': ['web/icons.js', 'text/javascript; charset=utf-8'],
72
72
  '/links.js': ['web/links.js', 'text/javascript; charset=utf-8'],
73
+ '/gitgraph.js': ['web/gitgraph.js', 'text/javascript; charset=utf-8'],
74
+ /* The review's diff, drawn by diff2html (vendored, pinned: web/vendor/diff2html/LICENSE). */
75
+ '/vendor/diff2html.js': ['web/vendor/diff2html/diff2html-ui-slim.min.js', 'text/javascript; charset=utf-8'],
76
+ '/vendor/diff2html.css': ['web/vendor/diff2html/diff2html.min.css', 'text/css; charset=utf-8'],
73
77
  '/vendor/xterm.js': [packageFile('@xterm/xterm', 'lib/xterm.js'), 'text/javascript; charset=utf-8'],
74
78
  '/vendor/xterm.css': [packageFile('@xterm/xterm', 'css/xterm.css'), 'text/css; charset=utf-8'],
75
79
  '/vendor/fit.js': [packageFile('@xterm/addon-fit', 'lib/addon-fit.js'), 'text/javascript; charset=utf-8'],