@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.
- package/README.md +51 -4
- package/dist/ary/browse.js +80 -0
- package/dist/ary/client.js +401 -0
- package/dist/ary/index.html +13 -0
- package/dist/ary/server.js +101 -0
- package/dist/ary/style.css +115 -0
- package/dist/cli.js +3 -1
- package/dist/commands/adopt.js +3 -0
- package/dist/commands/ary.js +35 -0
- package/dist/commands/carry.js +3 -0
- package/dist/commands/html.js +1 -1
- package/dist/commands/hunks.js +4 -0
- package/dist/commands/init.js +22 -8
- package/dist/commands/review.js +3 -1
- package/dist/commands/reviews.js +70 -0
- package/dist/commands/show.js +18 -0
- package/dist/commands/sync.js +53 -0
- package/dist/git.js +2 -1
- package/dist/html/client.js +30 -1
- package/dist/html/data.js +13 -0
- package/dist/html/page.js +1 -1
- package/dist/html/style.css +9 -0
- package/dist/reviews.js +205 -0
- package/package.json +1 -1
- package/skills/gloss/SKILL.md +8 -0
|
@@ -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' +
|
package/dist/commands/adopt.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/carry.js
CHANGED
|
@@ -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)
|
package/dist/commands/html.js
CHANGED
|
@@ -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]];
|
package/dist/commands/hunks.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
96
|
-
//
|
|
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 (
|
|
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
|
|
103
|
-
if (
|
|
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
|
-
|
|
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 ||',
|
package/dist/commands/review.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/show.js
CHANGED
|
@@ -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));
|
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|