@ok-ggwp/git-gloss 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -3
- 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/attention.js +34 -0
- package/dist/carry.js +4 -1
- package/dist/cli.js +3 -1
- package/dist/commands/add.js +17 -1
- package/dist/commands/ary.js +35 -0
- package/dist/commands/html.js +1 -1
- package/dist/commands/hunks.js +8 -1
- package/dist/commands/init.js +6 -1
- package/dist/commands/show.js +44 -16
- package/dist/commands/signals.js +88 -0
- package/dist/config.js +192 -0
- package/dist/floor.js +148 -0
- package/dist/git.js +13 -2
- package/dist/glob.js +76 -0
- package/dist/html/client.js +1016 -296
- package/dist/html/data.js +26 -1
- package/dist/html/page.js +1 -1
- package/dist/html/style.css +284 -164
- package/dist/loader.js +1 -1
- package/dist/merge.js +5 -0
- package/dist/render.js +51 -3
- package/dist/safe-match.js +28 -0
- package/dist/schema.js +46 -2
- package/dist/signals.js +292 -0
- package/dist/validate.js +6 -0
- package/package.json +1 -1
- package/skills/gloss/SKILL.md +31 -1
|
@@ -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; }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ATTENTION, IMPACT } from './schema.js';
|
|
2
|
+
// Rank of a group's impact; a group with none is below every level.
|
|
3
|
+
export const impactRank = (i) => (i ? IMPACT.indexOf(i) : -1);
|
|
4
|
+
export const maxImpact = (a, b) => (impactRank(b) > impactRank(a) ? b : a);
|
|
5
|
+
export const attentionRank = (a) => (a ? ATTENTION.indexOf(a) : -1);
|
|
6
|
+
export const maxAttention = (a, b) => (attentionRank(b) > attentionRank(a) ? b : a);
|
|
7
|
+
// One step up, capped at must-read.
|
|
8
|
+
export const raise = (a) => ATTENTION[Math.min(ATTENTION.length - 1, ATTENTION.indexOf(a) + 1)];
|
|
9
|
+
// The attention a given impact calls for, before any raising.
|
|
10
|
+
const ATTENTION_FOR_IMPACT = {
|
|
11
|
+
local: 'skim', module: 'look', shared: 'careful', contract: 'careful', sensitive: 'must-read',
|
|
12
|
+
};
|
|
13
|
+
export const attentionForImpact = (i) => ATTENTION_FOR_IMPACT[i];
|
|
14
|
+
// What the reviewer should do when glosses are combined (a squash, a rebase):
|
|
15
|
+
// the highest level of any of them. Focus groups of all of them, renamed to
|
|
16
|
+
// their new ids (`rename` returns undefined for a group that is gone), most
|
|
17
|
+
// impactful first, without repeats, at most three. Reasons are unioned.
|
|
18
|
+
export function combineAttention(parts, rename, groups) {
|
|
19
|
+
if (!parts.length)
|
|
20
|
+
return undefined;
|
|
21
|
+
const level = parts.map((p) => p.level).reduce((a, b) => (ATTENTION.indexOf(b) > ATTENTION.indexOf(a) ? b : a));
|
|
22
|
+
const impactOf = new Map(groups.map((g) => [g.id, g.impact]));
|
|
23
|
+
const focus = [...new Set(parts.flatMap((p, i) => (p.focus ?? []).flatMap((id) => rename(i, id) ?? [])))]
|
|
24
|
+
.sort((a, b) => impactRank(impactOf.get(b)) - impactRank(impactOf.get(a)))
|
|
25
|
+
.slice(0, 3);
|
|
26
|
+
const reasons = [...new Set(parts.flatMap((p) => p.reasons ?? []))];
|
|
27
|
+
const overrides = [...new Set(parts.flatMap((p) => (p.override_reason && p.level === level ? [p.override_reason] : [])))];
|
|
28
|
+
return {
|
|
29
|
+
level,
|
|
30
|
+
...(focus.length ? { focus } : {}),
|
|
31
|
+
...(reasons.length ? { reasons } : {}),
|
|
32
|
+
...(overrides.length ? { override_reason: overrides.join('; ') } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
package/dist/carry.js
CHANGED
|
@@ -52,5 +52,8 @@ export function carryGloss(note, current, previous = []) {
|
|
|
52
52
|
const byId = new Map(current.map((h) => [h.id, h]));
|
|
53
53
|
const claims = groups.flatMap((g) => [...selections(g.hunks, (id) => { const h = byId.get(id); return h && changedCount(h); })]
|
|
54
54
|
.flatMap(([id, lines]) => { const h = byId.get(id); return h ? [{ hunk: h, lines }] : []; }));
|
|
55
|
-
|
|
55
|
+
// Focus groups that were left behind can't be pointed at.
|
|
56
|
+
const focus = a.attention?.focus?.filter((id) => keptIds.has(id));
|
|
57
|
+
const attention = a.attention && { ...a.attention, focus: focus?.length ? focus : undefined };
|
|
58
|
+
return { annotation: { ...a, groups, ...(attention ? { attention } : {}) }, kept: groups.length, total: a.groups.length, dropped, gaps: uncovered(current, claims), partialMatch };
|
|
56
59
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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';
|
|
@@ -9,11 +10,12 @@ import * as review from './commands/review.js';
|
|
|
9
10
|
import * as reviews from './commands/reviews.js';
|
|
10
11
|
import * as schema from './commands/schema.js';
|
|
11
12
|
import * as show from './commands/show.js';
|
|
13
|
+
import * as signals from './commands/signals.js';
|
|
12
14
|
import * as sync from './commands/sync.js';
|
|
13
15
|
import * as versions from './commands/versions.js';
|
|
14
16
|
import * as why from './commands/why.js';
|
|
15
17
|
const COMMANDS = {
|
|
16
|
-
init, hunks, add, adopt, carry, show, html, why, review, reviews, sync, versions, schema,
|
|
18
|
+
init, hunks, signals, add, adopt, carry, show, html, ary, why, review, reviews, sync, versions, schema,
|
|
17
19
|
};
|
|
18
20
|
function help() {
|
|
19
21
|
return 'usage: git gloss <command> [options]\n\n' +
|
package/dist/commands/add.js
CHANGED
|
@@ -5,7 +5,9 @@ import { commitDiff, isWipSnapshot, NOTES_REF, readNote, resolveCommit, WIP_NOTE
|
|
|
5
5
|
import { location, parseDiff } from '../diff.js';
|
|
6
6
|
import { Annotation, noteFormat } from '../schema.js';
|
|
7
7
|
import { validate } from '../validate.js';
|
|
8
|
-
|
|
8
|
+
import { loadConfig } from '../config.js';
|
|
9
|
+
import { analyzeGroups, groupInputsFor, ratingProblems } from '../floor.js';
|
|
10
|
+
export const usage = 'add [<commit>] [-f <file>] [--force] [--partial] [--allow-underrate] [--dry-run] attach an annotation (JSON from file or stdin)';
|
|
9
11
|
export function run(argv) {
|
|
10
12
|
const { values, positionals } = parseArgs({
|
|
11
13
|
args: argv,
|
|
@@ -14,6 +16,7 @@ export function run(argv) {
|
|
|
14
16
|
file: { type: 'string', short: 'f' },
|
|
15
17
|
force: { type: 'boolean' },
|
|
16
18
|
partial: { type: 'boolean' },
|
|
19
|
+
'allow-underrate': { type: 'boolean' },
|
|
17
20
|
'dry-run': { type: 'boolean' },
|
|
18
21
|
},
|
|
19
22
|
});
|
|
@@ -41,10 +44,23 @@ export function run(argv) {
|
|
|
41
44
|
if (uncovered.length && !values.partial) {
|
|
42
45
|
errors.push(`hunks not assigned to any group: ${uncovered.join(', ')} (every hunk needs a group, or pass --partial)`);
|
|
43
46
|
}
|
|
47
|
+
// Computed signals: group impact below its floor is a warning; attention
|
|
48
|
+
// below the floor is an error unless explained (override_reason) or allowed.
|
|
49
|
+
// A malformed config stops here with its own message.
|
|
50
|
+
const warnings = [];
|
|
51
|
+
if (!errors.length) {
|
|
52
|
+
const analysis = analyzeGroups(hunks, groupInputsFor(parsed.data, hunks), loadConfig());
|
|
53
|
+
const problems = ratingProblems(parsed.data, analysis);
|
|
54
|
+
warnings.push(...problems.warnings);
|
|
55
|
+
if (problems.underrated)
|
|
56
|
+
(values['allow-underrate'] ? warnings : errors).push(problems.underrated);
|
|
57
|
+
}
|
|
44
58
|
if (errors.length) {
|
|
45
59
|
console.error('error: annotation is inconsistent with the commit:\n' + errors.map((e) => ` ✖ ${e}`).join('\n'));
|
|
46
60
|
return 1;
|
|
47
61
|
}
|
|
62
|
+
for (const w of warnings)
|
|
63
|
+
console.error(`warning: ${w}`);
|
|
48
64
|
// Not an error: long intents still work, but they make the summary rows tall.
|
|
49
65
|
for (const g of parsed.data.groups.filter((g) => g.intent.length > 100)) {
|
|
50
66
|
console.error(`note: group "${g.id}" has a ${g.intent.length}-character intent; keep it under ~80 and move detail to "notes"`);
|
|
@@ -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/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
|
@@ -6,6 +6,10 @@ import { location, parseDiff } from '../diff.js';
|
|
|
6
6
|
import { noteFormat } from '../schema.js';
|
|
7
7
|
import { colorDiff, hunkLabel, page, style } from '../render.js';
|
|
8
8
|
import { bodyLines, changedCount } from '../selection.js';
|
|
9
|
+
import { loadConfig } from '../config.js';
|
|
10
|
+
import { withSignals } from '../floor.js';
|
|
11
|
+
import { analyze } from '../signals.js';
|
|
12
|
+
import { summaryLines } from './signals.js';
|
|
9
13
|
export const usage = 'hunks [<commit> | wip] [--stat] [--json] list a commit\'s hunks (or snapshot uncommitted work with wip), numbering changed lines';
|
|
10
14
|
export function run(argv) {
|
|
11
15
|
const { values, positionals } = parseArgs({
|
|
@@ -59,6 +63,9 @@ export function run(argv) {
|
|
|
59
63
|
return colorDiff(h.text, ' ');
|
|
60
64
|
return [colorDiff(header, ' '), ...body.map((l) => style('dim', (l.idx === null ? '' : String(l.idx)).padStart(5)) + ' ' + colorDiff(l.raw))].join('\n');
|
|
61
65
|
};
|
|
62
|
-
|
|
66
|
+
// What the diff itself says about attention, for the author to read before glossing.
|
|
67
|
+
const summary = withSignals(() => summaryLines(analyze(hunks, loadConfig())));
|
|
68
|
+
const signals = summary ? ['', ...summary] : [];
|
|
69
|
+
page([hunks.map((h) => hunkLabel(h) + style('dim', ` (${changedCount(h)} changed)`) + (values.stat ? '' : '\n' + numbered(h) + '\n')).join('\n'), ...signals].join('\n'));
|
|
63
70
|
return 0;
|
|
64
71
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -122,7 +122,12 @@ export function run(argv = []) {
|
|
|
122
122
|
// The hooks run `git gloss`; a git without it on PATH (a GUI client, a
|
|
123
123
|
// different shell) would print an error after every commit and push.
|
|
124
124
|
if (spawnSync('sh', ['-c', 'command -v git-gloss'], { stdio: 'ignore' }).status !== 0) {
|
|
125
|
-
|
|
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").');
|
|
126
131
|
}
|
|
127
132
|
installHook('pre-push', HOOK, '`git push` now syncs glosses with the remote it pushes to', [
|
|
128
133
|
'git remote | grep -qxF -- "$1" && { GIT_TERMINAL_PROMPT=0 git gloss sync "$1" --pushing >&2 ||',
|
package/dist/commands/show.js
CHANGED
|
@@ -2,10 +2,12 @@ import { parseArgs } from 'node:util';
|
|
|
2
2
|
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
|
-
import {
|
|
5
|
+
import { impactRank } from '../attention.js';
|
|
6
|
+
import { showsImpact, tryAnalyze } from '../floor.js';
|
|
7
|
+
import { IMPACT, NECESSITY, isImpact, isNecessity } from '../schema.js';
|
|
6
8
|
import { current, reviewStatus, statusPhrase, verdict } from '../reviews.js';
|
|
7
|
-
import { colorDiff, field, groupHeader, hunkLabel, necessityWord, noteFooter, page, style, termWidth, wrap, } from '../render.js';
|
|
8
|
-
export const usage = 'show [<commit> | <range>] [-g <group>]... [--full] [--diff] [--only <necessity,...>] summarise a gloss, or each commit in a range';
|
|
9
|
+
import { attentionBlock, colorDiff, derivedImpactTag, field, groupHeader, hunkLabel, IMPACT_W, impactTag, necessityWord, noteFooter, page, style, termWidth, wrap, } from '../render.js';
|
|
10
|
+
export const usage = 'show [<commit> | <range>] [-g <group>]... [--full] [--diff] [--only <necessity|impact,...> (a group must match each kind named)] [--sort impact] summarise a gloss, or each commit in a range';
|
|
9
11
|
// Counts over the lines each part selects, not whole hunks, so groups that
|
|
10
12
|
// split a hunk don't both count all of it.
|
|
11
13
|
function stats(parts) {
|
|
@@ -26,12 +28,18 @@ export function run(argv) {
|
|
|
26
28
|
full: { type: 'boolean' },
|
|
27
29
|
diff: { type: 'boolean' },
|
|
28
30
|
only: { type: 'string' },
|
|
31
|
+
sort: { type: 'string' },
|
|
29
32
|
},
|
|
30
33
|
});
|
|
31
|
-
const
|
|
32
|
-
const unknown =
|
|
34
|
+
const names = values.only?.split(',').map((s) => s.trim());
|
|
35
|
+
const unknown = names?.filter((n) => !isNecessity(n) && !isImpact(n));
|
|
33
36
|
if (unknown?.length) {
|
|
34
|
-
console.error(`error: unknown necessity ${unknown.join(', ')}; expected ${NECESSITY.join(', ')}`);
|
|
37
|
+
console.error(`error: unknown necessity or impact ${unknown.join(', ')}; expected ${NECESSITY.join(', ')} or ${IMPACT.join(', ')}`);
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
const only = names && { names, necessities: names.filter(isNecessity), impacts: names.filter(isImpact) };
|
|
41
|
+
if (values.sort !== undefined && values.sort !== 'impact') {
|
|
42
|
+
console.error(`error: unknown sort ${values.sort}; expected impact`);
|
|
35
43
|
return 2;
|
|
36
44
|
}
|
|
37
45
|
const { shas, merges, range } = resolveCommits(positionals[0] ?? 'HEAD');
|
|
@@ -63,7 +71,7 @@ export function run(argv) {
|
|
|
63
71
|
}
|
|
64
72
|
const out = renderCommit(sha, gloss, values, only, true);
|
|
65
73
|
if (!out) {
|
|
66
|
-
console.error(`no groups match ${[values.group && `-g ${values.group.join(',')}`, only && `--only ${only.join(',')}`].filter(Boolean).join(' and ')}`);
|
|
74
|
+
console.error(`no groups match ${[values.group && `-g ${values.group.join(',')}`, only && `--only ${only.names.join(',')}`].filter(Boolean).join(' and ')}`);
|
|
67
75
|
return 1;
|
|
68
76
|
}
|
|
69
77
|
page(out.join('\n'));
|
|
@@ -99,13 +107,15 @@ export function run(argv) {
|
|
|
99
107
|
}
|
|
100
108
|
// An --only filter can leave a commit with nothing to show; say so and move on.
|
|
101
109
|
out.push(...(renderCommit(sha, gloss, values, only, false) ??
|
|
102
|
-
[`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`, style('dim', `no ${only?.join('/')} groups`)]));
|
|
110
|
+
[`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`, style('dim', `no ${only?.names.join('/')} groups`)]));
|
|
103
111
|
});
|
|
104
112
|
if (glossed)
|
|
105
113
|
out.push('', style('dim', 'One commit in detail: git gloss show <commit> --full (or -g <group> for its code)'));
|
|
106
114
|
page(out.join('\n'));
|
|
107
115
|
return 0;
|
|
108
116
|
}
|
|
117
|
+
const matchesOnly = (g, only) => !only ||
|
|
118
|
+
((!only.necessities.length || only.necessities.includes(g.necessity)) && (!only.impacts.length || (!!g.impact && only.impacts.includes(g.impact))));
|
|
109
119
|
// One line on whether the commit as it is now was reviewed, per stage.
|
|
110
120
|
function reviewsLine(sha) {
|
|
111
121
|
const statuses = reviewStatus(sha);
|
|
@@ -126,15 +136,25 @@ function renderCommit(sha, gloss, values, only, hints) {
|
|
|
126
136
|
out.push(...field('summary:', a.summary, 0));
|
|
127
137
|
for (const w of gloss.warnings)
|
|
128
138
|
out.push(...field('⚠', w, 0).map((l) => style('red', l)));
|
|
129
|
-
|
|
139
|
+
const an = tryAnalyze(gloss);
|
|
140
|
+
out.push(...attentionBlock(a.attention, an ? { floor: an.floor, derived: an.derived } : undefined), reviewsLine(sha));
|
|
130
141
|
const groups = gloss.groups
|
|
131
|
-
.filter((g) =>
|
|
142
|
+
.filter((g) => matchesOnly(g.group, only))
|
|
132
143
|
.filter((g) => !values.group || values.group.includes(g.group.id));
|
|
133
144
|
if (!groups.length)
|
|
134
145
|
return null;
|
|
146
|
+
// --sort impact: the most consequential first, then by necessity as usual.
|
|
147
|
+
const byNecessity = (x, y) => NECESSITY.indexOf(x.group.necessity) - NECESSITY.indexOf(y.group.necessity);
|
|
148
|
+
const byImpact = (x, y) => impactRank(y.group.impact) - impactRank(x.group.impact) || byNecessity(x, y);
|
|
149
|
+
const detail = values.group || values.full || values.diff;
|
|
150
|
+
// The summary lists required first; detail views keep the gloss's order unless asked.
|
|
151
|
+
if (values.sort === 'impact')
|
|
152
|
+
groups.sort(byImpact);
|
|
153
|
+
else if (!detail)
|
|
154
|
+
groups.sort(byNecessity);
|
|
135
155
|
// -g: chosen groups with their code. --full: every group's details, plus
|
|
136
156
|
// alternatives and risks. --diff: --full plus code.
|
|
137
|
-
if (
|
|
157
|
+
if (detail) {
|
|
138
158
|
const withCode = values.diff || !!values.group;
|
|
139
159
|
for (const g of groups) {
|
|
140
160
|
out.push('', ...groupHeader(g.group));
|
|
@@ -163,13 +183,12 @@ function renderCommit(sha, gloss, values, only, hints) {
|
|
|
163
183
|
return out;
|
|
164
184
|
}
|
|
165
185
|
// Summary: one line per group.
|
|
166
|
-
const sorted = [...groups].sort((x, y) => NECESSITY.indexOf(x.group.necessity) - NECESSITY.indexOf(y.group.necessity));
|
|
167
186
|
const tally = NECESSITY.map((n) => [n, gloss.groups.filter((g) => g.group.necessity === n).length])
|
|
168
187
|
.filter(([, c]) => c > 0)
|
|
169
188
|
.map(([n, c]) => `${c} ${necessityWord(n)}`);
|
|
170
|
-
const showing = groups.length < gloss.groups.length ? style('dim', ` (showing ${groups.length}: --only ${only?.join(',')})`) : '';
|
|
189
|
+
const showing = groups.length < gloss.groups.length ? style('dim', ` (showing ${groups.length}: --only ${only?.names.join(',')})`) : '';
|
|
171
190
|
out.push('', `${style('bold', `${gloss.groups.length} groups:`)} ${tally.join(style('dim', ' · '))}${showing}`, '');
|
|
172
|
-
const rows =
|
|
191
|
+
const rows = groups.map((g) => {
|
|
173
192
|
const s = stats(currentParts(g));
|
|
174
193
|
const stale = g.hunks.filter((h) => h.status !== 'ok').length;
|
|
175
194
|
return {
|
|
@@ -182,14 +201,21 @@ function renderCommit(sha, gloss, values, only, hints) {
|
|
|
182
201
|
const statW = Math.max(...rows.map((r) => r.stat.length), un.count ? 8 : 0);
|
|
183
202
|
const addW = Math.max(...rows.map((r) => r.added.length));
|
|
184
203
|
const remW = Math.max(...rows.map((r) => r.removed.length));
|
|
185
|
-
|
|
204
|
+
// An impact column only when some group declares one or the diff puts one
|
|
205
|
+
// above the baseline (module), so plain glosses look as before. Undeclared
|
|
206
|
+
// ones show the group's floor in parentheses.
|
|
207
|
+
const floorOf = (g) => an?.groups.get(g.group.id)?.floor;
|
|
208
|
+
const showImpact = showsImpact(gloss.groups.map((g) => g.group), an);
|
|
209
|
+
const impactW = showImpact ? IMPACT_W + 2 : 0;
|
|
210
|
+
const anyDerived = showImpact && gloss.groups.some((g) => !g.group.impact && floorOf(g));
|
|
211
|
+
const intentCol = 2 + 11 + 2 + impactW + idW + 2;
|
|
186
212
|
const statsW = 2 + statW + 1 + addW + 1 + remW;
|
|
187
213
|
const intentW = Math.max(20, termWidth() - intentCol - statsW);
|
|
188
214
|
for (const r of rows) {
|
|
189
215
|
// Intents wrap within their column rather than being cut off; the stats
|
|
190
216
|
// stay on the first line so the numbers still line up.
|
|
191
217
|
const [first, ...rest] = wrap(r.g.group.intent, intentW);
|
|
192
|
-
out.push(` ${necessityWord(r.g.group.necessity, 11)} ${style('bold', r.g.group.id.padEnd(idW))} ` +
|
|
218
|
+
out.push(` ${necessityWord(r.g.group.necessity, 11)} ${impactW ? (r.g.group.impact ? impactTag(r.g.group.impact, IMPACT_W) : floorOf(r.g) ? derivedImpactTag(floorOf(r.g), IMPACT_W) : ' '.repeat(IMPACT_W)) + ' ' : ''}${style('bold', r.g.group.id.padEnd(idW))} ` +
|
|
193
219
|
`${first.padEnd(intentW)} ` +
|
|
194
220
|
style('dim', r.stat.padStart(statW)) + ' ' + style('green', r.added.padStart(addW)) + ' ' + style('red', r.removed.padStart(remW)), ...rest.map((l) => ' '.repeat(intentCol) + l));
|
|
195
221
|
if (r.stale) {
|
|
@@ -200,6 +226,8 @@ function renderCommit(sha, gloss, values, only, hints) {
|
|
|
200
226
|
const where = gloss.unannotated.map((u) => refLabel(u.hunk.id, u.lines)).join(', ');
|
|
201
227
|
out.push(` ${style(['bold', 'red'], 'unannotated'.padEnd(11))} ${style('red', `+${un.added} -${un.removed} not in any group: ${where}`)}`);
|
|
202
228
|
}
|
|
229
|
+
if (anyDerived)
|
|
230
|
+
out.push('', style('dim', '(impact in parentheses is derived from the diff, not declared)'));
|
|
203
231
|
// Rejected alternatives and risks: short, and often what a reader most
|
|
204
232
|
// lacks, so they're in the summary rather than only behind --full.
|
|
205
233
|
out.push(...noteFooter(gloss.note));
|