@ok-ggwp/git-gloss 0.1.0 → 0.3.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.
@@ -0,0 +1,101 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createServer } from 'node:http';
3
+ import { GlossError, WIP_REF, gitTry, resolveCommits } from '../git.js';
4
+ import { buildPageData } from '../html/data.js';
5
+ 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');
8
+ // A range page loads and lays out every commit in it, so a huge one would hang
9
+ // the browser. Narrowing it is a better answer than a spinner.
10
+ 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
+ class BadRequest extends GlossError {
15
+ }
16
+ // A revision, branch or range typed by the user or taken from the sidebar. An
17
+ // option-looking one is refused so it can never be read by git as a flag.
18
+ function revParam(url, name, required = true) {
19
+ const v = url.searchParams.get(name);
20
+ if (!v) {
21
+ if (required)
22
+ throw new BadRequest(`missing ?${name}=`);
23
+ return null;
24
+ }
25
+ if (v.startsWith('-') || v.length > 500 || v.includes('\0'))
26
+ throw new BadRequest(`not a revision: ${v.slice(0, 80)}`);
27
+ return v;
28
+ }
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
+ function send(res, status, type, body, headers = {}) {
34
+ res.writeHead(status, {
35
+ 'content-type': `${type}; charset=utf-8`,
36
+ // Everything reflects the repo as it is now.
37
+ 'cache-control': 'no-store',
38
+ 'x-content-type-options': 'nosniff',
39
+ 'referrer-policy': 'no-referrer',
40
+ ...headers,
41
+ });
42
+ res.end(body);
43
+ }
44
+ const sendJson = (res, data) => send(res, 200, 'application/json', JSON.stringify(data));
45
+ function viewPage(url) {
46
+ const rev = revParam(url, 'rev');
47
+ // "wip" is the latest snapshot of uncommitted work. The server never takes
48
+ // one (that is `git gloss hunks wip`): browsing doesn't change anything.
49
+ const target = rev === 'wip' ? gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim() : rev;
50
+ if (!target)
51
+ throw new GlossError('no snapshot of uncommitted work yet; run `git gloss hunks wip` first');
52
+ const { shas, merges, range } = resolveCommits(target);
53
+ if (shas.length > MAX_RANGE_COMMITS) {
54
+ throw new GlossError(`${rev} has ${shas.length} commits; a page shows at most ${MAX_RANGE_COMMITS}. Pick a narrower range.`);
55
+ }
56
+ return renderPage(buildPageData(shas, range, merges));
57
+ }
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
+ }
72
+ }
73
+ export function createAry(port) {
74
+ return createServer((req, res) => {
75
+ // A web page on another site can make the browser send requests here, and
76
+ // with DNS rebinding even read the answers. Only our own origin's host
77
+ // names get through; the server is read-only besides.
78
+ const allowed = [`127.0.0.1:${port()}`, `localhost:${port()}`];
79
+ if (!allowed.includes((req.headers.host ?? '').toLowerCase()))
80
+ 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
+ if (req.method !== 'GET' && req.method !== 'HEAD')
82
+ return send(res, 405, 'text/plain', 'read-only', { allow: 'GET, HEAD' });
83
+ const url = new URL(req.url ?? '/', 'http://localhost');
84
+ try {
85
+ route(req, res, url);
86
+ }
87
+ catch (err) {
88
+ if (!(err instanceof GlossError)) {
89
+ console.error(err);
90
+ return send(res, 500, 'text/plain', 'internal error');
91
+ }
92
+ 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
+ }
99
+ }
100
+ });
101
+ }
@@ -0,0 +1,115 @@
1
+ :root {
2
+ --bg: #f7f7f5;
3
+ --surface: #ffffff;
4
+ --surface-2: #f1f1ee;
5
+ --border: #e2e2dc;
6
+ --text: #1d1d1b;
7
+ --muted: #6b6b66;
8
+ --accent: #3b5bdb;
9
+ --ok: #1a7f37;
10
+ --warn-bg: #fff4e5;
11
+ --warn-border: #f0b35b;
12
+ --warn-text: #7a4300;
13
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
14
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
15
+ color-scheme: light;
16
+ }
17
+ @media (prefers-color-scheme: dark) {
18
+ :root {
19
+ --bg: #151514;
20
+ --surface: #1e1e1c;
21
+ --surface-2: #262624;
22
+ --border: #34342f;
23
+ --text: #e8e8e3;
24
+ --muted: #9a9a92;
25
+ --accent: #8ea2ff;
26
+ --ok: #57c47a;
27
+ --warn-bg: #33270f;
28
+ --warn-border: #a2701e;
29
+ --warn-text: #f3c77a;
30
+ color-scheme: dark;
31
+ }
32
+ }
33
+
34
+ * { box-sizing: border-box; }
35
+ html, body, #app { height: 100%; }
36
+ body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 var(--sans); }
37
+ button, input, select { font: inherit; color: inherit; }
38
+
39
+ .shell { display: grid; grid-template-columns: 340px minmax(0, 1fr); height: 100%; }
40
+ @media (max-width: 800px) {
41
+ .shell { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 45%) minmax(0, 1fr); }
42
+ }
43
+
44
+ /* Sidebar */
45
+ .side { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--border); background: var(--surface); }
46
+ .side-top { padding: 12px; border-bottom: 1px solid var(--border); display: grid; gap: 10px; }
47
+ .title { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
48
+ .title strong { font-size: 15px; }
49
+ .title .repo { color: var(--muted); font-family: var(--mono); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
50
+ label.field, div.field { display: grid; gap: 3px; color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; }
51
+ label.field > input, .rev-row input, .combo-btn, .combo-panel input {
52
+ width: 100%; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px;
53
+ background: var(--bg); text-transform: none; letter-spacing: 0; font-size: 13px;
54
+ }
55
+ .combo { position: relative; }
56
+ .combo-btn { text-align: left; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 24px; position: relative; }
57
+ .combo-btn::after { content: "▾"; position: absolute; right: 8px; color: var(--muted); }
58
+ .combo-panel {
59
+ position: absolute; z-index: 10; left: 0; right: 0; top: calc(100% + 4px);
60
+ display: grid; gap: 6px; padding: 6px; border: 1px solid var(--border); border-radius: 8px;
61
+ background: var(--surface); box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
62
+ }
63
+ .combo-panel[hidden] { display: none; }
64
+ .combo-list { max-height: min(50vh, 320px); overflow: auto; }
65
+ .combo-group {
66
+ margin-top: 8px; padding: 10px 8px 4px; border-top: 1px solid var(--border);
67
+ font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text);
68
+ }
69
+ .combo-group:first-child { margin-top: 0; padding-top: 4px; border-top: 0; }
70
+ .combo-group .count { margin-left: 6px; color: var(--muted); font-weight: 400; }
71
+ .combo-item { display: block; width: 100%; text-align: left; border: 0; background: none; border-radius: 5px; padding: 4px 8px 4px 20px; cursor: pointer; font-family: var(--mono); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
72
+ .combo-item:hover, .combo-item.active { background: var(--surface-2); }
73
+ .combo-item[aria-selected="true"] { color: var(--accent); font-weight: 600; }
74
+ .combo-more { padding: 6px 8px; color: var(--muted); font-size: 12.5px; }
75
+ .rev-row { display: flex; gap: 6px; }
76
+ .rev-row input { font-family: var(--mono); font-size: 12.5px; }
77
+ .btn {
78
+ border: 1px solid var(--border); background: var(--surface); border-radius: 6px;
79
+ padding: 4px 10px; cursor: pointer; white-space: nowrap;
80
+ }
81
+ .btn:hover { background: var(--surface-2); }
82
+ .error { margin: 0; padding: 8px 10px; border-radius: 6px; background: var(--warn-bg); border: 1px solid var(--warn-border); color: var(--warn-text); font-size: 12.5px; }
83
+
84
+ .list { flex: 1; min-height: 0; overflow: auto; padding: 6px; }
85
+ .list h2 { margin: 10px 8px 4px; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 600; }
86
+ .row {
87
+ display: block; width: 100%; text-align: left; cursor: pointer;
88
+ border: 1px solid transparent; background: none; border-radius: 6px; padding: 6px 8px;
89
+ }
90
+ .row:hover { background: var(--surface-2); }
91
+ .row[aria-current="true"] { background: var(--surface-2); border-color: var(--accent); }
92
+ .row .subject {
93
+ /* the whole point of the list: enough of the subject to tell commits apart */
94
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2;
95
+ overflow: hidden; overflow-wrap: anywhere;
96
+ }
97
+ .row .meta { display: flex; flex-wrap: wrap; align-items: baseline; margin-top: 2px; color: var(--muted); font-size: 12px; }
98
+ .row .marks { margin-left: auto; padding-left: 10px; display: inline-flex; gap: 10px; white-space: nowrap; }
99
+ .sha { font-family: var(--mono); font-size: 12px; }
100
+ .mark.glossed { color: var(--ok); }
101
+ .mark.reviewed { color: var(--accent); }
102
+ .mark.warn { color: var(--warn-text); background: var(--warn-bg); border-radius: 4px; padding: 0 5px; }
103
+ .search-bar { padding: 8px 12px; border-bottom: 1px solid var(--border); }
104
+ .search-bar input { width: 100%; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); font-size: 13px; }
105
+ .empty { color: var(--muted); padding: 8px; }
106
+ .more { margin: 6px 8px; }
107
+
108
+ /* Viewer */
109
+ .viewer { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
110
+ .viewer-bar { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--surface); }
111
+ .viewer-bar .what { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
112
+ .viewer-bar a { color: var(--accent); text-decoration: none; }
113
+ .viewer-bar a:hover { text-decoration: underline; }
114
+ iframe { flex: 1; width: 100%; min-height: 0; border: 0; background: var(--bg); }
115
+ .placeholder { flex: 1; display: grid; place-items: center; color: var(--muted); padding: 24px; text-align: center; }
package/dist/cli.js CHANGED
@@ -1,18 +1,20 @@
1
1
  import { GlossError } from './git.js';
2
2
  import * as add from './commands/add.js';
3
3
  import * as adopt from './commands/adopt.js';
4
+ import * as ary from './commands/ary.js';
4
5
  import * as carry from './commands/carry.js';
5
6
  import * as html from './commands/html.js';
6
7
  import * as hunks from './commands/hunks.js';
7
8
  import * as init from './commands/init.js';
8
9
  import * as review from './commands/review.js';
10
+ import * as reviews from './commands/reviews.js';
9
11
  import * as schema from './commands/schema.js';
10
12
  import * as show from './commands/show.js';
11
13
  import * as sync from './commands/sync.js';
12
14
  import * as versions from './commands/versions.js';
13
15
  import * as why from './commands/why.js';
14
16
  const COMMANDS = {
15
- init, hunks, add, adopt, carry, show, html, why, review, sync, versions, schema,
17
+ init, hunks, add, adopt, carry, show, html, ary, why, review, reviews, sync, versions, schema,
16
18
  };
17
19
  function help() {
18
20
  return 'usage: git gloss <command> [options]\n\n' +
@@ -6,6 +6,7 @@ import { location, parseDiff } from '../diff.js';
6
6
  import { noteFormat } from '../schema.js';
7
7
  import { refLabel } from '../selection.js';
8
8
  import { carriedErrors } from '../validate.js';
9
+ import { copyReviews } from '../reviews.js';
9
10
  export const usage = 'adopt [<commit>] [--force] move the gloss of uncommitted work (wip) onto a commit made from it';
10
11
  // Carry a wip snapshot's gloss onto a real commit made from it (see
11
12
  // carryGloss): a commit takes the groups whose hunks it contains, so the work
@@ -70,6 +71,8 @@ export function run(argv) {
70
71
  annotation,
71
72
  hunks: current.map(location),
72
73
  });
74
+ // Reviews of the uncommitted work cover the parts of it this commit holds.
75
+ copyReviews([wip], sha);
73
76
  // Once nothing uncommitted is left, the snapshot has served its purpose.
74
77
  const head = gitTry(['rev-parse', '--verify', '--quiet', 'HEAD'])?.trim();
75
78
  const done = head === sha && git(['status', '--porcelain']).trim() === '';
@@ -0,0 +1,35 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { createAry } from '../ary/server.js';
3
+ import { GlossError, git } from '../git.js';
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';
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`.
9
+ export function run(argv) {
10
+ const { values } = parseArgs({
11
+ args: argv,
12
+ options: { port: { type: 'string' }, 'no-open': { type: 'boolean' } },
13
+ });
14
+ // Number('') and Number(' ') are 0, which would quietly mean "any port".
15
+ const port = values.port === undefined ? 0 : /^\d+$/.test(values.port) ? Number(values.port) : NaN;
16
+ if (!Number.isInteger(port) || port > 65535)
17
+ throw new GlossError(`not a port: ${values.port}`);
18
+ git(['rev-parse', '--git-dir']); // fail early outside a repository
19
+ let boundPort = port;
20
+ const server = createAry(() => boundPort);
21
+ server.on('error', (err) => {
22
+ console.error(`error: ${err.code === 'EADDRINUSE' ? `port ${port} is in use` : err.message}`);
23
+ process.exitCode = 1;
24
+ });
25
+ // Loopback only: the view includes the code of your commits.
26
+ server.listen(port, '127.0.0.1', () => {
27
+ boundPort = server.address().port;
28
+ const url = `http://127.0.0.1:${boundPort}/`;
29
+ console.log(url);
30
+ console.error('serving glosses read-only; press Ctrl-C to stop');
31
+ if (!values['no-open'])
32
+ openInBrowser(url);
33
+ });
34
+ return 0;
35
+ }
@@ -8,6 +8,7 @@ import { mergeGlosses } from '../merge.js';
8
8
  import { noteFormat } from '../schema.js';
9
9
  import { refLabel } from '../selection.js';
10
10
  import { carriedErrors } from '../validate.js';
11
+ import { copyReviews } from '../reviews.js';
11
12
  export const usage = 'carry <source>... [--to <commit>] [--force] | carry --find [--squash] [<commit>...] gloss a commit from the glosses of the commits its changes came from (a squash, cherry-pick or rebase); --find works out which commits those are';
12
13
  // The target's current gloss: none, one written by hand, one carried, or one
13
14
  // that can't be read (such as the joined notes a squash leaves). Carrying may
@@ -68,6 +69,8 @@ export function carry(sourceShas, target) {
68
69
  hunks: current.map(location),
69
70
  sources: merged.used,
70
71
  });
72
+ // The sources' reviews come along; their keys decide what of this commit they cover.
73
+ copyReviews(merged.used, target);
71
74
  const lines = [`carried ${merged.annotation.groups.length} group${merged.annotation.groups.length === 1 ? '' : 's'} from ` +
72
75
  `${merged.used.map((s) => s.slice(0, 7)).join(', ')} onto ${short}`];
73
76
  if (merged.dropped.length)
@@ -7,7 +7,7 @@ import { resolveCommits } from '../git.js';
7
7
  import { buildPageData } from '../html/data.js';
8
8
  import { renderPage } from '../html/page.js';
9
9
  export const usage = 'html [<commit> | <range>] [-o <file>] [--open] write a self-contained HTML view of a gloss, or of every commit in a range';
10
- function openInBrowser(file) {
10
+ export function openInBrowser(file) {
11
11
  const [cmd, args] = process.platform === 'darwin' ? ['open', [file]]
12
12
  : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', file]]
13
13
  : ['xdg-open', [file]];
@@ -1,6 +1,7 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { commitDiff, dropWipNote, readNote, resolveCommit, snapshotWorkingTree, WIP_NOTES_REF, writeNote } from '../git.js';
3
3
  import { carryGloss } from '../carry.js';
4
+ import { copyReviews } from '../reviews.js';
4
5
  import { location, parseDiff } from '../diff.js';
5
6
  import { noteFormat } from '../schema.js';
6
7
  import { colorDiff, hunkLabel, page, style } from '../render.js';
@@ -40,6 +41,9 @@ export function run(argv) {
40
41
  // them, since their text is discarded with the old snapshot's gloss.
41
42
  if (carried.dropped.length)
42
43
  console.error(`note: not kept (their changes were edited or committed): ${carried.dropped.join(', ')}`);
44
+ // Reviews of the old snapshot come along; their fingerprints say what
45
+ // of the new one they still cover.
46
+ copyReviews([snap.replaced], sha);
43
47
  dropWipNote(snap.replaced);
44
48
  }
45
49
  }
@@ -3,6 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'n
3
3
  import { join, resolve, sep } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { git, gitTry, NOTES_REF, REMOTE_NOTES_REF, WIP_REF } from '../git.js';
6
+ import { REVIEWS_REF } from '../reviews.js';
6
7
  export const usage = 'init [--hooks] set up glosses to follow amends/rebases and fetch from origin; --hooks: sync on push, adopt wip glosses on commit';
7
8
  const HOOK_MARKER = '# installed by git-gloss';
8
9
  // Runs before every `git push`. It syncs glosses with the remote being pushed
@@ -84,23 +85,31 @@ function installHook(name, body, done, manual) {
84
85
  }
85
86
  export function run(argv = []) {
86
87
  const { values } = parseArgs({ args: argv, options: { hooks: { type: 'boolean' } } });
88
+ // Reviews follow commits too; a squash joins their lines, which is what they need.
87
89
  const rewriteRefs = gitTry(['config', '--get-all', 'notes.rewriteRef'])?.split('\n') ?? [];
88
- if (!rewriteRefs.includes(NOTES_REF))
89
- git(['config', '--add', 'notes.rewriteRef', NOTES_REF]);
90
+ for (const ref of [NOTES_REF, REVIEWS_REF])
91
+ if (!rewriteRefs.includes(ref))
92
+ git(['config', '--add', 'notes.rewriteRef', ref]);
90
93
  console.log(`✓ glosses follow commits through amend and rebase (notes.rewriteRef=${NOTES_REF})`);
91
94
  console.log(' if the commit\'s diff changes, `show` and `review` will flag the gloss as stale');
92
95
  if (gitTry(['remote', 'get-url', 'origin'])) {
93
96
  // Fetch into a separate ref and merge explicitly. Fetching straight into
94
97
  // NOTES_REF with a forced refspec would overwrite unpushed local glosses.
95
- const refspec = `+${NOTES_REF}:${REMOTE_NOTES_REF}`;
96
- // Earlier versions of init added exactly that destructive refspec.
98
+ // One glob for the glosses and their reviews: a glob doesn't make
99
+ // `git fetch` fail while origin has none yet, as a plain refspec does.
100
+ const refspec = `+${NOTES_REF}*:${REMOTE_NOTES_REF}*`;
101
+ const fetches = () => gitTry(['config', '--get-all', 'remote.origin.fetch'])?.split('\n') ?? [];
102
+ // Earlier versions of init added exactly that destructive refspec, and
103
+ // later ones a plain one that the glob replaces.
97
104
  const legacy = `+${NOTES_REF}:${NOTES_REF}`;
98
- if (gitTry(['config', '--get-all', 'remote.origin.fetch'])?.split('\n').includes(legacy)) {
105
+ if (fetches().includes(legacy)) {
99
106
  git(['config', '--unset-all', '--fixed-value', 'remote.origin.fetch', legacy]);
100
107
  console.log(`✓ removed the old refspec ${legacy}, which overwrote local glosses on fetch`);
101
108
  }
102
- const fetches = gitTry(['config', '--get-all', 'remote.origin.fetch'])?.split('\n') ?? [];
103
- if (!fetches.includes(refspec))
109
+ const plain = `+${NOTES_REF}:${REMOTE_NOTES_REF}`;
110
+ if (fetches().includes(plain))
111
+ git(['config', '--unset-all', '--fixed-value', 'remote.origin.fetch', plain]);
112
+ if (!fetches().includes(refspec))
104
113
  git(['config', '--add', 'remote.origin.fetch', refspec]);
105
114
  console.log(`✓ \`git fetch\` stores origin's glosses in ${REMOTE_NOTES_REF}`);
106
115
  if (!values.hooks)
@@ -113,7 +122,12 @@ export function run(argv = []) {
113
122
  // The hooks run `git gloss`; a git without it on PATH (a GUI client, a
114
123
  // different shell) would print an error after every commit and push.
115
124
  if (spawnSync('sh', ['-c', 'command -v git-gloss'], { stdio: 'ignore' }).status !== 0) {
116
- console.log('! git-gloss is not on PATH here; the hooks need it wherever git runs (see README: npm link)');
125
+ // Without it, every commit and push prints an error and the gloss isn't
126
+ // moved, synced or carried, so say that plainly.
127
+ console.log('! git-gloss is not on PATH in this shell, so the hooks will fail on every commit and push.');
128
+ console.log(' Their errors are only warnings, and glosses are not adopted, carried or synced until it is fixed.');
129
+ console.log(' Either install it globally (npm install -g @ok-ggwp/git-gloss), or, for a project install,');
130
+ console.log(' put its node_modules/.bin first on PATH in each hook (see README: "Project installs").');
117
131
  }
118
132
  installHook('pre-push', HOOK, '`git push` now syncs glosses with the remote it pushes to', [
119
133
  'git remote | grep -qxF -- "$1" && { GIT_TERMINAL_PROMPT=0 git gloss sync "$1" --pushing >&2 ||',
@@ -107,7 +107,9 @@ Report, most important first:
107
107
  5. **Missing risks**: risks the author did not list.${range ? `
108
108
  6. **Across the branch**: later commits that undo, fix or rework earlier ones; the same intent spread over several commits; unglossed commits; whether the branch as a whole is lean for what it sets out to do.` : ''}
109
109
 
110
- For each finding, cite the ${range ? 'commit and ' : ''}group id and explain briefly. If the annotation is accurate and the change is lean, say so.`,
110
+ For each finding, cite the ${range ? 'commit and ' : ''}group id and explain briefly. If the annotation is accurate and the change is lean, say so.
111
+
112
+ End your report with one line on its own: \`VERDICT: CLEAN\` if you found no mismatches, misgrading or missing risks worth acting on, otherwise \`VERDICT: ISSUES\`. Tools record this line with the review.`,
111
113
  '---\n\n# Material under review',
112
114
  ...material,
113
115
  ];
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { parseArgs } from 'node:util';
4
+ import { commitSubject, gitTry, GlossError, resolveCommits } from '../git.js';
5
+ import { addReviews, reviewStatus, stamp, statusPhrase, verdictOf } from '../reviews.js';
6
+ 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 function run(argv) {
9
+ const { values, positionals } = parseArgs({
10
+ args: argv,
11
+ allowPositionals: true,
12
+ options: { stamp: { type: 'boolean' }, record: { type: 'boolean' } },
13
+ });
14
+ if (values.record) {
15
+ const input = JSON.parse(readFileSync(0, 'utf8'));
16
+ for (const k of ['stage', 'agent', 'target', 'findings']) {
17
+ if (typeof input[k] !== 'string' || !input[k])
18
+ throw new GlossError(`--record: "${k}" is missing`);
19
+ }
20
+ if (!input.stamp || typeof input.stamp !== 'object')
21
+ throw new GlossError('--record: "stamp" is missing (take it with --stamp before the review)');
22
+ // Every commit the stamp names must exist before anything is written, so
23
+ // a bad stamp records nothing rather than part of the review.
24
+ for (const [sha, seen] of Object.entries(input.stamp)) {
25
+ if (!/^[0-9a-f]{40,64}$/.test(sha) || gitTry(['cat-file', '-e', `${sha}^{commit}`]) === null) {
26
+ throw new GlossError(`--record: the stamp names ${sha.slice(0, 12)}, which isn't a commit here`);
27
+ }
28
+ if (!Array.isArray(seen?.lines) || !Array.isArray(seen?.groups))
29
+ throw new GlossError(`--record: the stamp for ${sha.slice(0, 12)} has no lines or groups`);
30
+ }
31
+ const id = randomUUID();
32
+ const at = new Date().toISOString();
33
+ for (const [sha, seen] of Object.entries(input.stamp)) {
34
+ const record = {
35
+ review: 1, id, at, stage: input.stage, agent: input.agent,
36
+ ...(input.model ? { model: input.model } : {}), ...(input.mode ? { mode: input.mode } : {}),
37
+ target: input.target, lines: seen.lines, groups: seen.groups, findings: input.findings,
38
+ ...(verdictOf(input.findings) ? { verdict: verdictOf(input.findings) } : {}),
39
+ };
40
+ addReviews(sha, [record]);
41
+ }
42
+ const n = Object.keys(input.stamp).length;
43
+ console.log(`recorded the ${input.stage} review on ${n} commit${n === 1 ? '' : 's'}`);
44
+ return 0;
45
+ }
46
+ const { shas } = resolveCommits(positionals[0] ?? 'HEAD');
47
+ if (values.stamp) {
48
+ console.log(JSON.stringify(Object.fromEntries(shas.map((sha) => [sha, stamp(sha)]))));
49
+ return 0;
50
+ }
51
+ const out = [];
52
+ for (const sha of shas) {
53
+ const statuses = reviewStatus(sha);
54
+ out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', commitSubject(sha))}`);
55
+ if (!statuses.length) {
56
+ out.push(style('dim', ' no reviews recorded'), '');
57
+ continue;
58
+ }
59
+ for (const s of statuses) {
60
+ out.push(` ${statusPhrase(s)}`);
61
+ 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}`));
63
+ out.push(...r.findings.trim().split('\n').map((l) => ` ${l}`));
64
+ }
65
+ out.push('');
66
+ }
67
+ }
68
+ page(out.join('\n'));
69
+ return 0;
70
+ }
@@ -3,6 +3,7 @@ import { commitSubject, gitTry, readNote, resolveCommits } from '../git.js';
3
3
  import { refLabel, sliceStats, sliceText } from '../selection.js';
4
4
  import { loadGloss } from '../loader.js';
5
5
  import { NECESSITY } from '../schema.js';
6
+ import { current, reviewStatus, statusPhrase, verdict } from '../reviews.js';
6
7
  import { colorDiff, field, groupHeader, hunkLabel, necessityWord, noteFooter, page, style, termWidth, wrap, } from '../render.js';
7
8
  export const usage = 'show [<commit> | <range>] [-g <group>]... [--full] [--diff] [--only <necessity,...>] summarise a gloss, or each commit in a range';
8
9
  // Counts over the lines each part selects, not whole hunks, so groups that
@@ -77,8 +78,15 @@ export function run(argv) {
77
78
  const glosses = shas.map((sha) => loadGloss(sha));
78
79
  const glossed = glosses.filter(Boolean).length;
79
80
  const stale = glosses.filter((g) => g?.stale).length;
81
+ // How many glossed commits had their gloss checked: an honesty review that
82
+ // saw this code and this gloss.
83
+ const honesty = shas.map((sha, i) => (glosses[i] ? reviewStatus(sha).find((r) => r.stage === 'honesty' && current(r)) : undefined));
84
+ const vetted = honesty.filter(Boolean).length;
85
+ const flagged = honesty.filter((r) => r && verdict(r) === 'issues').length;
80
86
  const out = [
81
87
  style('bold', `${range}: ${shas.length} commit${shas.length === 1 ? '' : 's'}, ${glossed} glossed`) +
88
+ (glossed ? style(vetted === glossed ? 'green' : 'yellow', `, ${vetted} honesty-reviewed`) : '') +
89
+ (flagged ? style('yellow', ` (${flagged} with issues)`) : '') +
82
90
  (stale ? style('red', ` (${stale} stale)`) : '') +
83
91
  (merges.length ? style('dim', ` (${merges.length} merge${merges.length === 1 ? '' : 's'} not shown)`) : ''),
84
92
  ];
@@ -98,6 +106,15 @@ export function run(argv) {
98
106
  page(out.join('\n'));
99
107
  return 0;
100
108
  }
109
+ // One line on whether the commit as it is now was reviewed, per stage.
110
+ function reviewsLine(sha) {
111
+ const statuses = reviewStatus(sha);
112
+ if (!statuses.length)
113
+ return style('dim', 'reviews: none recorded');
114
+ const color = (s) => (!current(s) || verdict(s) === 'issues' ? 'yellow' : verdict(s) === 'clean' ? 'green' : 'dim');
115
+ return `reviews: ${statuses.map((s) => style(color(s), statusPhrase(s))).join(style('dim', ' · '))}` +
116
+ style('dim', ` (findings: git gloss reviews ${sha.slice(0, 7)})`);
117
+ }
101
118
  // One commit's view, or null if no group matches -g/--only. `hints` adds the
102
119
  // footer on how to expand, which a range prints once instead.
103
120
  function renderCommit(sha, gloss, values, only, hints) {
@@ -109,6 +126,7 @@ function renderCommit(sha, gloss, values, only, hints) {
109
126
  out.push(...field('summary:', a.summary, 0));
110
127
  for (const w of gloss.warnings)
111
128
  out.push(...field('⚠', w, 0).map((l) => style('red', l)));
129
+ out.push(reviewsLine(sha));
112
130
  const groups = gloss.groups
113
131
  .filter((g) => !only || only.includes(g.group.necessity))
114
132
  .filter((g) => !values.group || values.group.includes(g.group.id));
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { parseArgs } from 'node:util';
3
3
  import { git, gitTry, GitError, NOTES_REF } from '../git.js';
4
4
  import { carryNew } from '../origins.js';
5
+ import { REVIEWS_REF } from '../reviews.js';
5
6
  import { carry } from './carry.js';
6
7
  export const usage = 'sync [<remote>] fetch, merge and push glosses (default remote: origin)';
7
8
  const MAX_ATTEMPTS = 3;
@@ -150,6 +151,51 @@ function round(remote, pushing, carried) {
150
151
  }
151
152
  return { fetched, pushed: toPush.length, withheld, conflicts };
152
153
  }
154
+ const REVIEWS_PUSH_REF = 'refs/notes/gloss-reviews-sync-tmp';
155
+ // Reviews are shared like glosses, but never conflict: each review is a line,
156
+ // and both sides' lines are kept (git's cat_sort_uniq). Only reviews of
157
+ // commits the remote may see are sent. Returns null if the push lost a race.
158
+ function syncReviews(remote, pushing) {
159
+ const tracking = `refs/notes/remotes/${remote}/gloss-reviews`;
160
+ const remoteHas = gitTry(['ls-remote', '--exit-code', remote, REVIEWS_REF]) !== null;
161
+ if (remoteHas)
162
+ git(['fetch', '-q', remote, `+${REVIEWS_REF}:${tracking}`]);
163
+ const before = noteList(REVIEWS_REF);
164
+ const theirs = remoteHas ? noteList(tracking) : new Map();
165
+ if (remoteHas) {
166
+ if (gitTry(['rev-parse', '-q', '--verify', REVIEWS_REF]) === null)
167
+ git(['update-ref', REVIEWS_REF, tracking]);
168
+ else
169
+ git(['notes', '--ref', REVIEWS_REF, 'merge', '-q', '-s', 'cat_sort_uniq', tracking]);
170
+ }
171
+ const merged = noteList(REVIEWS_REF);
172
+ const fetched = [...merged].filter(([object, blob]) => before.get(object) !== blob).length;
173
+ const allowed = shareable(remote, pushing);
174
+ const toPush = [...merged].filter(([object, blob]) => allowed.has(object) && theirs.get(object) !== blob);
175
+ if (!toPush.length)
176
+ return { fetched, pushed: 0 };
177
+ try {
178
+ if (remoteHas)
179
+ git(['update-ref', REVIEWS_PUSH_REF, tracking]);
180
+ else
181
+ gitTry(['update-ref', '-d', REVIEWS_PUSH_REF]);
182
+ for (const [object, blob] of toPush)
183
+ git(['notes', '--ref', REVIEWS_PUSH_REF, 'add', '-f', '-C', blob, object]);
184
+ try {
185
+ git(['push', '-q', '--no-verify', remote, `${REVIEWS_PUSH_REF}:${REVIEWS_REF}`]);
186
+ }
187
+ catch (err) {
188
+ if (/\(fetch first\)|non-fast-forward/.test(err.message))
189
+ return null;
190
+ throw err;
191
+ }
192
+ git(['notes', '--ref', REVIEWS_REF, 'merge', '-q', '-s', 'cat_sort_uniq', REVIEWS_PUSH_REF]);
193
+ }
194
+ finally {
195
+ gitTry(['update-ref', '-d', REVIEWS_PUSH_REF]);
196
+ }
197
+ return { fetched, pushed: toPush.length };
198
+ }
153
199
  export function run(argv) {
154
200
  // --pushing (from the pre-push hook): read the refs being pushed from stdin,
155
201
  // as git gives them to the hook, so glosses of those commits go too.
@@ -183,6 +229,13 @@ export function run(argv) {
183
229
  }
184
230
  for (const c of carried)
185
231
  console.log(c);
232
+ let reviews = null;
233
+ for (let tries = 0; tries < MAX_ATTEMPTS && !reviews; tries++)
234
+ reviews = syncReviews(remote, pushing);
235
+ if (!reviews)
236
+ console.log(` reviews not synced: others kept pushing reviews to ${remote}; run git gloss sync again`);
237
+ else if (reviews.fetched || reviews.pushed)
238
+ console.log(` reviews: fetched ${reviews.fetched}, pushed ${reviews.pushed}`);
186
239
  if (outcome.withheld) {
187
240
  console.log(` kept ${outcome.withheld} gloss${outcome.withheld === 1 ? '' : 'es'} local: their commits aren't on ${remote} yet`);
188
241
  }
package/dist/git.js CHANGED
@@ -146,7 +146,8 @@ export function resolveCommit(rev) {
146
146
  // "wip": the latest snapshot of uncommitted work (made by `hunks wip`). A
147
147
  // branch or tag named wip still works when there is no snapshot, and can be
148
148
  // reached as heads/wip or tags/wip when there is.
149
- const named = gitTry(['rev-parse', '--verify', '--quiet', `${rev}^{commit}`])?.trim();
149
+ // --end-of-options: the revision is whatever the user typed, never a flag.
150
+ const named = gitTry(['rev-parse', '--verify', '--quiet', '--end-of-options', `${rev}^{commit}`])?.trim();
150
151
  if (rev === 'wip') {
151
152
  const sha = gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim();
152
153
  if (sha) {