@ok-ggwp/git-gloss 0.0.0-stage → 0.1.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/dist/git.js ADDED
@@ -0,0 +1,315 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync, utimesSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { Annotation, NOTE_FORMAT } from './schema.js';
6
+ import { parsePath, unquote } from './diff.js';
7
+ export const NOTES_REF = 'refs/notes/gloss';
8
+ // Glosses of uncommitted work (wip snapshots) live in their own notes ref,
9
+ // which sync never pushes, and the latest snapshot in a private ref that
10
+ // keeps it from being garbage-collected. refs/worktree/ refs are per
11
+ // worktree, so each linked worktree has its own snapshot.
12
+ export const WIP_NOTES_REF = 'refs/notes/gloss-wip';
13
+ export const WIP_REF = 'refs/worktree/gloss/wip';
14
+ const WIP_MESSAGE = 'git-gloss wip snapshot';
15
+ export const REMOTE_NOTES_REF = 'refs/notes/remotes/origin/gloss';
16
+ export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
17
+ // Errors whose message is fit to show the user without a stack trace.
18
+ export class GlossError extends Error {
19
+ }
20
+ export class GitError extends GlossError {
21
+ }
22
+ export function git(args, input, env = {}) {
23
+ try {
24
+ return execFileSync('git', args, {
25
+ encoding: 'utf8',
26
+ input,
27
+ // Untranslated messages: readNote recognises "no note found" by its text.
28
+ env: { ...process.env, ...env, LC_ALL: 'C' },
29
+ maxBuffer: 256 * 1024 * 1024,
30
+ stdio: ['pipe', 'pipe', 'pipe'],
31
+ });
32
+ }
33
+ catch (err) {
34
+ const stderr = err.stderr?.trim();
35
+ throw new GitError(stderr || `git ${args.join(' ')} failed`);
36
+ }
37
+ }
38
+ export function gitTry(args, input) {
39
+ try {
40
+ return git(args, input);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ // Untracked files that look like secrets. A snapshot is shown and sent to
47
+ // reviewers, so these are left out unless the user stages them.
48
+ const SENSITIVE = [
49
+ /^\.env(\..*)?$/, /\.(pem|key|p12|pfx|jks|keystore|kdbx)$/, /^id_(rsa|dsa|ecdsa|ed25519)$/,
50
+ /(^|[-_.])(secrets?|credentials?|tokens?|passwords?)([-_.]|$)/i, /^\.(netrc|npmrc|pypirc)$/,
51
+ ];
52
+ const looksSensitive = (path) => SENSITIVE.some((re) => re.test(path.split('/').pop()));
53
+ // The working tree as a tree object: tracked files, staged files, and
54
+ // untracked files except ignored ones and ones that look like secrets. Built
55
+ // in a temporary index so the real index is untouched. `excluded` lists the
56
+ // untracked files left out as possible secrets.
57
+ function workingTree() {
58
+ const dir = mkdtempSync(join(tmpdir(), 'git-gloss-index-'));
59
+ try {
60
+ const index = join(dir, 'index');
61
+ const real = git(['rev-parse', '--path-format=absolute', '--git-path', 'index']).trim();
62
+ if (existsSync(real)) {
63
+ copyFileSync(real, index);
64
+ // Keep the index's timestamps: git compares them with file mtimes to
65
+ // spot files edited in the same second as the index was written
66
+ // ("racily clean"), and a fresh mtime would hide such edits.
67
+ const { atime, mtime } = statSync(real);
68
+ utimesSync(index, atime, mtime);
69
+ }
70
+ const env = { GIT_INDEX_FILE: index };
71
+ const untracked = git(['ls-files', '--others', '--exclude-standard', '-z', '--', ':/']).split('\0').filter(Boolean);
72
+ const excluded = untracked.filter(looksSensitive);
73
+ git(['add', '-A', '--', ':/'], undefined, env);
74
+ if (excluded.length)
75
+ git(['rm', '-q', '--cached', '--', ...excluded.map((p) => `:/${p}`)], undefined, env);
76
+ return { tree: git(['write-tree'], undefined, env).trim(), excluded };
77
+ }
78
+ finally {
79
+ rmSync(dir, { recursive: true, force: true });
80
+ }
81
+ }
82
+ const headSha = () => gitTry(['rev-parse', '--verify', '--quiet', 'HEAD'])?.trim() ?? null;
83
+ const treeAndParent = (sha) => {
84
+ const [tree, parent] = git(['log', '-1', '--format=%T %P', sha]).trim().split(' ');
85
+ return { tree, parent: parent || null };
86
+ };
87
+ // Record uncommitted changes as a commit on top of HEAD without touching the
88
+ // branch, index or files, and point WIP_REF at it. Reuses the current
89
+ // snapshot if nothing has changed, so its gloss stays attached.
90
+ // `replaced` is the previous snapshot when this one replaces it, so the
91
+ // caller can carry its gloss over.
92
+ export function snapshotWorkingTree() {
93
+ const { tree, excluded } = workingTree();
94
+ const head = headSha();
95
+ if (head && git(['rev-parse', `${head}^{tree}`]).trim() === tree) {
96
+ throw new GlossError('there are no uncommitted changes to snapshot');
97
+ }
98
+ const current = gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim();
99
+ if (current) {
100
+ const c = treeAndParent(current);
101
+ if (c.tree === tree && c.parent === head)
102
+ return { sha: current, excluded, replaced: null };
103
+ }
104
+ const sha = git(['commit-tree', tree, ...(head ? ['-p', head] : []), '-m', WIP_MESSAGE], undefined, commitIdentity()).trim();
105
+ if (current)
106
+ archiveSnapshot(current);
107
+ git(['update-ref', WIP_REF, sha]);
108
+ return { sha, excluded, replaced: current ?? null };
109
+ }
110
+ // Snapshots and archive entries are commit objects, which need an identity;
111
+ // fall back to a placeholder where git has none configured rather than fail.
112
+ function commitIdentity() {
113
+ const ident = (v) => gitTry(['var', v]) !== null;
114
+ const env = {};
115
+ if (!ident('GIT_AUTHOR_IDENT'))
116
+ Object.assign(env, { GIT_AUTHOR_NAME: 'git-gloss', GIT_AUTHOR_EMAIL: 'git-gloss@localhost' });
117
+ if (!ident('GIT_COMMITTER_IDENT'))
118
+ Object.assign(env, { GIT_COMMITTER_NAME: 'git-gloss', GIT_COMMITTER_EMAIL: 'git-gloss@localhost' });
119
+ return env;
120
+ }
121
+ // A glossed snapshot that is being replaced or cleared stays reachable from
122
+ // this ref, so git gc keeps it: its old gloss, which the notes history keeps,
123
+ // still has the changes it describes (`versions wip` reads and restores them).
124
+ // Each entry is an empty commit whose parents are the previous entry and the
125
+ // snapshot. Per worktree, like WIP_REF.
126
+ export const ARCHIVE_REF = 'refs/worktree/gloss/archive';
127
+ export function archiveSnapshot(sha) {
128
+ if (rawNote(WIP_NOTES_REF, sha) === null)
129
+ return;
130
+ const prev = gitTry(['rev-parse', '-q', '--verify', ARCHIVE_REF])?.trim();
131
+ const entry = git(['commit-tree', EMPTY_TREE, ...(prev ? ['-p', prev] : []), '-p', sha, '-m', 'git-gloss: earlier glossed wip snapshot'], undefined, commitIdentity()).trim();
132
+ git(['update-ref', ARCHIVE_REF, entry]);
133
+ }
134
+ export const dropWipNote = (sha) => gitTry(['notes', '--ref', WIP_NOTES_REF, 'remove', '--ignore-missing', sha]);
135
+ export const isWipSnapshot = (sha) => gitTry(['log', '-1', '--format=%s', sha])?.trim() === WIP_MESSAGE;
136
+ // Why the latest snapshot no longer matches the working tree, or null.
137
+ export function wipStaleness(sha) {
138
+ const s = treeAndParent(sha);
139
+ if (s.parent !== headSha())
140
+ return 'HEAD has moved since this snapshot was taken';
141
+ if (workingTree().tree !== s.tree)
142
+ return 'the working tree has changed since this snapshot was taken';
143
+ return null;
144
+ }
145
+ export function resolveCommit(rev) {
146
+ // "wip": the latest snapshot of uncommitted work (made by `hunks wip`). A
147
+ // branch or tag named wip still works when there is no snapshot, and can be
148
+ // reached as heads/wip or tags/wip when there is.
149
+ const named = gitTry(['rev-parse', '--verify', '--quiet', `${rev}^{commit}`])?.trim();
150
+ if (rev === 'wip') {
151
+ const sha = gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim();
152
+ if (sha) {
153
+ if (named)
154
+ console.error('note: "wip" means the snapshot of uncommitted work; for the branch or tag, use heads/wip or tags/wip');
155
+ const stale = wipStaleness(sha);
156
+ if (stale)
157
+ console.error(`warning: ${stale}; run \`git gloss hunks wip\` to snapshot again, then re-gloss`);
158
+ return sha;
159
+ }
160
+ if (!named)
161
+ throw new GlossError('no snapshot of uncommitted work yet; run `git gloss hunks wip` first');
162
+ }
163
+ const sha = named;
164
+ if (!sha)
165
+ throw new GitError(`not a commit: ${rev}`);
166
+ return sha.trim();
167
+ }
168
+ // A commit, or the commits in a range such as "main..feature", oldest first.
169
+ // Merges are not reviewed (their first-parent diff repeats the merged
170
+ // commits) but are returned so views can say they were skipped: a merge can
171
+ // still carry conflict resolutions or edits of its own.
172
+ export function resolveCommits(arg) {
173
+ if (!arg.includes('..'))
174
+ return { shas: [resolveCommit(arg)], merges: [], range: null };
175
+ if (arg.includes('...')) {
176
+ throw new GitError(`${arg}: "..." includes commits from both sides; use A..B for the commits on B since A`);
177
+ }
178
+ // --end-of-options: the argument is a revision range, never an option.
179
+ const list = (...flags) => git(['rev-list', '--reverse', ...flags, '--end-of-options', arg, '--']).split('\n').filter(Boolean);
180
+ const shas = list('--no-merges');
181
+ const merges = list('--merges');
182
+ if (!shas.length && !merges.length)
183
+ throw new GitError(`no commits in ${arg}`);
184
+ return { shas, merges, range: arg };
185
+ }
186
+ export function commitSubject(sha) {
187
+ return git(['log', '-1', '--format=%s', sha]).trim();
188
+ }
189
+ // Diff of a commit against its first parent (or the empty tree for a root
190
+ // commit). Every option that changes which hunks git produces, or their order,
191
+ // is pinned so hunk ids are the same regardless of the user's diff config.
192
+ export function commitDiff(sha) {
193
+ return diffBetween(gitTry(['rev-parse', '--verify', '--quiet', `${sha}^1`])?.trim() || EMPTY_TREE, sha);
194
+ }
195
+ // The diff between two commits (or trees), with the same pinned options.
196
+ export function diffBetween(from, to) {
197
+ return git([
198
+ '-c', 'core.quotePath=false', '-c', 'diff.suppressBlankEmpty=false',
199
+ 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--find-renames',
200
+ '--src-prefix=a/', '--dst-prefix=b/', '-U3',
201
+ '-O/dev/null', '--diff-algorithm=myers', '--indent-heuristic',
202
+ '--inter-hunk-context=0', '--no-relative', '--submodule=short', '-l1000',
203
+ from, to,
204
+ ]);
205
+ }
206
+ function rawNote(ref, sha) {
207
+ try {
208
+ return git(['notes', '--ref', ref, 'show', sha]);
209
+ }
210
+ catch (err) {
211
+ if (/no note found/i.test(err.message))
212
+ return null;
213
+ throw err;
214
+ }
215
+ }
216
+ export function readNote(sha) {
217
+ // A commit's gloss, or a wip snapshot's (they are never the same object).
218
+ const raw = rawNote(NOTES_REF, sha) ?? rawNote(WIP_NOTES_REF, sha);
219
+ if (raw === null)
220
+ return null;
221
+ const at = `the gloss on ${sha.slice(0, 10)}`;
222
+ let data;
223
+ try {
224
+ data = JSON.parse(raw);
225
+ }
226
+ catch {
227
+ // notes.rewriteMode=concatenate joins two notes when a rebase squashes
228
+ // glossed commits together.
229
+ if (/}\s*\n\s*{/.test(raw)) {
230
+ throw new GlossError(`${at} holds several glosses joined together (from a squash or fixup); re-gloss it with \`git gloss add --force\``);
231
+ }
232
+ throw new GlossError(`${at} is not valid JSON; replace it with \`git gloss add --force\``);
233
+ }
234
+ if (typeof data?.gloss === 'number' && data.gloss > NOTE_FORMAT) {
235
+ throw new GlossError(`${at} was written by a newer git-gloss (format ${data.gloss}); upgrade to read it`);
236
+ }
237
+ const annotation = Annotation.safeParse(data?.annotation);
238
+ if (![1, 2, 3].includes(data?.gloss) || !Array.isArray(data.hunks) || !annotation.success) {
239
+ throw new GlossError(`${at} is malformed; replace it with \`git gloss add --force\``);
240
+ }
241
+ return { ...data, annotation: annotation.data };
242
+ }
243
+ export function writeNote(sha, note, ref = NOTES_REF) {
244
+ git(['notes', '--ref', ref, 'add', '-f', '-F', '-', sha], JSON.stringify(note, null, 2) + '\n');
245
+ }
246
+ // The commits that changed lines start..end of `file`, newest first, starting
247
+ // from `commit`, following them through edits, moves within the file and
248
+ // renames. Each step gives the file's path in that commit and the lines' new
249
+ // numbers in its diff. `max` stops after that many commits (to check whether
250
+ // any older history exists without walking it). Uses git log -L, so it is
251
+ // approximate when lines are heavily rewritten. Null if git can't trace them.
252
+ export function lineHistory(file, start, end, commit, max) {
253
+ const out = gitTry([
254
+ // showSignature would print signature checks between the commits.
255
+ '-c', 'core.quotePath=false', '-c', 'log.showSignature=false', 'log', '--no-color', '--no-ext-diff',
256
+ '--src-prefix=a/', '--dst-prefix=b/', `-L${start},${end}:${file}`, '--format=%x00%H',
257
+ ...(max ? [`--max-count=${max}`] : []), commit,
258
+ ]);
259
+ if (out === null)
260
+ return null;
261
+ return out.split('\0').filter((chunk) => chunk.trim()).map((chunk) => {
262
+ const [first, ...rest] = chunk.split('\n');
263
+ let path = file, newNo = 0, inHunk = false;
264
+ const lines = [];
265
+ for (const l of rest) {
266
+ if (l.startsWith('diff --git ')) {
267
+ inHunk = false;
268
+ continue;
269
+ }
270
+ // Inside a hunk, decide by position, not prefix: an added line whose
271
+ // text starts with "++ " would otherwise look like a "+++" header.
272
+ if (!inHunk && l.startsWith('+++ ')) {
273
+ path = parsePath(l.slice(4)) ?? path;
274
+ continue;
275
+ }
276
+ const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l);
277
+ if (h) {
278
+ newNo = Number(h[1]);
279
+ inHunk = true;
280
+ continue;
281
+ }
282
+ if (!inHunk)
283
+ continue;
284
+ if (l.startsWith('+'))
285
+ lines.push(newNo++);
286
+ else if (l.startsWith(' '))
287
+ newNo++;
288
+ }
289
+ return { commit: first.trim(), file: path, lines };
290
+ });
291
+ }
292
+ // Blame lines start..end in one call (one record per line).
293
+ export function blameRange(file, start, end, rev) {
294
+ const args = ['blame', '--line-porcelain', '-L', `${start},${end}`];
295
+ if (rev)
296
+ args.push(rev);
297
+ args.push('--', file);
298
+ const out = [];
299
+ let cur = null;
300
+ for (const l of git(args).split('\n')) {
301
+ // 40 hex digits for SHA-1 repositories, 64 for SHA-256.
302
+ const head = /^([0-9a-f]{40}(?:[0-9a-f]{24})?) (\d+) (\d+)/.exec(l);
303
+ if (head) {
304
+ cur = { commit: head[1], origLine: Number(head[2]), line: Number(head[3]), origFile: file };
305
+ out.push(cur);
306
+ continue;
307
+ }
308
+ // Porcelain C-quotes unusual file names, like diff headers do.
309
+ if (cur && l.startsWith('filename ')) {
310
+ const raw = l.slice(9);
311
+ cur.origFile = raw.startsWith('"') ? unquote(raw) : raw;
312
+ }
313
+ }
314
+ return out;
315
+ }
@@ -0,0 +1,354 @@
1
+ // Renders the gloss page from the embedded JSON. All text goes through
2
+ // textContent; nothing from the data is ever parsed as HTML.
3
+ (function () {
4
+ 'use strict'
5
+ var data = JSON.parse(document.getElementById('gloss-data').textContent)
6
+ var app = document.getElementById('app')
7
+ var hidden = new Set() // necessity values filtered out
8
+ var multi = data.commits.length > 1 || data.range !== null
9
+ // Group ids are unique within a commit, not across a range, so a range page
10
+ // keys cards by commit too: #g-<sha7>-<group>.
11
+ function key(c, id) { return multi ? c.sha.slice(0, 7) + '-' + id : id }
12
+
13
+ function el(tag, cls, text) {
14
+ var e = document.createElement(tag)
15
+ if (cls) e.className = cls
16
+ if (text !== undefined && text !== null) e.textContent = String(text)
17
+ return e
18
+ }
19
+ function add(parent) {
20
+ for (var i = 1; i < arguments.length; i++) if (arguments[i]) parent.appendChild(arguments[i])
21
+ return parent
22
+ }
23
+ function color(e, necessity) {
24
+ e.style.setProperty('--c', 'var(--n-' + necessity + ')')
25
+ return e
26
+ }
27
+ function plural(n, word) { return n + ' ' + word + (n === 1 ? '' : 's') }
28
+ function stats(added, removed) {
29
+ return add(el('span', 'stats'), el('span', 'plus', '+' + added), document.createTextNode(' '), el('span', 'minus', '−' + removed))
30
+ }
31
+ function groupLink(c, id) {
32
+ var a = el('a', null, id)
33
+ a.href = '#g-' + key(c, id)
34
+ a.addEventListener('click', function (ev) { ev.preventDefault(); focusGroup(key(c, id), true) })
35
+ return a
36
+ }
37
+
38
+ // ---- diff rendering ----
39
+ function hunkView(c, h) {
40
+ var head = add(el('div', 'hunk-head'),
41
+ el('span', 'range', h.ref),
42
+ el('span', 'file', h.oldFile ? h.oldFile + ' → ' + h.file : h.file),
43
+ el('span', 'range', h.range),
44
+ stats(h.added, h.removed))
45
+ if (h.status === 'changed') add(head, el('span', 'stale', 'changed since glossed'))
46
+ if (h.status === 'missing') {
47
+ add(head, el('span', 'stale', 'no longer in this commit'))
48
+ return add(el('div', 'hunk'), head)
49
+ }
50
+ if (h.alsoIn.length) {
51
+ var also = add(el('span', 'also'), document.createTextNode('shares lines with '))
52
+ h.alsoIn.forEach(function (id, i) { if (i) also.appendChild(document.createTextNode(', ')); also.appendChild(groupLink(c, id)) })
53
+ add(head, also)
54
+ }
55
+ var table = el('table', 'diff')
56
+ h.lines.forEach(function (l) {
57
+ var tr = el('tr', l.kind)
58
+ if (l.kind === 'meta' || l.kind === 'gap') {
59
+ var td = el('td', 'text', l.text)
60
+ td.colSpan = 4
61
+ add(tr, td)
62
+ } else {
63
+ add(tr,
64
+ el('td', 'ln', l.old === null ? '' : l.old),
65
+ el('td', 'ln', l.new === null ? '' : l.new),
66
+ el('td', 'mark', l.kind === 'add' ? '+' : l.kind === 'del' ? '−' : ' '),
67
+ el('td', 'text', l.text))
68
+ }
69
+ table.appendChild(tr)
70
+ })
71
+ return add(el('div', 'hunk'), head, add(el('div', 'scroll'), table))
72
+ }
73
+
74
+ // ---- group cards ----
75
+ var cards = []
76
+ function groupCard(c, g) {
77
+ var card = color(el('article', 'group'), g.necessity)
78
+ card.id = 'g-' + key(c, g.id)
79
+ card.tabIndex = -1
80
+ card.dataset.necessity = g.necessity
81
+
82
+ var title = add(el('div', 'group-title'),
83
+ el('span', 'badge', g.necessity),
84
+ el('span', 'intent', g.intent),
85
+ el('span', 'gid', g.id))
86
+ if (g.stale) add(title, el('span', 'stale', g.stale + ' of ' + plural(g.total, 'hunk') + ' changed or gone'))
87
+ add(title, stats(g.added, g.removed))
88
+
89
+ var facts = el('dl', 'facts')
90
+ function fact(label, value) {
91
+ if (!value) return
92
+ add(facts, el('dt', null, label), typeof value === 'string' ? el('dd', null, value) : add(el('dd'), value))
93
+ }
94
+ if (g.serves[0] !== 'task') {
95
+ var links = el('span')
96
+ g.serves.forEach(function (id, i) { if (i) links.appendChild(document.createTextNode(', ')); links.appendChild(groupLink(c, id)) })
97
+ fact('serves', links)
98
+ }
99
+ fact('if removed', g.removal_impact)
100
+ fact('evidence', g.evidence)
101
+ fact('notes', g.notes)
102
+
103
+ add(card, add(el('div', 'group-head'), title), facts)
104
+
105
+ if (g.hunks.length) {
106
+ var code = el('div', 'code')
107
+ code.hidden = true
108
+ var toggle = el('button', 'code-toggle')
109
+ toggle.type = 'button'
110
+ var rendered = false
111
+ function setOpen(open) {
112
+ if (open && !rendered) { g.hunks.forEach(function (h) { code.appendChild(hunkView(c, h)) }); rendered = true }
113
+ code.hidden = !open
114
+ toggle.textContent = (open ? '▾ Hide code' : '▸ Show code') + ' · ' + plural(g.hunks.length, 'hunk')
115
+ toggle.setAttribute('aria-expanded', String(open))
116
+ }
117
+ toggle.addEventListener('click', function () { setOpen(code.hidden) })
118
+ setOpen(false)
119
+ card.setOpen = setOpen
120
+ card.isOpen = function () { return !code.hidden }
121
+ add(card, toggle, code)
122
+ }
123
+ cards.push(card)
124
+ return card
125
+ }
126
+
127
+ // ---- page ----
128
+ function statement(parent, label, text) {
129
+ if (text) add(parent, add(el('p', 'statement'), el('span', 'label', label), document.createTextNode(text)))
130
+ }
131
+ function commitMeta(c) {
132
+ return add(el('div', 'commit'), el('span', 'sha', c.sha.slice(0, 10)),
133
+ el('span', null, c.author), el('span', null, new Date(c.date).toLocaleString()))
134
+ }
135
+
136
+ // One commit's groups, unannotated changes, risks and alternatives, with its
137
+ // entries in the sidebar.
138
+ function commitBody(c, main) {
139
+ var tocList = el('ol')
140
+ var groupsEl = el('section', 'groups')
141
+ c.groups.forEach(function (g) {
142
+ groupsEl.appendChild(groupCard(c, g))
143
+ var li = el('li')
144
+ li.dataset.necessity = g.necessity
145
+ li.dataset.key = key(c, g.id)
146
+ var a = add(el('a'), color(el('span', 'dot'), g.necessity), el('span', null, g.id))
147
+ a.href = '#g-' + key(c, g.id)
148
+ a.title = g.intent
149
+ a.addEventListener('click', function (ev) { ev.preventDefault(); focusGroup(key(c, g.id), false) })
150
+ tocList.appendChild(add(li, a))
151
+ })
152
+ add(main, groupsEl)
153
+
154
+ var h = multi ? 'h3' : 'h2'
155
+ if (c.unannotated.length) {
156
+ var unId = 'unannotated' + (multi ? '-' + c.sha.slice(0, 7) : '')
157
+ var un = add(el('section', 'section'), el(h, null, (c.glossed ? 'Unannotated changes' : 'Changes') + ' (' + c.unannotated.length + ')'))
158
+ var box = add(el('div', 'unannotated'), el('p', null, c.glossed
159
+ ? 'These changes are in the commit but no group explains them.'
160
+ : 'This commit has no gloss, so nothing explains these changes.'))
161
+ c.unannotated.forEach(function (hk) { box.appendChild(hunkView(c, hk)) })
162
+ un.id = unId
163
+ add(main, add(un, box))
164
+ var ua = add(el('a'), el('span', null, c.glossed ? 'unannotated' : 'unglossed diff'))
165
+ ua.href = '#' + unId
166
+ tocList.appendChild(add(el('li'), ua))
167
+ }
168
+ if (c.risks.length) {
169
+ var rl = el('ul', 'list')
170
+ c.risks.forEach(function (r) { rl.appendChild(el('li', null, r)) })
171
+ add(main, add(el('section', 'section'), el(h, null, 'Risks'), rl))
172
+ }
173
+ if (c.alternatives.length) {
174
+ var al = el('ul', 'list')
175
+ c.alternatives.forEach(function (x) {
176
+ al.appendChild(add(el('li'), el('strong', null, x.option), el('span', 'why', ' — ' + x.rejected_because)))
177
+ })
178
+ add(main, add(el('section', 'section'), el(h, null, 'Alternatives considered'), al))
179
+ }
180
+ return tocList
181
+ }
182
+
183
+ function render() {
184
+ var layout = el('div', 'layout')
185
+ var toc = el('nav', 'toc')
186
+ var main = el('main')
187
+ add(layout, toc, main)
188
+ var allGroups = []
189
+ data.commits.forEach(function (c) { allGroups = allGroups.concat(c.groups) })
190
+
191
+ // Header: the commit itself, or the range and its commits.
192
+ if (multi) {
193
+ var glossed = data.commits.filter(function (c) { return c.glossed }).length
194
+ add(main, add(el('div', 'commit'), el('span', 'sha', data.range),
195
+ el('span', null, plural(data.commits.length, 'commit') + ', ' + glossed + ' glossed')))
196
+ add(main, el('h1', null, 'Branch ' + data.range))
197
+ var list = el('ol', 'commit-list')
198
+ data.commits.forEach(function (c) {
199
+ var a = add(el('a'), el('span', 'sha', c.sha.slice(0, 7)), document.createTextNode(' ' + c.subject))
200
+ a.href = '#c-' + c.sha.slice(0, 7)
201
+ var li = add(el('li'), a)
202
+ if (!c.glossed) add(li, el('span', 'stale', 'no gloss'))
203
+ else if (c.warnings.length) add(li, el('span', 'stale', 'stale gloss'))
204
+ list.appendChild(li)
205
+ })
206
+ data.merges.forEach(function (m) {
207
+ list.appendChild(add(el('li'), el('span', 'sha', m.sha.slice(0, 7)), document.createTextNode(' ' + m.subject),
208
+ el('span', 'stale', 'merge, not reviewed')))
209
+ })
210
+ add(main, list)
211
+ } else {
212
+ var only = data.commits[0]
213
+ add(main, commitMeta(only), el('h1', null, only.subject))
214
+ statement(main, 'Task', only.task)
215
+ statement(main, 'Summary', only.summary)
216
+ only.warnings.forEach(function (w) { add(main, el('div', 'warning', '⚠ ' + w)) })
217
+ }
218
+
219
+ // Necessity overview across every commit: bar weighted by changed lines,
220
+ // chips filter groups.
221
+ var present = data.necessity.filter(function (n) { return allGroups.some(function (g) { return g.necessity === n.name }) })
222
+ var total = allGroups.reduce(function (s, g) { return s + g.weight }, 0) || 1
223
+ var bar = el('div', 'bar')
224
+ var chips = el('div', 'chips')
225
+ present.forEach(function (n) {
226
+ var groups = allGroups.filter(function (g) { return g.necessity === n.name })
227
+ var weight = groups.reduce(function (s, g) { return s + g.weight }, 0)
228
+ var seg = color(el('span'), n.name)
229
+ seg.style.width = (100 * weight / total) + '%'
230
+ seg.title = n.name + ': ' + Math.round(100 * weight / total) + '% of changed lines'
231
+ bar.appendChild(seg)
232
+
233
+ var chip = color(el('button', 'chip'), n.name)
234
+ chip.type = 'button'
235
+ chip.title = n.help + ' (click to show or hide)'
236
+ chip.setAttribute('aria-pressed', 'true')
237
+ add(chip, el('span', 'dot'), el('span', null, n.name), el('span', 'count', groups.length))
238
+ chip.dataset.necessity = n.name
239
+ chip.addEventListener('click', function () {
240
+ if (hidden.has(n.name)) hidden.delete(n.name); else hidden.add(n.name)
241
+ chip.setAttribute('aria-pressed', String(!hidden.has(n.name)))
242
+ applyFilter()
243
+ })
244
+ chips.appendChild(chip)
245
+ })
246
+ add(main, add(el('section', 'overview'), bar, chips,
247
+ el('div', 'note', 'Bar shows each category’s share of changed lines' + (multi ? ' across the range' : '') +
248
+ '. Click a category to hide or show its groups.')))
249
+
250
+ // Toolbar
251
+ var shown = el('span', 'shown')
252
+ var expand = el('button', 'btn', 'Expand all code')
253
+ var collapse = el('button', 'btn', 'Collapse all')
254
+ expand.type = collapse.type = 'button'
255
+ expand.addEventListener('click', function () { setAll(true) })
256
+ collapse.addEventListener('click', function () { setAll(false) })
257
+ add(main, add(el('div', 'toolbar'), shown, add(el('span'), expand, document.createTextNode(' '), collapse)))
258
+
259
+ // Commits, each with its groups; the sidebar groups them by commit.
260
+ data.commits.forEach(function (c) {
261
+ var target = main
262
+ if (multi) {
263
+ target = el('section', 'commit-section')
264
+ target.id = 'c-' + c.sha.slice(0, 7)
265
+ add(target, commitMeta(c), el('h2', null, c.subject))
266
+ statement(target, 'Task', c.task)
267
+ statement(target, 'Summary', c.summary)
268
+ c.warnings.forEach(function (w) { add(target, el('div', 'warning', '⚠ ' + w)) })
269
+ add(main, target)
270
+ }
271
+ var tocList = commitBody(c, target)
272
+ var heading = multi ? c.sha.slice(0, 7) + ' ' + c.subject : 'Groups'
273
+ var tocHead = el('h2', multi ? 'commit-head' : null, heading)
274
+ if (multi) tocHead.title = c.subject
275
+ add(toc, tocHead, tocList)
276
+ })
277
+
278
+ var foot = el('footer')
279
+ add(foot, document.createTextNode('Keys: '), el('kbd', null, 'j'), document.createTextNode(' / '), el('kbd', null, 'k'),
280
+ document.createTextNode(' next / previous group · '), el('kbd', null, 'o'), document.createTextNode(' show or hide code · '),
281
+ el('kbd', null, 'e'), document.createTextNode(' expand all · '), el('kbd', null, 'c'),
282
+ document.createTextNode(' collapse all.'))
283
+ add(foot, el('p', null, 'A snapshot generated by git-gloss ' + new Date(data.generated).toLocaleString() +
284
+ '. It does not update if the gloss or commit changes; regenerate with git gloss html ' +
285
+ (data.range || data.commits[0].sha.slice(0, 10)) + '.'))
286
+ add(main, foot)
287
+
288
+ app.textContent = ''
289
+ app.appendChild(layout)
290
+
291
+ function applyFilter() {
292
+ var visible = 0
293
+ cards.forEach(function (c) {
294
+ var off = hidden.has(c.dataset.necessity)
295
+ c.classList.toggle('hidden', off)
296
+ if (!off) visible++
297
+ })
298
+ toc.querySelectorAll('li[data-necessity]').forEach(function (li) { li.classList.toggle('hidden', hidden.has(li.dataset.necessity)) })
299
+ shown.textContent = visible === cards.length
300
+ ? plural(cards.length, 'group')
301
+ : 'Showing ' + visible + ' of ' + plural(cards.length, 'group')
302
+ }
303
+ applyFilter()
304
+ showNecessity = function (n) {
305
+ hidden.delete(n)
306
+ chips.querySelectorAll('.chip').forEach(function (c) { if (c.dataset.necessity === n) c.setAttribute('aria-pressed', 'true') })
307
+ applyFilter()
308
+ }
309
+ }
310
+ var showNecessity = function () {}
311
+
312
+ function setAll(open) {
313
+ cards.forEach(function (c) { if (c.setOpen && !c.classList.contains('hidden')) c.setOpen(open) })
314
+ }
315
+
316
+ var current = -1
317
+ function focusGroup(k, openCode, instant) {
318
+ var i = cards.findIndex(function (c) { return c.id === 'g-' + k })
319
+ if (i < 0) return
320
+ var card = cards[i]
321
+ // Following a link to a filtered-out group turns its category back on.
322
+ if (card.classList.contains('hidden')) showNecessity(card.dataset.necessity)
323
+ cards.forEach(function (c) { c.classList.remove('focused') })
324
+ card.classList.add('focused')
325
+ current = i
326
+ if (openCode && card.setOpen) card.setOpen(true)
327
+ card.scrollIntoView({ block: 'start', behavior: instant ? 'auto' : 'smooth' })
328
+ document.querySelectorAll('.toc li').forEach(function (li) { li.classList.toggle('active', li.dataset.key === k) })
329
+ history.replaceState(null, '', '#g-' + k)
330
+ }
331
+ function step(dir) {
332
+ var visible = cards.filter(function (c) { return !c.classList.contains('hidden') })
333
+ if (!visible.length) return
334
+ var pos = visible.indexOf(cards[current])
335
+ var next = visible[Math.min(visible.length - 1, Math.max(0, pos + dir))] || visible[0]
336
+ focusGroup(next.id.slice(2), false)
337
+ }
338
+
339
+ document.addEventListener('keydown', function (ev) {
340
+ if (ev.metaKey || ev.ctrlKey || ev.altKey) return
341
+ var t = ev.target
342
+ if (t && (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable)) return
343
+ if (ev.key === 'j') step(1)
344
+ else if (ev.key === 'k') step(-1)
345
+ else if (ev.key === 'o' && cards[current] && cards[current].setOpen) cards[current].setOpen(!cards[current].isOpen())
346
+ else if (ev.key === 'e') setAll(true)
347
+ else if (ev.key === 'c') setAll(false)
348
+ })
349
+
350
+ render()
351
+ if (location.hash.indexOf('#g-') === 0) {
352
+ try { focusGroup(decodeURIComponent(location.hash.slice(3)), true, true) } catch (e) { /* malformed hash */ }
353
+ }
354
+ })()