@ok-ggwp/git-gloss 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -2
- package/dist/ary/browse.js +4 -61
- package/dist/ary/picker.js +163 -0
- package/dist/ary/server.js +87 -37
- package/dist/commands/ary.js +3 -3
- package/dist/commands/review.js +5 -4
- package/dist/commands/reviews.js +58 -5
- package/dist/commands/show.js +14 -6
- package/dist/commands/signals.js +9 -8
- package/dist/commands/sync.js +14 -2
- package/dist/commands/why.js +3 -2
- package/dist/config.js +61 -5
- package/dist/floor.js +2 -2
- package/dist/git.js +18 -2
- package/dist/html/client.js +23 -7
- package/dist/html/data.js +1 -1
- package/dist/html/page.js +1 -1
- package/dist/html/style.css +27 -1
- package/dist/loader.js +6 -2
- package/dist/plain.js +9 -0
- package/dist/render.js +6 -4
- package/dist/reviews.js +98 -4
- package/dist/safe-match.js +5 -3
- package/dist/schema.js +10 -3
- package/dist/selection.js +21 -0
- package/dist/signals.js +13 -3
- package/dist/validate.js +4 -1
- package/package.json +1 -1
- package/skills/gloss/SKILL.md +15 -1
- package/dist/ary/client.js +0 -401
- package/dist/ary/index.html +0 -13
- package/dist/ary/style.css +0 -115
package/dist/commands/show.js
CHANGED
|
@@ -5,7 +5,8 @@ import { loadGloss } from '../loader.js';
|
|
|
5
5
|
import { impactRank } from '../attention.js';
|
|
6
6
|
import { showsImpact, tryAnalyze } from '../floor.js';
|
|
7
7
|
import { IMPACT, NECESSITY, isImpact, isNecessity } from '../schema.js';
|
|
8
|
-
import { current, reviewStatus, statusPhrase, verdict } from '../reviews.js';
|
|
8
|
+
import { current, decisionSummary, readDecisions, readReviews, reviewStatus, statusPhrase, verdict } from '../reviews.js';
|
|
9
|
+
import { plain } from '../plain.js';
|
|
9
10
|
import { attentionBlock, colorDiff, derivedImpactTag, field, groupHeader, hunkLabel, IMPACT_W, impactTag, necessityWord, noteFooter, page, style, termWidth, wrap, } from '../render.js';
|
|
10
11
|
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';
|
|
11
12
|
// Counts over the lines each part selects, not whole hunks, so groups that
|
|
@@ -102,12 +103,12 @@ export function run(argv) {
|
|
|
102
103
|
out.push('', style('dim', '─'.repeat(Math.min(termWidth(), 60))));
|
|
103
104
|
const gloss = glosses[i];
|
|
104
105
|
if (!gloss) {
|
|
105
|
-
out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', commitSubject(sha))}`, style('dim', 'no gloss for this commit'));
|
|
106
|
+
out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(commitSubject(sha)))}`, style('dim', 'no gloss for this commit'));
|
|
106
107
|
return;
|
|
107
108
|
}
|
|
108
109
|
// An --only filter can leave a commit with nothing to show; say so and move on.
|
|
109
110
|
out.push(...(renderCommit(sha, gloss, values, only, false) ??
|
|
110
|
-
[`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`, style('dim', `no ${only?.names.join('/')} groups`)]));
|
|
111
|
+
[`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(gloss.subject))}`, style('dim', `no ${only?.names.join('/')} groups`)]));
|
|
111
112
|
});
|
|
112
113
|
if (glossed)
|
|
113
114
|
out.push('', style('dim', 'One commit in detail: git gloss show <commit> --full (or -g <group> for its code)'));
|
|
@@ -122,7 +123,14 @@ function reviewsLine(sha) {
|
|
|
122
123
|
if (!statuses.length)
|
|
123
124
|
return style('dim', 'reviews: none recorded');
|
|
124
125
|
const color = (s) => (!current(s) || verdict(s) === 'issues' ? 'yellow' : verdict(s) === 'clean' ? 'green' : 'dim');
|
|
125
|
-
|
|
126
|
+
// What was decided about the reviews that found something (or didn't say);
|
|
127
|
+
// a commit with only clean reviews has nothing to decide.
|
|
128
|
+
const open = readReviews(sha).filter((r) => r.verdict !== 'clean');
|
|
129
|
+
const decisions = readDecisions(sha).filter((d) => open.some((r) => r.id === d.review));
|
|
130
|
+
const decided = open.length
|
|
131
|
+
? ` ${style('dim', '·')} decisions: ${decisionSummary(decisions, open.every((r) => decisions.some((d) => d.review === r.id && d.complete)))}`
|
|
132
|
+
: '';
|
|
133
|
+
return `reviews: ${statuses.map((s) => style(color(s), statusPhrase(s))).join(style('dim', ' · '))}${decided}` +
|
|
126
134
|
style('dim', ` (findings: git gloss reviews ${sha.slice(0, 7)})`);
|
|
127
135
|
}
|
|
128
136
|
// One commit's view, or null if no group matches -g/--only. `hints` adds the
|
|
@@ -130,14 +138,14 @@ function reviewsLine(sha) {
|
|
|
130
138
|
function renderCommit(sha, gloss, values, only, hints) {
|
|
131
139
|
const a = gloss.note.annotation;
|
|
132
140
|
const short = sha.slice(0, 7);
|
|
133
|
-
const out = [`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`];
|
|
141
|
+
const out = [`${style('yellow', sha.slice(0, 10))} ${style('bold', plain(gloss.subject))}`];
|
|
134
142
|
out.push(...field('task:', a.task, 0));
|
|
135
143
|
if (a.summary)
|
|
136
144
|
out.push(...field('summary:', a.summary, 0));
|
|
137
145
|
for (const w of gloss.warnings)
|
|
138
146
|
out.push(...field('⚠', w, 0).map((l) => style('red', l)));
|
|
139
147
|
const an = tryAnalyze(gloss);
|
|
140
|
-
out.push(...attentionBlock(a.attention, an ? { floor: an.floor, derived: an.derived } : undefined), reviewsLine(sha));
|
|
148
|
+
out.push(...attentionBlock(a.attention, an ? { floor: an.floor, derived: an.derived, timedOut: an.timedOut } : undefined), reviewsLine(sha));
|
|
141
149
|
const groups = gloss.groups
|
|
142
150
|
.filter((g) => matchesOnly(g.group, only))
|
|
143
151
|
.filter((g) => !values.group || values.group.includes(g.group.id));
|
package/dist/commands/signals.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { plain } from '../plain.js';
|
|
1
2
|
import { parseArgs } from 'node:util';
|
|
2
3
|
import { loadConfig } from '../config.js';
|
|
3
4
|
import { commitDiff, diffBetween, git, GitError, resolveCommit, workingDiff } from '../git.js';
|
|
@@ -17,11 +18,11 @@ export function summaryLines(sig, max = 6) {
|
|
|
17
18
|
const files = sig.files.filter((f) => impactRank(f.floor) >= impactRank('shared'))
|
|
18
19
|
.sort((a, b) => impactRank(b.floor) - impactRank(a.floor) || a.file.localeCompare(b.file));
|
|
19
20
|
for (const f of files.slice(0, max))
|
|
20
|
-
out.push(` ${(f.floor + ':').padEnd(10)} ${f.file}${f.why.length ? ` (${f.why.join('; ')})` : ''}`);
|
|
21
|
+
out.push(` ${(f.floor + ':').padEnd(10)} ${plain(f.file)}${f.why.length ? ` (${plain(f.why.join('; '))})` : ''}`);
|
|
21
22
|
if (files.length > max)
|
|
22
23
|
out.push(` ... and ${files.length - max} more at shared or above (git gloss signals)`);
|
|
23
24
|
for (const r of sig.floor.reasons.filter((r) => / files changed /.test(r)))
|
|
24
|
-
out.push(` large: ${r}`);
|
|
25
|
+
out.push(` large: ${plain(r)}`);
|
|
25
26
|
if (sig.floor.reasons.includes(CONFIG_EDIT_NOTE))
|
|
26
27
|
out.push(` note: ${CONFIG_EDIT_NOTE}`);
|
|
27
28
|
out.push(` stats: ${statsLine(sig.stats)}`);
|
|
@@ -31,16 +32,16 @@ function detailLines(sig, label) {
|
|
|
31
32
|
const out = [style('bold', `signals for ${label}:`) + ` floor attention = ${style(['bold', ATTENTION_STYLE[sig.floor.attention]], sig.floor.attention)}` +
|
|
32
33
|
style('dim', ` (floor impact: ${sig.floor.impact})`)];
|
|
33
34
|
for (const r of sig.floor.reasons)
|
|
34
|
-
out.push(` why: ${r}`);
|
|
35
|
+
out.push(` why: ${plain(r)}`);
|
|
35
36
|
out.push(` stats: ${statsLine(sig.stats)}`);
|
|
36
37
|
const notable = sig.hunks.filter((h) => impactRank(h.floor) > impactRank('module') || h.hits.length);
|
|
37
38
|
if (notable.length)
|
|
38
39
|
out.push('', style('bold', 'hunks with signals'));
|
|
39
40
|
for (const h of notable) {
|
|
40
|
-
out.push(` ${style('bold', h.id.padEnd(5))} ${impactTag(h.floor, 11)} ${h.file}` +
|
|
41
|
-
style('dim', ` [${h.classes.join(', ') || 'no class'}]${h.why.length ? ' ' + h.why.join('; ') : ''}`));
|
|
41
|
+
out.push(` ${style('bold', h.id.padEnd(5))} ${impactTag(h.floor, 11)} ${plain(h.file)}` +
|
|
42
|
+
style('dim', ` [${h.classes.join(', ') || 'no class'}]${h.why.length ? ' ' + plain(h.why.join('; ')) : ''}`));
|
|
42
43
|
for (const x of h.hits.slice(0, 8)) {
|
|
43
|
-
out.push(` ${h.id}:${x.line} ${x.sign} ${x.class} pattern "${x.name}"${x.damped ? style('dim', ' (damped: test, docs or lockfile path)') : ''} ${style('dim', x.text)}`);
|
|
44
|
+
out.push(` ${h.id}:${x.line} ${x.sign} ${x.class} pattern "${x.name}"${x.timedOut ? ' (pattern timed out: counted as matching)' : ''}${x.damped ? style('dim', ' (damped: test, docs or lockfile path)') : ''} ${style('dim', plain(x.text))}`);
|
|
44
45
|
}
|
|
45
46
|
if (h.hits.length > 8)
|
|
46
47
|
out.push(style('dim', ` ... ${h.hits.length - 8} more hits (--json)`));
|
|
@@ -51,9 +52,9 @@ function detailLines(sig, label) {
|
|
|
51
52
|
if (sig.dependencies.length)
|
|
52
53
|
out.push('', style('bold', 'dependency changes'));
|
|
53
54
|
for (const d of sig.dependencies) {
|
|
54
|
-
out.push(` ${d.file} (${d.kind}${d.format ? `, parsed as ${d.format}` : d.kind === 'lockfile' ? ', no version detail for this format' : ''})`);
|
|
55
|
+
out.push(` ${plain(d.file)} (${d.kind}${d.format ? `, parsed as ${d.format}` : d.kind === 'lockfile' ? ', no version detail for this format' : ''})`);
|
|
55
56
|
for (const b of d.bumps.slice(0, 20)) {
|
|
56
|
-
const what = b.kind === 'bump' ? `${b.name} ${b.from} -> ${b.to}` : b.kind === 'added' ? `+ ${b.name} ${b.to}` : `- ${b.name} ${b.from}
|
|
57
|
+
const what = plain(b.kind === 'bump' ? `${b.name} ${b.from} -> ${b.to}` : b.kind === 'added' ? `+ ${b.name} ${b.to}` : `- ${b.name} ${b.from}`);
|
|
57
58
|
out.push(` ${what}${b.unrelated ? style('yellow', ' (transitive: no manifest change names it)') : ''}`);
|
|
58
59
|
}
|
|
59
60
|
if (d.bumps.length > 20)
|
package/dist/commands/sync.js
CHANGED
|
@@ -46,6 +46,18 @@ function shareable(remote, pushing) {
|
|
|
46
46
|
return new Set();
|
|
47
47
|
return new Set(git(['rev-list', ...starts, '--']).split('\n').filter(Boolean));
|
|
48
48
|
}
|
|
49
|
+
// The fetched ref is the remote's to set: it must be a commit before notes
|
|
50
|
+
// code (or an update-ref onto our own notes ref) touches it.
|
|
51
|
+
function checkTracking(remote, ref, tracking) {
|
|
52
|
+
if (gitTry(['rev-parse', '-q', '--verify', `${tracking}^{commit}`]) === null) {
|
|
53
|
+
gitTry(['update-ref', '-d', tracking]);
|
|
54
|
+
throw new GitError(`${remote}'s ${ref} is not a notes commit; ignoring it (nothing was changed here)`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function fetchTracking(remote, ref, tracking) {
|
|
58
|
+
git(['fetch', '-q', remote, `+${ref}:${tracking}`]);
|
|
59
|
+
checkTracking(remote, ref, tracking);
|
|
60
|
+
}
|
|
49
61
|
const PUSH_REF = 'refs/notes/gloss-sync-tmp';
|
|
50
62
|
// One fetch → merge → push round. Returns null if the push lost a race with
|
|
51
63
|
// someone else's push and the round should be retried.
|
|
@@ -53,7 +65,7 @@ function round(remote, pushing, carried) {
|
|
|
53
65
|
const tracking = `refs/notes/remotes/${remote}/gloss`;
|
|
54
66
|
const remoteHas = gitTry(['ls-remote', '--exit-code', remote, NOTES_REF]) !== null;
|
|
55
67
|
if (remoteHas)
|
|
56
|
-
|
|
68
|
+
fetchTracking(remote, NOTES_REF, tracking);
|
|
57
69
|
const local = noteList(NOTES_REF);
|
|
58
70
|
const theirs = remoteHas ? noteList(tracking) : new Map();
|
|
59
71
|
// The last state both sides shared. A gloss changed on one side only is
|
|
@@ -159,7 +171,7 @@ function syncReviews(remote, pushing) {
|
|
|
159
171
|
const tracking = `refs/notes/remotes/${remote}/gloss-reviews`;
|
|
160
172
|
const remoteHas = gitTry(['ls-remote', '--exit-code', remote, REVIEWS_REF]) !== null;
|
|
161
173
|
if (remoteHas)
|
|
162
|
-
|
|
174
|
+
fetchTracking(remote, REVIEWS_REF, tracking);
|
|
163
175
|
const before = noteList(REVIEWS_REF);
|
|
164
176
|
const theirs = remoteHas ? noteList(tracking) : new Map();
|
|
165
177
|
if (remoteHas) {
|
package/dist/commands/why.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { plain } from '../plain.js';
|
|
1
2
|
import { parseArgs } from 'node:util';
|
|
2
3
|
import { blameRange, commitSubject, lineHistory } from '../git.js';
|
|
3
4
|
import { loadGloss } from '../loader.js';
|
|
@@ -158,8 +159,8 @@ export function run(argv) {
|
|
|
158
159
|
const renderStep = (s, label, older) => {
|
|
159
160
|
// Where the lines were in an older commit: after edits or a file rename
|
|
160
161
|
// that differs from the query.
|
|
161
|
-
const then = older || s.file !== file ? style('dim', ` (${s.file}:${formatRanges(s.lines)} then)`) : '';
|
|
162
|
-
out.push(`${label}${style('yellow', s.commit.slice(0, 10))} ${s.subject}${then}`);
|
|
162
|
+
const then = older || s.file !== file ? style('dim', ` (${plain(s.file)}:${formatRanges(s.lines)} then)`) : '';
|
|
163
|
+
out.push(`${label}${style('yellow', s.commit.slice(0, 10))} ${plain(s.subject)}${then}`);
|
|
163
164
|
if (!s.gloss) {
|
|
164
165
|
out.push(style('dim', ' no gloss for this commit'));
|
|
165
166
|
return;
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { GlossError, gitTry } from './git.js';
|
|
2
|
+
import { git, GitError, GlossError, gitTry } from './git.js';
|
|
3
3
|
import { globToRegExp } from './glob.js';
|
|
4
4
|
// Settings for the computed signals, in three layers (later wins for scalars,
|
|
5
5
|
// repo config extends the lists of the one before):
|
|
@@ -135,6 +135,31 @@ function gitInt(key) {
|
|
|
135
135
|
throw new ConfigError(`git config gloss.attention.${key} must be a positive integer, got "${v}"`);
|
|
136
136
|
return Number(v);
|
|
137
137
|
}
|
|
138
|
+
// Git failing (a broken repo, an unreadable object, no permission) is not the
|
|
139
|
+
// same as "no base" or "no file": the repo's own, perhaps stricter, config
|
|
140
|
+
// would silently not apply. It is not fatal either (the built-in defaults
|
|
141
|
+
// still give a floor, and refusing to rate anything would make every command
|
|
142
|
+
// fail in a damaged repo), so it is warned about on stderr, once per message,
|
|
143
|
+
// and the defaults are used.
|
|
144
|
+
const warned = new Set();
|
|
145
|
+
function warnOnce(msg) {
|
|
146
|
+
if (warned.has(msg))
|
|
147
|
+
return;
|
|
148
|
+
warned.add(msg);
|
|
149
|
+
console.error(`warning: ${msg}`);
|
|
150
|
+
}
|
|
151
|
+
// git's output, or null when it exits 1 with nothing to say (its way of
|
|
152
|
+
// answering "no"); any other failure is thrown.
|
|
153
|
+
function gitAnswer(args) {
|
|
154
|
+
try {
|
|
155
|
+
return git(args);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
if (err instanceof GitError && err.status === 1 && /failed$/.test(err.message))
|
|
159
|
+
return null;
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
138
163
|
// The config is read from the base branch, not the working tree: a change under
|
|
139
164
|
// review must not be able to reclassify its own paths or add patterns that
|
|
140
165
|
// lower its own floor. The base is the merge base of the checked-out HEAD (not
|
|
@@ -145,18 +170,49 @@ function gitInt(key) {
|
|
|
145
170
|
// the change's own config. Editing the file in a change is itself flagged: it
|
|
146
171
|
// is always a `sensitive` path.
|
|
147
172
|
function trustedRef() {
|
|
173
|
+
// No commits yet: no base, and nothing failed.
|
|
174
|
+
if (gitAnswer(['rev-parse', '--verify', '-q', 'HEAD']) === null)
|
|
175
|
+
return noBase();
|
|
176
|
+
let failed = false;
|
|
148
177
|
// Full ref names, so a tag called "main" cannot stand in for the branch.
|
|
149
178
|
for (const b of ['refs/remotes/origin/main', 'refs/heads/main', 'refs/remotes/origin/master', 'refs/heads/master']) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
179
|
+
try {
|
|
180
|
+
if (gitAnswer(['rev-parse', '--verify', '-q', b]) === null)
|
|
181
|
+
continue;
|
|
182
|
+
const mb = gitAnswer(['merge-base', 'HEAD', b])?.trim();
|
|
183
|
+
if (mb)
|
|
184
|
+
return mb;
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
// A broken candidate does not hide the ones after it.
|
|
188
|
+
failed = true;
|
|
189
|
+
warnOnce(`git failed finding the base branch with ${b} (${err.message}); trying the next candidate`);
|
|
190
|
+
}
|
|
153
191
|
}
|
|
192
|
+
// Some candidate failed: git's warning above is the reason, not an absent branch.
|
|
193
|
+
return failed ? (console.error(`warning: no working base branch, so ${CONFIG_FILE} is not read; using the built-in defaults`), null) : noBase();
|
|
194
|
+
}
|
|
195
|
+
function noBase() {
|
|
154
196
|
console.error('warning: no base branch (origin/main, main, origin/master, master) found, so .gloss/config.json is not read; using the built-in defaults');
|
|
155
197
|
return null;
|
|
156
198
|
}
|
|
199
|
+
// The config file at `ref`: null when the file is not there (the usual case),
|
|
200
|
+
// a warning and null when git could not tell or could not read it. Presence
|
|
201
|
+
// is asked of ls-tree (empty output: absent), not read off an error message.
|
|
202
|
+
function configAt(ref) {
|
|
203
|
+
try {
|
|
204
|
+
if (!git(['ls-tree', '--full-tree', '--name-only', ref, '--', CONFIG_FILE]).trim())
|
|
205
|
+
return null;
|
|
206
|
+
return git(['show', `${ref}:${CONFIG_FILE}`]);
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
warnOnce(`${CONFIG_FILE} could not be read from the base branch (${err.message}); using the built-in defaults, so the repo's own patterns do not apply`);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
157
213
|
function readRepoConfig() {
|
|
158
214
|
const ref = trustedRef();
|
|
159
|
-
const text = ref ?
|
|
215
|
+
const text = ref ? configAt(ref) : null;
|
|
160
216
|
if (text === null)
|
|
161
217
|
return undefined;
|
|
162
218
|
let json;
|
package/dist/floor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ConfigError, loadConfig } from './config.js';
|
|
2
2
|
import { attentionForImpact, attentionRank, impactRank, maxAttention, maxImpact, raise } from './attention.js';
|
|
3
3
|
import { servesOf } from './schema.js';
|
|
4
|
-
import { analyze, CONFIG_EDIT_NOTE, partFloor } from './signals.js';
|
|
4
|
+
import { analyze, CONFIG_EDIT_NOTE, partFloor, timedOutNotes } from './signals.js';
|
|
5
5
|
import { changedCount, selections } from './selection.js';
|
|
6
6
|
// The groups of a loaded gloss with their current hunks (a hunk that is gone is skipped).
|
|
7
7
|
export function groupInputs(gloss) {
|
|
@@ -101,7 +101,7 @@ export function analyzeGroups(hunks, groups, cfg) {
|
|
|
101
101
|
level = maxAttention(level, signals.floor.attention);
|
|
102
102
|
reasons.push(`floor ${signals.floor.attention}: ${signals.floor.reasons[0] ?? signals.floor.impact}`);
|
|
103
103
|
}
|
|
104
|
-
return { signals, groups: floors, floor: signals.floor.attention, derived: { level, reasons } };
|
|
104
|
+
return { signals, groups: floors, floor: signals.floor.attention, derived: { level, reasons }, timedOut: timedOutNotes(signals.hunks) };
|
|
105
105
|
}
|
|
106
106
|
export const analyzeGloss = (gloss, cfg) => analyzeGroups(gloss.hunks, groupInputs(gloss), cfg);
|
|
107
107
|
export function ratingProblems(a, an) {
|
package/dist/git.js
CHANGED
|
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { Annotation, NOTE_FORMAT } from './schema.js';
|
|
6
6
|
import { parsePath, unquote } from './diff.js';
|
|
7
|
+
import { plain } from './plain.js';
|
|
7
8
|
export const NOTES_REF = 'refs/notes/gloss';
|
|
8
9
|
// Glosses of uncommitted work (wip snapshots) live in their own notes ref,
|
|
9
10
|
// which sync never pushes, and the latest snapshot in a private ref that
|
|
@@ -18,6 +19,8 @@ export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
|
18
19
|
export class GlossError extends Error {
|
|
19
20
|
}
|
|
20
21
|
export class GitError extends GlossError {
|
|
22
|
+
// git's exit code, when it ran and failed (1 is often "no", 128 a real failure).
|
|
23
|
+
status;
|
|
21
24
|
}
|
|
22
25
|
export function git(args, input, env = {}) {
|
|
23
26
|
try {
|
|
@@ -32,7 +35,9 @@ export function git(args, input, env = {}) {
|
|
|
32
35
|
}
|
|
33
36
|
catch (err) {
|
|
34
37
|
const stderr = err.stderr?.trim();
|
|
35
|
-
|
|
38
|
+
const e = new GitError(stderr || `git ${args.join(' ')} failed`);
|
|
39
|
+
e.status = typeof err.status === 'number' ? err.status : undefined;
|
|
40
|
+
throw e;
|
|
36
41
|
}
|
|
37
42
|
}
|
|
38
43
|
export function gitTry(args, input) {
|
|
@@ -224,6 +229,15 @@ function rawNote(ref, sha) {
|
|
|
224
229
|
throw err;
|
|
225
230
|
}
|
|
226
231
|
}
|
|
232
|
+
function cleanStrings(v) {
|
|
233
|
+
if (typeof v === 'string')
|
|
234
|
+
return plain(v, true);
|
|
235
|
+
if (Array.isArray(v))
|
|
236
|
+
return v.map(cleanStrings);
|
|
237
|
+
if (v && typeof v === 'object')
|
|
238
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cleanStrings(x)]));
|
|
239
|
+
return v;
|
|
240
|
+
}
|
|
227
241
|
export function readNote(sha) {
|
|
228
242
|
// A commit's gloss, or a wip snapshot's (they are never the same object).
|
|
229
243
|
const raw = rawNote(NOTES_REF, sha) ?? rawNote(WIP_NOTES_REF, sha);
|
|
@@ -249,7 +263,9 @@ export function readNote(sha) {
|
|
|
249
263
|
if (![1, 2, 3, 4].includes(data?.gloss) || !Array.isArray(data.hunks) || !annotation.success) {
|
|
250
264
|
throw new GlossError(`${at} is malformed; replace it with \`git gloss add --force\``);
|
|
251
265
|
}
|
|
252
|
-
|
|
266
|
+
// The gloss's text is shown in a terminal, and a note can come from anyone
|
|
267
|
+
// who can push to the remote: no escape sequences or control characters get through.
|
|
268
|
+
return { ...data, annotation: cleanStrings(annotation.data) };
|
|
253
269
|
}
|
|
254
270
|
export function writeNote(sha, note, ref = NOTES_REF) {
|
|
255
271
|
git(['notes', '--ref', ref, 'add', '-f', '-F', '-', sha], JSON.stringify(note, null, 2) + '\n');
|
package/dist/html/client.js
CHANGED
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
return { level: null, source: 'unrated' }
|
|
63
63
|
}
|
|
64
64
|
function fileCount(c) {
|
|
65
|
-
var seen =
|
|
65
|
+
var seen = Object.create(null)
|
|
66
66
|
c.groups.forEach(function (g) { g.hunks.forEach(function (h) { seen[h.file] = true }) })
|
|
67
67
|
c.unannotated.forEach(function (h) { seen[h.file] = true })
|
|
68
68
|
return Object.keys(seen).length
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
var readCache = {}
|
|
73
73
|
function readSet(c) {
|
|
74
74
|
if (!readCache[c.sha]) {
|
|
75
|
-
var s =
|
|
75
|
+
var s = Object.create(null) // no prototype: a group id like "constructor" must not read as already read
|
|
76
76
|
try {
|
|
77
77
|
var raw = window.sessionStorage.getItem('git-gloss:read:' + c.sha)
|
|
78
78
|
if (raw) JSON.parse(raw).forEach(function (id) { s[id] = true })
|
|
@@ -240,6 +240,11 @@
|
|
|
240
240
|
reasons.forEach(function (r) { ul.appendChild(el('li', null, r)) })
|
|
241
241
|
add(box, ul)
|
|
242
242
|
}
|
|
243
|
+
if (k && k.timedOut && k.timedOut.length) {
|
|
244
|
+
var tl = el('ul', 'timedout')
|
|
245
|
+
k.timedOut.forEach(function (r) { tl.appendChild(el('li', null, r)) })
|
|
246
|
+
add(box, tl)
|
|
247
|
+
}
|
|
243
248
|
if (c.risks.length) {
|
|
244
249
|
var rb = button('linkbtn', plural(c.risks.length, 'risk') + ' flagged', function () { selectRisks(commits.indexOf(c)) })
|
|
245
250
|
add(box, add(el('p', 'look'), rb))
|
|
@@ -278,6 +283,8 @@
|
|
|
278
283
|
|
|
279
284
|
// ---- markdown: a small subset, built as DOM nodes (never parsed as HTML) ----
|
|
280
285
|
var ALNUM = /[A-Za-z0-9]/
|
|
286
|
+
// Review text is untrusted and unbounded: no emphasis or link may reach further than this.
|
|
287
|
+
var MAX_SPAN = 500
|
|
281
288
|
function inline(parent, s) {
|
|
282
289
|
var i = 0, buf = ''
|
|
283
290
|
function flush() { if (buf) { parent.appendChild(text(buf)); buf = '' } }
|
|
@@ -290,12 +297,13 @@
|
|
|
290
297
|
j = s.indexOf('**', i + 2)
|
|
291
298
|
if (j > i + 2) { flush(); parent.appendChild(inline(el('strong'), s.slice(i + 2, j))); i = j + 2; continue }
|
|
292
299
|
} else if (ch === '*' && s.charAt(i + 1) && !/[\s*]/.test(s.charAt(i + 1)) && (i === 0 || !ALNUM.test(s.charAt(i - 1)))) {
|
|
293
|
-
|
|
300
|
+
// Looks no further than MAX_SPAN: an unclosed marker would otherwise re-read the rest of the text each time (quadratic).
|
|
301
|
+
for (j = i + 2; j < s.length && j < i + MAX_SPAN; j++) {
|
|
294
302
|
if (s.charAt(j) === '*' && !/\s/.test(s.charAt(j - 1)) && !ALNUM.test(s.charAt(j + 1)) && s.charAt(j + 1) !== '*') break
|
|
295
303
|
}
|
|
296
|
-
if (j < s.length) { flush(); parent.appendChild(inline(el('em'), s.slice(i + 1, j))); i = j + 1; continue }
|
|
304
|
+
if (j < s.length && j < i + MAX_SPAN) { flush(); parent.appendChild(inline(el('em'), s.slice(i + 1, j))); i = j + 1; continue }
|
|
297
305
|
} else if (ch === '[') {
|
|
298
|
-
m = /^\[([^\]]
|
|
306
|
+
m = /^\[([^\]]{1,200})\]\(([^)\s]{1,400})\)/.exec(s.slice(i, i + MAX_SPAN))
|
|
299
307
|
if (m) { flush(); inline(parent, m[1]); parent.appendChild(el('span', 'muted', ' (' + m[2] + ')')); i += m[0].length; continue }
|
|
300
308
|
}
|
|
301
309
|
buf += ch
|
|
@@ -466,6 +474,10 @@
|
|
|
466
474
|
|
|
467
475
|
// ---- the detail pane ----
|
|
468
476
|
var topEl = el('header', 'top')
|
|
477
|
+
// Served by `git gloss ary`: the branch pickers (src/ary/picker.js, included
|
|
478
|
+
// ahead of this script only then) live in the top bar. Built once, since
|
|
479
|
+
// the bar is rebuilt on every change of filter.
|
|
480
|
+
var aryEl = data.ary ? aryBar(data.ary, el, add) : null
|
|
469
481
|
var railEl = el('aside', 'rail')
|
|
470
482
|
railEl.setAttribute('aria-label', multi ? 'Commits in the range' : 'Groups in review order')
|
|
471
483
|
var mainEl = el('main', 'main')
|
|
@@ -562,7 +574,7 @@
|
|
|
562
574
|
var order = c.order
|
|
563
575
|
var hunkN = g.hunks.length
|
|
564
576
|
var files = []
|
|
565
|
-
var perFile =
|
|
577
|
+
var perFile = Object.create(null)
|
|
566
578
|
g.hunks.forEach(function (h) {
|
|
567
579
|
if (!perFile[h.file]) { perFile[h.file] = 0; files.push(h.file) }
|
|
568
580
|
perFile[h.file]++
|
|
@@ -753,7 +765,7 @@
|
|
|
753
765
|
function renderTop() {
|
|
754
766
|
var c = commits[state.ci]
|
|
755
767
|
topEl.textContent = ''
|
|
756
|
-
add(topEl, el('span', 'brand', 'git gloss'))
|
|
768
|
+
add(topEl, el('span', 'brand', 'git gloss'), aryEl)
|
|
757
769
|
if (multi) {
|
|
758
770
|
var glossed = commits.filter(function (x) { return x.glossed }).length
|
|
759
771
|
var vetted = commits.filter(function (x) {
|
|
@@ -1091,8 +1103,12 @@
|
|
|
1091
1103
|
|
|
1092
1104
|
// ---- start ----
|
|
1093
1105
|
var desk = add(el('div', 'desk'), topEl, body)
|
|
1106
|
+
// The rail sticks below the top bar, whose height varies (the pickers wrap).
|
|
1107
|
+
function measureTop() { desk.style.setProperty('--top-h', topEl.offsetHeight + 'px') }
|
|
1094
1108
|
app.textContent = ''
|
|
1095
1109
|
app.appendChild(desk)
|
|
1110
|
+
measureTop()
|
|
1111
|
+
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(measureTop).observe(topEl)
|
|
1096
1112
|
if (multi) {
|
|
1097
1113
|
var first = commitOrder()[0]
|
|
1098
1114
|
state.ci = first
|
package/dist/html/data.js
CHANGED
|
@@ -127,7 +127,7 @@ export function commitData(gloss) {
|
|
|
127
127
|
task: a.task,
|
|
128
128
|
summary: a.summary,
|
|
129
129
|
attention: a.attention ?? null,
|
|
130
|
-
computed: an ? { floor: an.floor, derived: an.derived, below: belowFloor(a.attention, an) } : null,
|
|
130
|
+
computed: an ? { floor: an.floor, derived: an.derived, below: belowFloor(a.attention, an), timedOut: an.timedOut } : null,
|
|
131
131
|
warnings: gloss.warnings,
|
|
132
132
|
groups,
|
|
133
133
|
order: reviewOrder(groups, a.attention?.focus),
|
package/dist/html/page.js
CHANGED
|
@@ -27,7 +27,7 @@ ${asset('./style.css')}
|
|
|
27
27
|
<div id="app"><noscript>This page needs JavaScript to render the gloss.</noscript></div>
|
|
28
28
|
<script type="application/json" id="gloss-data">${embedJson(data)}</script>
|
|
29
29
|
<script nonce="${nonce}">
|
|
30
|
-
${asset('./client.js')}
|
|
30
|
+
${data.ary ? asset('../ary/picker.js') + '\n' : ''}${asset('./client.js')}
|
|
31
31
|
</script>
|
|
32
32
|
</body>
|
|
33
33
|
</html>
|
package/dist/html/style.css
CHANGED
|
@@ -119,13 +119,38 @@ kbd { font-family: var(--mono); font-size: 11px; border: 1px solid var(--line);
|
|
|
119
119
|
.track { width: 120px; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
|
|
120
120
|
.track i { display: block; height: 100%; width: 0; background: var(--accent); }
|
|
121
121
|
|
|
122
|
+
/* Branch pickers and revision box in the top bar, only when served by `git gloss ary` */
|
|
123
|
+
.ary-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; min-width: 0; }
|
|
124
|
+
.ary-repo { color: var(--muted); font-family: var(--mono); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
|
|
125
|
+
.ary-field { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
|
|
126
|
+
.ary-notice { flex-basis: 100%; margin: 0; padding: 6px 10px; border-radius: 8px; border: 1px dashed var(--warn); font-size: 13px; color: var(--ink); }
|
|
127
|
+
.combo { position: relative; }
|
|
128
|
+
.combo-btn, .combo-panel input, .ary-rev input { border: 1px solid var(--line); background: var(--panel); color: var(--ink); border-radius: 8px; min-height: 36px; padding: 4px 12px; font-size: 13px; }
|
|
129
|
+
.combo-btn { position: relative; max-width: 220px; padding-right: 28px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 12.5px; }
|
|
130
|
+
.combo-btn::after { content: "▾"; position: absolute; right: 10px; color: var(--muted); }
|
|
131
|
+
.combo-btn:hover { background: var(--accent-soft); }
|
|
132
|
+
.combo-panel { position: absolute; z-index: 10; left: 0; top: calc(100% + 4px); width: 300px; max-width: calc(100vw - 32px); display: grid; gap: 6px; padding: 6px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: 0 8px 24px rgb(0 0 0 / 0.25); }
|
|
133
|
+
.combo-panel[hidden] { display: none; }
|
|
134
|
+
.combo-panel input { width: 100%; }
|
|
135
|
+
.combo-list { max-height: min(50vh, 320px); overflow: auto; }
|
|
136
|
+
.combo-group { margin-top: 8px; padding: 10px 8px 4px; border-top: 1px solid var(--line); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink); }
|
|
137
|
+
.combo-group:first-child { margin-top: 0; padding-top: 4px; border-top: 0; }
|
|
138
|
+
.combo-group .count { margin-left: 6px; color: var(--muted); font-weight: 400; }
|
|
139
|
+
.combo-item { display: block; width: 100%; text-align: left; border: 0; background: none; border-radius: 6px; padding: 5px 8px 5px 20px; font-family: var(--mono); font-size: 12.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
140
|
+
.combo-item:hover, .combo-item.active { background: var(--accent-soft); }
|
|
141
|
+
.combo-item[aria-selected="true"] { color: var(--accent); font-weight: 600; }
|
|
142
|
+
.combo-more { padding: 6px 8px; color: var(--muted); font-size: 12.5px; }
|
|
143
|
+
.ary-rev input { width: 220px; max-width: 100%; font-family: var(--mono); font-size: 12.5px; }
|
|
144
|
+
|
|
122
145
|
/* Body: rail above the pane when narrow, beside it from 900px */
|
|
123
146
|
.body { flex: 1; display: grid; grid-template-columns: minmax(0, 1fr); align-items: start; }
|
|
124
147
|
@media (min-width: 900px) {
|
|
125
148
|
/* the rail's panel colour runs the full height, however tall the top bar is */
|
|
126
149
|
.body { grid-template-columns: 360px minmax(0, 1fr); background: linear-gradient(90deg, var(--panel) 359px, var(--line) 359px 360px, transparent 360px); }
|
|
127
150
|
.body.range { grid-template-columns: 400px minmax(0, 1fr); background: linear-gradient(90deg, var(--panel) 399px, var(--line) 399px 400px, transparent 400px); }
|
|
128
|
-
|
|
151
|
+
/* the bar stays put and the rail fits in what is left (--top-h is measured by the script) */
|
|
152
|
+
.top { position: sticky; top: 0; z-index: 5; }
|
|
153
|
+
.rail { position: sticky; top: var(--top-h, 0px); max-height: calc(100vh - var(--top-h, 0px)); overflow: auto; overscroll-behavior: contain; border-bottom: 0; border-right: 1px solid var(--line); }
|
|
129
154
|
}
|
|
130
155
|
.rail { background: var(--panel); border-bottom: 1px solid var(--line); display: flex; flex-direction: column; min-width: 0; }
|
|
131
156
|
.main { min-width: 0; padding: 20px 16px 48px; }
|
|
@@ -139,6 +164,7 @@ kbd { font-family: var(--mono); font-size: 11px; border: 1px solid var(--line);
|
|
|
139
164
|
.att li { margin-bottom: 4px; }
|
|
140
165
|
.att .look { margin: 10px 0 0; font-size: 13px; }
|
|
141
166
|
.att .override { margin: 10px 0 0; font-size: 13px; color: var(--warn-text); background: var(--warn-bg); border-radius: 6px; padding: 6px 10px; }
|
|
167
|
+
.att .timedout { margin: 10px 0 0; padding: 6px 10px 6px 28px; font-size: 13px; color: var(--warn-text); background: var(--warn-bg); border-radius: 6px; }
|
|
142
168
|
.att .override.mismatch { font-weight: 600; border: 1px solid var(--warn-border); }
|
|
143
169
|
.gauge { display: inline-flex; gap: 3px; }
|
|
144
170
|
.gauge i { display: block; width: 22px; height: 8px; border-radius: 2px; background: var(--line); }
|
package/dist/loader.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { commitDiff, commitSubject, readNote } from './git.js';
|
|
1
|
+
import { commitDiff, commitSubject, GlossError, readNote } from './git.js';
|
|
2
2
|
import { parseDiff } from './diff.js';
|
|
3
|
-
import { changedCount, selections, uncovered } from './selection.js';
|
|
3
|
+
import { changedCount, claimsProblem, selections, uncovered } from './selection.js';
|
|
4
4
|
const locationKey = (h) => [h.file, h.oldFile ?? '', h.oldStart, h.oldLines, h.newStart, h.newLines].join('\0');
|
|
5
5
|
// Pair each stored hunk with a current one: first by the hash of its changes
|
|
6
6
|
// (it may have moved in a rebase), then by location (same place, different
|
|
@@ -49,6 +49,10 @@ export function loadGloss(sha) {
|
|
|
49
49
|
})),
|
|
50
50
|
}));
|
|
51
51
|
const claims = groups.flatMap((g) => g.hunks.flatMap((h) => (h.current ? [{ hunk: h.current, lines: h.lines }] : [])));
|
|
52
|
+
// Readers refuse what `add` refuses (see claimsProblem).
|
|
53
|
+
const tooMany = claimsProblem(claims, current);
|
|
54
|
+
if (tooMany)
|
|
55
|
+
throw new GlossError(`the gloss on ${sha.slice(0, 10)} is refused: ${tooMany}`);
|
|
52
56
|
const unannotated = uncovered(current, claims);
|
|
53
57
|
const bad = [...resolved.values()].filter((h) => h.status !== 'ok').length;
|
|
54
58
|
const matched = new Set([...resolved.values()].map((h) => h.current));
|
package/dist/plain.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Text from a note is shown in a terminal: no escape sequences or other
|
|
2
|
+
// control characters get through. Newlines and tabs stay when `multiline`.
|
|
3
|
+
export function plain(text, multiline = false) {
|
|
4
|
+
return text
|
|
5
|
+
// OSC (ended by BEL or ESC \) and CSI sequences, then any lone ESC and control characters (newline and tab kept when multiline).
|
|
6
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, '')
|
|
7
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]?/g, '')
|
|
8
|
+
.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, (c) => (multiline && (c === '\n' || c === '\t') ? c : ' '));
|
|
9
|
+
}
|
package/dist/render.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { plain } from './plain.js';
|
|
1
2
|
import { spawnSync } from 'node:child_process';
|
|
2
3
|
import { styleText } from 'node:util';
|
|
3
4
|
import { gitTry } from './git.js';
|
|
@@ -54,21 +55,22 @@ export function attentionBlock(a, view) {
|
|
|
54
55
|
return [style('dim', 'attention: unrated')];
|
|
55
56
|
const d = view.derived;
|
|
56
57
|
return [`attention: ${level(d.level)} ${style('dim', '(derived)')}` + (d.level === view.floor ? '' : ` · floor: ${view.floor}`),
|
|
57
|
-
...d.reasons.flatMap((r) => field('•', r))];
|
|
58
|
+
...d.reasons.flatMap((r) => field('•', r)), ...timedOutLines(view)];
|
|
58
59
|
}
|
|
59
60
|
const head = `attention: ${level(a.level)}${view ? ' ' + style('dim', '(declared)') + ` · floor: ${view.floor}` : ''}` +
|
|
60
61
|
(a.focus?.length ? ` · focus: ${a.focus.join(', ')}` : '');
|
|
61
62
|
const below = belowFloor(a, view);
|
|
62
63
|
return [head, ...(a.reasons ?? []).flatMap((r) => field('•', r)),
|
|
63
64
|
...(below ? field('⚠', `declared ${a.level}, below the computed floor ${view.floor}` + (a.override_reason ? '' : ' (no override_reason)'), 2).map((l) => style('yellow', l)) : []),
|
|
64
|
-
...(a.override_reason ? field('override:', a.override_reason) : [])];
|
|
65
|
+
...(a.override_reason ? field('override:', a.override_reason) : []), ...timedOutLines(view)];
|
|
65
66
|
}
|
|
67
|
+
const timedOutLines = (view) => (view?.timedOut ?? []).flatMap((r) => field('⚠', r, 2).map((l) => style('yellow', l)));
|
|
66
68
|
export const termWidth = () => Math.min(process.stdout.columns || 100, 120);
|
|
67
69
|
// Word-wrap plain text to `width` columns.
|
|
68
70
|
export function wrap(text, width) {
|
|
69
71
|
const lines = [];
|
|
70
72
|
let line = '';
|
|
71
|
-
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
73
|
+
for (const word of plain(text).split(/\s+/).filter(Boolean)) {
|
|
72
74
|
if (line && line.length + 1 + word.length > width) {
|
|
73
75
|
lines.push(line);
|
|
74
76
|
line = word;
|
|
@@ -90,7 +92,7 @@ export function hunkLabel(h, lines = null) {
|
|
|
90
92
|
const range = h.newLines || h.oldLines
|
|
91
93
|
? `-${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines}`
|
|
92
94
|
: 'file header';
|
|
93
|
-
const file = h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ${h.file}` : h.file;
|
|
95
|
+
const file = plain(h.oldFile && h.oldFile !== h.file ? `${h.oldFile} → ${h.file}` : h.file);
|
|
94
96
|
const s = sliceStats(h, lines);
|
|
95
97
|
return `${style('bold', refLabel(h.id, lines))} ${file} ${style('dim', range)} ${style('green', `+${s.added}`)} ${style('red', `-${s.removed}`)}`;
|
|
96
98
|
}
|