@ok-ggwp/git-gloss 0.4.0 → 0.5.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/README.md CHANGED
@@ -35,7 +35,10 @@ git gloss review [<commit>|<range>] print a review prompt: git gloss review ma
35
35
  --material ...only the gloss and code, for a reviewer with its own instructions
36
36
  (which must tell it the author's text is untrusted)
37
37
  git gloss versions [<commit>|wip] every gloss a commit has had, newest first (--show <n>, --restore <n>)
38
- git gloss reviews [<commit>|<range>] the reviews recorded for commits, with their findings
38
+ git gloss reviews [<commit>|<range>] the reviews recorded for commits, with their findings and decisions
39
+ --decide <review-id> --finding <text> --outcome fixed|follow-up|rejected|accepted [--note <text>]
40
+ record what was decided about one finding
41
+ --decide <review-id> --complete the arbiter has finished with that review
39
42
  git gloss schema print the annotation JSON Schema
40
43
  ```
41
44
 
@@ -159,6 +162,26 @@ reviews: honesty: no issues (claude, 2026-10-18) · bugs: defects found (claude,
159
162
 
160
163
  A review says that something was checked, not that it passed. Each review ends with a verdict (`VERDICT: CLEAN` or `VERDICT: ISSUES`). For each part of the commit, the latest review that saw it decides, and a clean result that followed one with issues says so.
161
164
 
165
+ ### Decisions
166
+
167
+ A review holds a report and a verdict, so by itself it cannot tell "3 fixed, 2 deferred" from "5 ignored". Whoever arbitrates a review (the `gg-deliver` arbiter, or a person) records **decisions** about it, in the same notes ref and on the same commits as the review. A decision is one JSON line:
168
+
169
+ ```
170
+ {"decision":1,"review":"<review id>","at":"…","by":"…","finding":"1","outcome":"fixed","note":"…"}
171
+ {"decision":1,"review":"<review id>","at":"…","by":"…","complete":true}
172
+ ```
173
+
174
+ `finding` is free text naming the finding (reports are free text, so git-gloss doesn't parse findings out of them); `outcome` is `fixed`, `follow-up`, `rejected` or `accepted`; `note` is optional. The second line says the arbiter has finished and no more decisions are coming, which is what separates "all arbitrated" from "5 ignored". The marker is advisory: decisions added after it are still accepted and counted. A review whose verdict is clean needs none.
175
+
176
+ ```bash
177
+ git gloss reviews wip --decide <review-id> --finding "Rework: wip rule claim" --outcome follow-up --note "next release"
178
+ git gloss reviews wip --decide <review-id> --complete
179
+ ```
180
+
181
+ The target (`wip`, a commit, a range) is as for `reviews`; the decision is added to every commit there that holds that review, and an unknown review id is an error. `by` is `--by <name>`, else `git config user.name`, else "unknown". `reviews` prints each review's id in its header line (the id `--decide` takes) and lists its decisions under it with a summary ("3 fixed, 2 follow-up · complete", or "4 decided · not marked complete"), and `show` carries the same summary on its reviews line. The HTML view does not show decisions yet. Decisions follow their reviews through `adopt` (so decisions made on `wip` survive committing), `carry`, rebases and `sync`. Text from notes (decisions and review records) and commit subjects is shown with control characters and escape sequences removed, since it can arrive from a remote; decision lines over 16 KB are skipped silently, and review lines over 8 MiB or with findings text over 256 KiB are skipped with a warning; unknown outcomes and decisions for a review that is not on the commit are ignored.
182
+
183
+ Known cost: decisions share the review note, and a git-gloss without this feature ignores decision lines when reading but does not know to keep them: when it copies review records onto another commit (`adopt` and `carry`), it writes only the review records, so the decisions are lost on that copy (git's own note rewriting on an amend or rebase copies whole notes, so that path keeps them). Keep everyone who arbitrates on the same version.
184
+
162
185
  Like glosses, reviews follow rebased commits in repos set up with `git gloss init`. Reviews are shared by `git gloss sync` (and so by every push with the hooks) like glosses, for commits the remote has. They never conflict: everyone's reviews are kept. Other tools can record reviews too: `git gloss reviews --stamp <rev>` before the review, then `git gloss reviews --record` with the stamp and the report as JSON on stdin.
163
186
 
164
187
  ## Impact and attention
@@ -212,7 +235,7 @@ All gloss and diff text is rendered as text, and a Content-Security-Policy with
212
235
 
213
236
  ## Browsing
214
237
 
215
- `git gloss ary` starts a small web server and opens it in your browser, so you can explore glosses without working out commits, branches and ranges on the command line. Pick a branch and a branch to compare it with (the repo's `main` or `master` by default); each picker has a search box, for repos with many branches. Then click a commit, or "Whole branch" for the range view. Each commit shows its subject (up to two lines) and whether it has a gloss and a review, and a search box above the list finds commits by message or author (case-insensitive, plain text). Searching covers the whole selected branch, not only the commits loaded so far. Your uncommitted work appears at the top once it has been snapshotted (`git gloss hunks wip`). To look at anything else, such as `HEAD~3` or `release..main`, type it into the revision box. As everywhere in git-gloss, `wip` there means the snapshot of uncommitted work, so to view a branch or tag called `wip`, type `heads/wip` or `tags/wip`. Each selection is the page `git gloss html` writes, regenerated on every load, so Reload picks up new glosses and reviews. The address bar keeps the selection, so a reload returns to it (with a random port, a bookmark lasts only until you stop the server; pass `--port` for a stable address).
238
+ `git gloss ary` starts a small web server and opens it in your browser, so you can explore glosses without working out commits, branches and ranges on the command line. The page is the review desk that `git gloss html` writes, with the pickers in its top bar: choose a branch and a branch to compare it with (the repo's `main` or `master` by default); each picker has a search box, for repos with many branches. With a base, the desk's own sidebar lists the commits of the range; with "none" (or when there is nothing beyond the base, or more than 200 commits) the page shows just the branch's newest commit and its groups. To look at anything else, such as `HEAD~3` or `release..main`, type it into the revision box. As everywhere in git-gloss, `wip` there means the snapshot of uncommitted work, which is what opens first once it has been snapshotted (`git gloss hunks wip`); to view a branch or tag called `wip`, type `heads/wip` or `tags/wip`. Every choice loads a new page (`/?rev=…&head=…&base=…`), regenerated from the repo each time, so reloading picks up new glosses and reviews, and the address keeps the selection (with a random port, a bookmark lasts only until you stop the server; pass `--port` for a stable address).
216
239
 
217
240
  It serves one repository, the one you run it in, until you press Ctrl-C. It listens on 127.0.0.1 only, on a free port (or `--port`), only answers requests addressed to that host name, and never writes anything: it doesn't take snapshots or change notes. A page shows at most 200 commits; pick a narrower range for more. `--no-open` doesn't launch a browser.
218
241
 
@@ -1,80 +1,23 @@
1
1
  import { basename } from 'node:path';
2
- import { NOTES_REF, WIP_NOTES_REF, WIP_REF, git, gitTry, wipStaleness } from '../git.js';
3
- import { REVIEWS_REF } from '../reviews.js';
4
- // The commits a notes ref has notes on ("<note> <commit>" per line). A missing
5
- // ref is no notes.
6
- function notedCommits(ref) {
7
- const lines = (gitTry(['notes', '--ref', ref, 'list']) ?? '').split('\n');
8
- return new Set(lines.filter(Boolean).map((l) => l.split(' ')[1]));
9
- }
2
+ import { git, gitTry } from '../git.js';
10
3
  function refList(namespace) {
11
- return git(['for-each-ref', '--sort=-committerdate', '--format=%(refname)%00%(refname:short)%00%(committerdate:relative)%00%(subject)', namespace])
4
+ return git(['for-each-ref', '--sort=-committerdate', '--format=%(refname)%00%(refname:short)', namespace])
12
5
  .split('\n')
13
6
  .filter(Boolean)
14
7
  .map((l) => l.split('\0'))
15
8
  // refs/remotes/<remote>/HEAD is a pointer to another branch, not a branch.
16
9
  .filter(([full]) => !full.endsWith('/HEAD'))
17
- .map(([, name, date, subject]) => ({ name, date, subject }));
10
+ .map(([, name]) => name);
18
11
  }
19
12
  export function listRefs() {
20
13
  const branches = refList('refs/heads');
21
14
  const remotes = refList('refs/remotes');
22
- const names = new Set([...branches, ...remotes].map((b) => b.name));
23
- const wipSha = gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim();
24
- let stale = null;
25
- if (wipSha) {
26
- try {
27
- stale = wipStaleness(wipSha);
28
- }
29
- catch {
30
- // Staleness is advice; the snapshot is still viewable without it.
31
- }
32
- }
15
+ const names = new Set([...branches, ...remotes]);
33
16
  return {
34
17
  repo: basename(git(['rev-parse', '--show-toplevel']).trim()),
35
18
  current: gitTry(['symbolic-ref', '--short', '--quiet', 'HEAD'])?.trim() || null,
36
19
  branches,
37
20
  remotes,
38
21
  defaultBase: ['main', 'master', 'origin/main', 'origin/master'].find((n) => names.has(n)) ?? null,
39
- wip: wipSha ? { sha: wipSha, glossed: notedCommits(WIP_NOTES_REF).has(wipSha), stale } : null,
40
- };
41
- }
42
- // Newest first: the commits of `head`, or only those not in `base` when given.
43
- // `query` keeps the commits whose message or author contains it (any case).
44
- export function listLog(head, base, limit, skip, query = '') {
45
- const range = base ? `${base}..${head}` : head;
46
- const format = '--format=%H%x00%an%x00%aI%x00%s%x00%P';
47
- let out;
48
- if (query) {
49
- // git ANDs --grep with --author, so ask for each on its own and merge, then
50
- // list the union by commit date (what --no-walk=sorted gives, which can
51
- // differ from the order of an unfiltered log when dates are skewed).
52
- const shas = new Set();
53
- for (const filter of ['--grep', '--author']) {
54
- const found = git(['log', '--format=%H', '--regexp-ignore-case', '--fixed-strings', `${filter}=${query}`, '--end-of-options', range, '--']);
55
- for (const sha of found.split('\n').filter(Boolean))
56
- shas.add(sha);
57
- }
58
- // No -n or --skip here: git then walks on to the parents, so page by hand.
59
- out = shas.size
60
- ? git(['log', '--no-walk=sorted', '--stdin', format], [...shas].join('\n') + '\n').split('\n').slice(skip, skip + limit + 1).join('\n')
61
- : '';
62
- }
63
- else {
64
- // One more than asked for, to know whether there are more.
65
- out = git(['log', format, '-n', String(limit + 1), '--skip', String(skip), '--end-of-options', range, '--']);
66
- }
67
- const glossed = notedCommits(NOTES_REF);
68
- const reviewed = notedCommits(REVIEWS_REF);
69
- const rows = out.split('\n').filter(Boolean).map((l) => l.split('\0'));
70
- return {
71
- commits: rows.slice(0, limit).map(([sha, author, date, subject, parents]) => ({
72
- sha, author, date, subject,
73
- merge: parents.includes(' '),
74
- glossed: glossed.has(sha),
75
- reviewed: reviewed.has(sha),
76
- })),
77
- more: rows.length > limit,
78
- total: base && !query ? Number(git(['rev-list', '--count', '--end-of-options', range, '--']).trim()) : null,
79
22
  };
80
23
  }
@@ -0,0 +1,163 @@
1
+ // The branch pickers and revision box in the desk's top bar when served by
2
+ // `git gloss ary`. Included by page.ts only in that mode, ahead of the desk's
3
+ // script, in the same nonce'd <script>; `el` and `add` are the desk's helpers. It makes no requests: the branches
4
+ // arrive in the page data. Choosing something loads the URL for it, and the
5
+ // server renders that page. Text only goes through textContent.
6
+ function aryBar(ary, el, add) {
7
+ 'use strict'
8
+ // A branch picker with a search box: a repo can have hundreds of branches,
9
+ // too many to scan in a <select>. Matching is case-insensitive and every
10
+ // space-separated word must appear in the name.
11
+ var SHOWN = 200
12
+ function picker(onPick) {
13
+ var root = el('div', 'combo')
14
+ var button = el('button', 'combo-btn')
15
+ button.type = 'button'
16
+ button.setAttribute('aria-haspopup', 'listbox')
17
+ var panel = el('div', 'combo-panel')
18
+ panel.hidden = true
19
+ var input = el('input')
20
+ input.type = 'search'
21
+ input.spellcheck = false
22
+ input.placeholder = 'Search branches…'
23
+ input.setAttribute('aria-label', 'Search branches')
24
+ var list = el('div', 'combo-list')
25
+ list.setAttribute('role', 'listbox')
26
+ add(panel, input, list)
27
+ add(root, button, panel)
28
+
29
+ var groups = []
30
+ var noneLabel = null
31
+ var value = null
32
+ var items = [] // the rows now shown: { value, node }
33
+ var active = 0
34
+
35
+ function matches(name, words) {
36
+ var n = name.toLowerCase()
37
+ return words.every(function (w) { return n.indexOf(w) >= 0 })
38
+ }
39
+ function paint() {
40
+ items.forEach(function (it, i) { it.node.classList.toggle('active', i === active) })
41
+ if (items[active]) items[active].node.scrollIntoView({ block: 'nearest' })
42
+ }
43
+ function render() {
44
+ var words = input.value.toLowerCase().split(/\s+/).filter(Boolean)
45
+ list.textContent = ''
46
+ items = []
47
+ var shown = 0
48
+ var hidden = 0
49
+ function item(label, v) {
50
+ var node = el('button', 'combo-item', label)
51
+ node.type = 'button'
52
+ node.setAttribute('role', 'option')
53
+ node.setAttribute('aria-selected', String(v === value))
54
+ node.addEventListener('click', function () { pick(v) })
55
+ items.push({ value: v, node: node })
56
+ return node
57
+ }
58
+ if (noneLabel && !words.length) list.appendChild(item(noneLabel, null))
59
+ groups.forEach(function (g) {
60
+ var found = g.names.filter(function (n) { return matches(n, words) })
61
+ var room = found.slice(0, Math.max(0, SHOWN - shown))
62
+ hidden += found.length - room.length
63
+ shown += room.length
64
+ if (!room.length) return
65
+ add(list, add(el('div', 'combo-group', g.label), el('span', 'count', found.length)))
66
+ room.forEach(function (n) { list.appendChild(item(n, n)) })
67
+ })
68
+ if (hidden) add(list, el('div', 'combo-more', hidden + ' more: keep typing to narrow the list'))
69
+ if (!items.length) add(list, el('div', 'combo-more', 'No branches match.'))
70
+ // Start on the current value, or on the first match when searching.
71
+ var at = words.length ? -1 : items.findIndex(function (it) { return it.value === value })
72
+ active = at >= 0 ? at : 0
73
+ paint()
74
+ }
75
+ function open() {
76
+ panel.hidden = false
77
+ button.setAttribute('aria-expanded', 'true')
78
+ input.value = ''
79
+ render()
80
+ input.focus()
81
+ }
82
+ function close() {
83
+ panel.hidden = true
84
+ button.setAttribute('aria-expanded', 'false')
85
+ }
86
+ function pick(v) {
87
+ close()
88
+ button.focus()
89
+ if (v !== value) onPick(v)
90
+ }
91
+ button.addEventListener('click', function () { if (panel.hidden) open(); else close() })
92
+ input.addEventListener('input', render)
93
+ input.addEventListener('keydown', function (ev) {
94
+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
95
+ ev.preventDefault()
96
+ if (items.length) active = (active + (ev.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length
97
+ paint()
98
+ } else if (ev.key === 'Enter') {
99
+ ev.preventDefault()
100
+ if (items[active]) pick(items[active].value)
101
+ } else if (ev.key === 'Escape') {
102
+ ev.preventDefault()
103
+ close()
104
+ button.focus()
105
+ }
106
+ })
107
+ document.addEventListener('mousedown', function (ev) { if (!panel.hidden && !root.contains(ev.target)) close() })
108
+
109
+ return {
110
+ root: root,
111
+ // Local and remote branches, and what choosing "no branch" is called (if allowed).
112
+ setOptions: function (g, none) { groups = g; noneLabel = none },
113
+ setValue: function (v) {
114
+ value = v
115
+ button.textContent = v || noneLabel || 'choose a branch'
116
+ button.title = button.textContent
117
+ },
118
+ }
119
+ }
120
+
121
+ function branchGroups() {
122
+ return [
123
+ { label: 'Local branches', names: ary.branches },
124
+ { label: 'Remote branches', names: ary.remotes },
125
+ ]
126
+ }
127
+ function go(head, base, rev) {
128
+ var p = new URLSearchParams()
129
+ if (rev) p.set('rev', rev)
130
+ if (head) p.set('head', head)
131
+ if (base) p.set('base', base)
132
+ location.assign('/?' + p.toString())
133
+ }
134
+
135
+ var headPicker = picker(function (name) {
136
+ // A feature branch is most useful against the usual base; the base itself is listed whole.
137
+ go(name, name === ary.defaultBase ? null : ary.defaultBase, null)
138
+ })
139
+ headPicker.setOptions(branchGroups(), null)
140
+ headPicker.setValue(ary.head)
141
+ var basePicker = picker(function (name) { go(ary.head, name, null) })
142
+ basePicker.setOptions(branchGroups(), '(none: just the newest commit)')
143
+ basePicker.setValue(ary.base)
144
+
145
+ var input = el('input')
146
+ input.type = 'text'
147
+ input.spellcheck = false
148
+ input.placeholder = 'HEAD~3, abc123, main..feature, wip'
149
+ input.setAttribute('aria-label', 'Any revision or range')
150
+ input.value = ary.rev || ''
151
+ var form = add(el('form', 'ary-rev'), input)
152
+ form.addEventListener('submit', function (ev) {
153
+ ev.preventDefault()
154
+ var v = input.value.trim()
155
+ if (v) go(ary.head, ary.base, v)
156
+ })
157
+
158
+ function field(label, node) { return add(el('div', 'ary-field'), el('span', null, label), node) }
159
+ var bar = add(el('div', 'ary-bar'), el('span', 'ary-repo', ary.repo),
160
+ field('Branch', headPicker.root), field('Compare with', basePicker.root), field('Revision', form))
161
+ if (ary.notice) bar.appendChild(el('p', 'ary-notice', ary.notice))
162
+ return bar
163
+ }
@@ -1,16 +1,11 @@
1
- import { readFileSync } from 'node:fs';
2
1
  import { createServer } from 'node:http';
3
- import { GlossError, WIP_REF, gitTry, resolveCommits } from '../git.js';
2
+ import { GlossError, WIP_REF, git, gitTry, resolveCommits } from '../git.js';
4
3
  import { buildPageData } from '../html/data.js';
5
4
  import { escapeHtml, renderPage } from '../html/page.js';
6
- import { listLog, listRefs } from './browse.js';
7
- const asset = (name) => readFileSync(new URL(name, import.meta.url), 'utf8');
5
+ import { listRefs } from './browse.js';
8
6
  // A range page loads and lays out every commit in it, so a huge one would hang
9
7
  // the browser. Narrowing it is a better answer than a spinner.
10
8
  export const MAX_RANGE_COMMITS = 200;
11
- // The shell page loads only its own script and style, and may frame only
12
- // /view pages. Those carry their own policy: an inline script with a nonce.
13
- const SHELL_CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-src 'self'; base-uri 'none'; form-action 'none'";
14
9
  class BadRequest extends GlossError {
15
10
  }
16
11
  // A revision, branch or range typed by the user or taken from the sidebar. An
@@ -26,10 +21,6 @@ function revParam(url, name, required = true) {
26
21
  throw new BadRequest(`not a revision: ${v.slice(0, 80)}`);
27
22
  return v;
28
23
  }
29
- const intParam = (url, name, fallback, min, max) => {
30
- const n = Number(url.searchParams.get(name) ?? fallback);
31
- return Number.isInteger(n) && n >= min ? Math.min(n, max) : fallback;
32
- };
33
24
  function send(res, status, type, body, headers = {}) {
34
25
  res.writeHead(status, {
35
26
  'content-type': `${type}; charset=utf-8`,
@@ -41,9 +32,74 @@ function send(res, status, type, body, headers = {}) {
41
32
  });
42
33
  res.end(body);
43
34
  }
44
- const sendJson = (res, data) => send(res, 200, 'application/json', JSON.stringify(data));
45
- function viewPage(url) {
46
- const rev = revParam(url, 'rev');
35
+ // The page may not be framed by anyone: it shows the code of your commits.
36
+ const PAGE_HEADERS = { 'content-security-policy': "frame-ancestors 'none'" };
37
+ function errorPage(res, status, message) {
38
+ send(res, status, 'text/html', `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark"><pre style="white-space:pre-wrap;font:14px/1.5 system-ui;padding:16px">${escapeHtml(message)}</pre><p style="font:14px/1.5 system-ui;padding:0 16px"><a href="/">Back to the default view</a></p>`, { 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'" });
39
+ }
40
+ // The commits of `head` the page shows when nothing else is chosen: those
41
+ // beyond `base` (when a page can show them all), or else the newest one (as a sha, so a branch called "wip"
42
+ // doesn't mean the snapshot).
43
+ function defaultRev(head, base) {
44
+ if (!head)
45
+ return null;
46
+ if (base) {
47
+ // A range beyond what a page shows would only be an error page: show the newest commit.
48
+ const n = Number(git(['rev-list', '--count', '--end-of-options', `${base}..${head}`, '--']).trim());
49
+ if (n > 0 && n <= MAX_RANGE_COMMITS)
50
+ return `${base}..${head}`;
51
+ }
52
+ return gitTry(['rev-parse', '--verify', '--quiet', `${head}^{commit}`])?.trim() || null;
53
+ }
54
+ // The page for ?rev=&head=&base=. head and base only fill the pickers and
55
+ // choose the default rev; the rev alone decides which commits are shown.
56
+ function deskPage(url, res) {
57
+ const refs = listRefs();
58
+ const known = new Set([...refs.branches, ...refs.remotes]);
59
+ const wantHead = revParam(url, 'head', false);
60
+ const wantBase = revParam(url, 'base', false);
61
+ let rev = revParam(url, 'rev', false);
62
+ // The default base goes with the default branch.
63
+ const baseFor = (head) => (head === refs.defaultBase ? null : refs.defaultBase);
64
+ // A link or bookmark may name a branch that has since been deleted or
65
+ // renamed: use the usual choice instead, and say so.
66
+ let head = wantHead && known.has(wantHead) ? wantHead : null;
67
+ const fallback = refs.current || refs.defaultBase || refs.branches[0] || null;
68
+ let base;
69
+ let notice = null;
70
+ if (wantHead && !head) {
71
+ head = fallback;
72
+ base = baseFor(head);
73
+ notice = `Branch \u201c${wantHead}\u201d no longer exists; showing ${head ?? 'the default'} instead.`;
74
+ }
75
+ else if (wantBase && !known.has(wantBase)) {
76
+ head ??= fallback;
77
+ base = null;
78
+ notice = `The branch to compare with, \u201c${wantBase}\u201d, no longer exists; showing the newest commit of ${head ?? 'the default'}.`;
79
+ }
80
+ else if (head) {
81
+ base = wantBase;
82
+ }
83
+ else {
84
+ head = fallback;
85
+ base = wantBase ?? baseFor(head);
86
+ }
87
+ if (!rev) {
88
+ // Opening with nothing chosen shows the uncommitted work, if snapshotted;
89
+ // choosing a branch shows that branch.
90
+ const wip = !wantHead && !wantBase && gitTry(['rev-parse', '--verify', '--quiet', WIP_REF]);
91
+ rev = wip ? 'wip' : defaultRev(head, base);
92
+ if (!rev)
93
+ throw new GlossError(head ? `${head} has no commits to show` : 'there are no branches to show; pass ?rev= to view a commit');
94
+ if (!notice) {
95
+ const p = new URLSearchParams({ rev });
96
+ if (head)
97
+ p.set('head', head);
98
+ if (base)
99
+ p.set('base', base);
100
+ return send(res, 302, 'text/plain', 'redirecting', { location: `/?${p}` });
101
+ }
102
+ }
47
103
  // "wip" is the latest snapshot of uncommitted work. The server never takes
48
104
  // one (that is `git gloss hunks wip`): browsing doesn't change anything.
49
105
  const target = rev === 'wip' ? gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim() : rev;
@@ -53,22 +109,14 @@ function viewPage(url) {
53
109
  if (shas.length > MAX_RANGE_COMMITS) {
54
110
  throw new GlossError(`${rev} has ${shas.length} commits; a page shows at most ${MAX_RANGE_COMMITS}. Pick a narrower range.`);
55
111
  }
56
- return renderPage(buildPageData(shas, range, merges));
112
+ const data = buildPageData(shas, range, merges);
113
+ data.ary = { repo: refs.repo, branches: refs.branches, remotes: refs.remotes, defaultBase: refs.defaultBase, head, base, rev, notice };
114
+ send(res, 200, 'text/html', renderPage(data), PAGE_HEADERS);
57
115
  }
58
- function route(req, res, url) {
59
- const shell = (name, type) => send(res, 200, type, asset(name), type === 'text/html' ? { 'content-security-policy': SHELL_CSP } : {});
60
- switch (url.pathname) {
61
- case '/': return shell('./index.html', 'text/html');
62
- case '/ary.js': return shell('./client.js', 'text/javascript');
63
- case '/ary.css': return shell('./style.css', 'text/css');
64
- case '/api/refs': return sendJson(res, listRefs());
65
- case '/api/log':
66
- return sendJson(res, listLog(revParam(url, 'head'), revParam(url, 'base', false), intParam(url, 'limit', 100, 1, 500), intParam(url, 'skip', 0, 0, 1e6), (url.searchParams.get('q') ?? '').slice(0, 200)));
67
- case '/view':
68
- // The page may only be framed by the shell, not by another site.
69
- return send(res, 200, 'text/html', viewPage(url), { 'content-security-policy': "frame-ancestors 'self'" });
70
- default: return send(res, 404, 'text/plain', 'not found');
71
- }
116
+ function route(res, url) {
117
+ if (url.pathname === '/')
118
+ return deskPage(url, res);
119
+ send(res, 404, 'text/plain', 'not found');
72
120
  }
73
121
  export function createAry(port) {
74
122
  return createServer((req, res) => {
@@ -80,9 +128,16 @@ export function createAry(port) {
80
128
  return send(res, 403, 'text/plain', `forbidden host: this server answers to ${allowed.join(' and ')} (it listens on this machine only; through an SSH tunnel, forward the same port number)`);
81
129
  if (req.method !== 'GET' && req.method !== 'HEAD')
82
130
  return send(res, 405, 'text/plain', 'read-only', { allow: 'GET, HEAD' });
83
- const url = new URL(req.url ?? '/', 'http://localhost');
84
131
  try {
85
- route(req, res, url);
132
+ // A request target need not be a URL ("//[" is not), and a throw here would end the process.
133
+ let url;
134
+ try {
135
+ url = new URL(req.url ?? '/', 'http://localhost');
136
+ }
137
+ catch {
138
+ throw new BadRequest('not a valid request target');
139
+ }
140
+ route(res, url);
86
141
  }
87
142
  catch (err) {
88
143
  if (!(err instanceof GlossError)) {
@@ -90,12 +145,7 @@ export function createAry(port) {
90
145
  return send(res, 500, 'text/plain', 'internal error');
91
146
  }
92
147
  const status = err instanceof BadRequest ? 400 : 422;
93
- if (url.pathname === '/view') {
94
- send(res, status, 'text/html', `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark"><pre style="white-space:pre-wrap;font:14px/1.5 system-ui;padding:16px">${escapeHtml(err.message)}</pre>`, { 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'" });
95
- }
96
- else {
97
- send(res, status, 'application/json', JSON.stringify({ error: err.message }));
98
- }
148
+ errorPage(res, status, err.message);
99
149
  }
100
150
  });
101
151
  }
@@ -2,10 +2,10 @@ import { parseArgs } from 'node:util';
2
2
  import { createAry } from '../ary/server.js';
3
3
  import { GlossError, git } from '../git.js';
4
4
  import { openInBrowser } from './html.js';
5
- export const usage = 'ary [--port <n>] [--no-open] browse glosses of branches, commits and uncommitted work in a local web page';
5
+ export const usage = 'ary [--port <n>] [--no-open] browse glosses of branches, commits and uncommitted work in a local web page (the html view with branch pickers)';
6
6
  // Starts a read-only server on this machine and keeps running until stopped.
7
- // The browser page lists branches and commits and shows each selection with
8
- // the same view as `git gloss html`.
7
+ // The browser page is the same view as `git gloss html`, with branch pickers
8
+ // and a revision box in its top bar; each choice loads a new page.
9
9
  export function run(argv) {
10
10
  const { values } = parseArgs({
11
11
  args: argv,
@@ -1,4 +1,5 @@
1
1
  import { parseArgs } from 'node:util';
2
+ import { plain } from '../plain.js';
2
3
  import { commitDiff, commitSubject, isWipSnapshot, resolveCommits } from '../git.js';
3
4
  import { parseDiff } from '../diff.js';
4
5
  import { refLabel, sliceText } from '../selection.js';
@@ -15,16 +16,16 @@ function fenced(lang, body) {
15
16
  // Author text is JSON-quoted so it stays on one line and can't fake a heading
16
17
  // or end the material section.
17
18
  const q = (s) => JSON.stringify(s);
18
- const hunkLine = (h) => `- ${h.id} ${h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ` : ''}${h.file} ` +
19
+ const hunkLine = (h) => `- ${h.id} ${plain(h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ${h.file}` : h.file)} ` +
19
20
  `(-${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines}, +${h.added} -${h.removed})`;
20
21
  // The material for one commit: its gloss and the code under each group, or,
21
22
  // for an unglossed commit in a range, its whole diff.
22
23
  function commitMaterial(sha, gloss) {
23
24
  if (!gloss) {
24
25
  return [
25
- `## Commit ${sha.slice(0, 10)}: ${commitSubject(sha)}`,
26
+ `## Commit ${sha.slice(0, 10)}: ${plain(commitSubject(sha))}`,
26
27
  'This commit has no gloss. Review its changes as unexplained.',
27
- fenced('diff', parseDiff(commitDiff(sha)).map((h) => `# ${h.file}\n${h.text}`).join('\n')),
28
+ fenced('diff', parseDiff(commitDiff(sha)).map((h) => `# ${plain(h.file)}\n${h.text}`).join('\n')),
28
29
  ];
29
30
  }
30
31
  const a = gloss.note.annotation;
@@ -47,7 +48,7 @@ function commitMaterial(sha, gloss) {
47
48
  const sections = [
48
49
  isWipSnapshot(sha)
49
50
  ? `## Uncommitted work (snapshot ${sha.slice(0, 10)})\n\nThese changes are not committed yet; the author glossed them before committing.`
50
- : `## Commit ${sha.slice(0, 10)}: ${gloss.subject}`,
51
+ : `## Commit ${sha.slice(0, 10)}: ${plain(gloss.subject)}`,
51
52
  ...gloss.warnings.map((w) => `> **Warning from git-gloss:** ${w}`),
52
53
  `**Task (author's statement):** ${q(a.task)}`,
53
54
  ...(a.summary ? [`**Summary (author's statement):** ${q(a.summary)}`] : []),
@@ -2,14 +2,18 @@ import { randomUUID } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { parseArgs } from 'node:util';
4
4
  import { commitSubject, gitTry, GlossError, resolveCommits } from '../git.js';
5
- import { addReviews, reviewStatus, stamp, statusPhrase, verdictOf } from '../reviews.js';
5
+ import { addReviews, checkBy, checkDecision, MAX_DECISION_LINE, MAX_FINDINGS, MAX_REVIEW_LINE, decisionSummary, readDecisions, readReviews, reviewStatus, stamp, statusPhrase, verdictOf } from '../reviews.js';
6
+ import { plain } from '../plain.js';
6
7
  import { page, style } from '../render.js';
7
- export const usage = 'reviews [<commit> | <range> | wip] [--stamp | --record] the reviews recorded for commits, with their findings; --stamp and --record are for review tools';
8
+ export const usage = 'reviews [<commit> | <range> | wip] [--stamp | --record] the reviews recorded for commits, with their findings and what was decided about them\n reviews <target> --decide <review-id> --finding <text> --outcome <fixed|follow-up|rejected|accepted> [--note <text>] [--by <name>]\n reviews <target> --decide <review-id> --complete the arbiter is done; --stamp, --record and --decide are for tools';
8
9
  export function run(argv) {
9
10
  const { values, positionals } = parseArgs({
10
11
  args: argv,
11
12
  allowPositionals: true,
12
- options: { stamp: { type: 'boolean' }, record: { type: 'boolean' } },
13
+ options: {
14
+ stamp: { type: 'boolean' }, record: { type: 'boolean' }, decide: { type: 'string' }, finding: { type: 'string' },
15
+ outcome: { type: 'string' }, note: { type: 'string' }, complete: { type: 'boolean' }, by: { type: 'string' },
16
+ },
13
17
  });
14
18
  if (values.record) {
15
19
  const input = JSON.parse(readFileSync(0, 'utf8'));
@@ -28,6 +32,9 @@ export function run(argv) {
28
32
  if (!Array.isArray(seen?.lines) || !Array.isArray(seen?.groups))
29
33
  throw new GlossError(`--record: the stamp for ${sha.slice(0, 12)} has no lines or groups`);
30
34
  }
35
+ // Readers skip what is over these limits, so a record that is would vanish: refuse it here instead.
36
+ if (input.findings.length > MAX_FINDINGS)
37
+ throw new GlossError(`--record: the findings are too long (${input.findings.length} characters; readers skip reports over ${MAX_FINDINGS / 1024} KiB); shorten the report`);
31
38
  const id = randomUUID();
32
39
  const at = new Date().toISOString();
33
40
  for (const [sha, seen] of Object.entries(input.stamp)) {
@@ -37,6 +44,8 @@ export function run(argv) {
37
44
  target: input.target, lines: seen.lines, groups: seen.groups, findings: input.findings,
38
45
  ...(verdictOf(input.findings) ? { verdict: verdictOf(input.findings) } : {}),
39
46
  };
47
+ if (JSON.stringify(record).length > MAX_REVIEW_LINE)
48
+ throw new GlossError(`--record: the record for ${sha.slice(0, 10)} is over ${MAX_REVIEW_LINE / 1024 / 1024} MiB with its stamp, which readers skip; review fewer changed lines at a time`);
40
49
  addReviews(sha, [record]);
41
50
  }
42
51
  const n = Object.keys(input.stamp).length;
@@ -44,6 +53,42 @@ export function run(argv) {
44
53
  return 0;
45
54
  }
46
55
  const { shas } = resolveCommits(positionals[0] ?? 'HEAD');
56
+ if (values.decide !== undefined) {
57
+ const id = values.decide;
58
+ if (values.complete) {
59
+ if (values.finding !== undefined || values.outcome !== undefined || values.note !== undefined) {
60
+ throw new GlossError('--complete marks the review as arbitrated; it takes no --finding, --outcome or --note');
61
+ }
62
+ }
63
+ else
64
+ checkDecision(values);
65
+ // Every commit of the target holding that review gets the decision.
66
+ const holders = shas.filter((sha) => readReviews(sha).some((r) => r.id === id));
67
+ if (!holders.length) {
68
+ const known = [...new Set(shas.flatMap(readReviews).map((r) => r.id))];
69
+ throw new GlossError(`no review ${id} on ${positionals[0] ?? 'HEAD'}` + (known.length ? ` (reviews there: ${plain(known.join(', '))})` : ' (it has no reviews)'));
70
+ }
71
+ const by = values.by !== undefined ? values.by.trim() : gitTry(['config', 'user.name'])?.trim() || 'unknown';
72
+ checkBy(by);
73
+ const record = {
74
+ decision: 1, review: id, at: new Date().toISOString(), by,
75
+ ...(values.complete ? { complete: true } : {
76
+ finding: values.finding.trim(), outcome: values.outcome,
77
+ ...(values.note ? { note: values.note } : {}),
78
+ }),
79
+ };
80
+ // Control characters are written as 6-byte escapes, so check the line itself against the reader's cap.
81
+ if (JSON.stringify(record).length > MAX_DECISION_LINE)
82
+ throw new GlossError('that decision is too long to record: the finding and note, as written, exceed what readers accept; shorten them');
83
+ for (const sha of holders)
84
+ addReviews(sha, [record]);
85
+ console.log(`recorded ${values.complete ? 'the review as complete' : `"${record.finding}": ${record.outcome}`} on ${holders.length} commit${holders.length === 1 ? '' : 's'}`);
86
+ return 0;
87
+ }
88
+ for (const k of ['finding', 'outcome', 'note', 'complete', 'by']) {
89
+ if (values[k] !== undefined)
90
+ throw new GlossError(`--${k} goes with --decide <review-id>`);
91
+ }
47
92
  if (values.stamp) {
48
93
  console.log(JSON.stringify(Object.fromEntries(shas.map((sha) => [sha, stamp(sha)]))));
49
94
  return 0;
@@ -51,7 +96,8 @@ export function run(argv) {
51
96
  const out = [];
52
97
  for (const sha of shas) {
53
98
  const statuses = reviewStatus(sha);
54
- out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', commitSubject(sha))}`);
99
+ const decisions = readDecisions(sha);
100
+ out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(commitSubject(sha)))}`);
55
101
  if (!statuses.length) {
56
102
  out.push(style('dim', ' no reviews recorded'), '');
57
103
  continue;
@@ -59,8 +105,15 @@ export function run(argv) {
59
105
  for (const s of statuses) {
60
106
  out.push(` ${statusPhrase(s)}`);
61
107
  for (const r of s.reviews) {
62
- out.push('', style('dim', ` ── ${r.stage} by ${r.agent}${r.model ? `:${r.model}` : ''}${r.mode ? ` (${r.mode})` : ''}, ${r.at.slice(0, 16).replace('T', ' ')}, of ${r.target}`));
108
+ out.push('', style('dim', ` ── ${r.stage} by ${r.agent}${r.model ? `:${r.model}` : ''}${r.mode ? ` (${r.mode})` : ''}, ${r.at.slice(0, 16).replace('T', ' ')}, of ${r.target}, id ${plain(r.id)}`));
63
109
  out.push(...r.findings.trim().split('\n').map((l) => ` ${l}`));
110
+ const ds = decisions.filter((d) => d.review === r.id);
111
+ if (r.verdict !== 'clean' || ds.length) {
112
+ out.push('', ` decisions on this review: ${decisionSummary(ds)}`);
113
+ for (const d of ds.filter((d) => d.outcome).sort((a, b) => a.at.localeCompare(b.at))) {
114
+ out.push(` ${d.outcome}: ${plain(d.finding)}${d.note ? ` — ${plain(d.note)}` : ''} ${style('dim', `(${plain(d.by)}, ${plain(d.at).slice(0, 10)})`)}`);
115
+ }
116
+ }
64
117
  }
65
118
  out.push('');
66
119
  }