@ok-ggwp/git-gloss 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,7 @@ git gloss show [<commit>|<range>] summary: one line per group, alternatives,
23
23
  --diff ...every group's details and code
24
24
  --only speculative,incidental ...just the parts you might cut
25
25
  git gloss html [<commit>|<range>] self-contained HTML view, of one commit or a branch (--open, -o <file>)
26
+ git gloss ary [--port <n>] [--no-open] browse branches, commits and wip in a local web page (see "Browsing")
26
27
  git gloss why <file>:<line>[-<end>] explain why code exists (via blame), looking past mechanical edits
27
28
  --history ...every commit that changed the lines, newest first
28
29
  --json ...for agents, with risks and rejected alternatives
@@ -63,10 +64,25 @@ npm install -D @ok-ggwp/git-gloss
63
64
  npx git-gloss init --hooks
64
65
  ```
65
66
 
66
- The hooks run `git gloss`, so it must be on PATH wherever git runs; with a project install, that means running git from a shell where `node_modules/.bin` is on PATH, or installing it globally as well.
67
+ The hooks run `git gloss`, so it must be on PATH wherever git runs. A project install isn't, by default: a GUI git client, or a shell that didn't run `pnpm`/`npm` scripts, won't find it. See [Project installs](#project-installs) for how to keep the team on one version anyway.
67
68
 
68
69
  To work on git-gloss itself: clone it, `npm install`, `npm link`, `npm test`. From a checkout, on Node 22.18+, the TypeScript source runs directly; on older versions the launcher runs `dist/`, which `npm install` builds (and `npm run build` rebuilds after changes). The tests need Node 23.6+.
69
70
 
71
+ ### Project installs
72
+
73
+ A project install (`npm install -D`) keeps every teammate on the same version, which matters because glosses and reviews are shared git notes that each copy reads and writes. The cost is that `git gloss` is then on PATH only inside a shell that has `node_modules/.bin` on it. Two ways to deal with that:
74
+
75
+ - **Put it on PATH in the hooks.** Add one line near the top of each hook that calls `git gloss` (`pre-push`, `post-commit`, `post-rewrite`), with the hooks kept in a tracked folder (for example, `.githooks/`, set with `git config core.hooksPath .githooks`):
76
+
77
+ ```sh
78
+ PATH="$PWD/node_modules/.bin:$PATH"
79
+ ```
80
+
81
+ Git runs hooks from the top of the working tree, so `$PWD` is that directory. In a monorepo where the install lives in a subfolder (`web/node_modules/.bin`), use that path instead. On Windows, use `$PWD` rather than `git rev-parse --show-toplevel`, which returns a `C:/…` path that Git for Windows' shell can't put on PATH.
82
+ - **Run the setup from `prepare`.** A `prepare` script in the package that owns `node_modules` can set `core.hooksPath` and run `git gloss init --hooks` on every install, so nobody has to remember it in each clone. Skip it in CI, and make it never fail the install. Note that pnpm doesn't run `prepare` when `node_modules` is already up to date, so an existing clone needs `pnpm run prepare` once.
83
+
84
+ Either way, the hooks never block a commit or push. Without `git gloss` on PATH they print an error and glosses aren't adopted, carried or synced, so check that `git gloss init` prints no warning after changing it. The Claude Code skill can run the tool through the project install too; see "Calling git-gloss" in the skill.
85
+
70
86
  ### Setting up a repo
71
87
 
72
88
  In each repo you use it in:
@@ -149,6 +165,12 @@ Give a range (`git gloss html main..feature --open`) for a branch or PR view: ev
149
165
 
150
166
  All gloss and diff text is rendered as text, and a Content-Security-Policy with a per-page nonce blocks any script the page didn't generate.
151
167
 
168
+ ## Browsing
169
+
170
+ `git gloss ary` starts a small web server and opens it in your browser, so you can explore glosses without working out commits, branches and ranges on the command line. Pick a branch and a branch to compare it with (the repo's `main` or `master` by default); each picker has a search box, for repos with many branches. Then click a commit, or "Whole branch" for the range view. Each commit shows its subject (up to two lines) and whether it has a gloss and a review, and a search box above the list finds commits by message or author (case-insensitive, plain text). Searching covers the whole selected branch, not only the commits loaded so far. Your uncommitted work appears at the top once it has been snapshotted (`git gloss hunks wip`). To look at anything else, such as `HEAD~3` or `release..main`, type it into the revision box. As everywhere in git-gloss, `wip` there means the snapshot of uncommitted work, so to view a branch or tag called `wip`, type `heads/wip` or `tags/wip`. Each selection is the page `git gloss html` writes, regenerated on every load, so Reload picks up new glosses and reviews. The address bar keeps the selection, so a reload returns to it (with a random port, a bookmark lasts only until you stop the server; pass `--port` for a stable address).
171
+
172
+ It serves one repository, the one you run it in, until you press Ctrl-C. It listens on 127.0.0.1 only, on a free port (or `--port`), only answers requests addressed to that host name, and never writes anything: it doesn't take snapshots or change notes. A page shows at most 200 commits; pick a narrower range for more. `--no-open` doesn't launch a browser.
173
+
152
174
  ## Caveats
153
175
 
154
176
  - Carrying a gloss onto a server-side squash needs the original commits in your repository; usually the author has them. If a squash merge resolved conflicts or changed much of the code, it may not reach the 80% match; run `git gloss carry <branch commits> --to <squash>`.
@@ -0,0 +1,80 @@
1
+ import { basename } from 'node:path';
2
+ import { NOTES_REF, WIP_NOTES_REF, WIP_REF, git, gitTry, wipStaleness } from '../git.js';
3
+ import { REVIEWS_REF } from '../reviews.js';
4
+ // The commits a notes ref has notes on ("<note> <commit>" per line). A missing
5
+ // ref is no notes.
6
+ function notedCommits(ref) {
7
+ const lines = (gitTry(['notes', '--ref', ref, 'list']) ?? '').split('\n');
8
+ return new Set(lines.filter(Boolean).map((l) => l.split(' ')[1]));
9
+ }
10
+ function refList(namespace) {
11
+ return git(['for-each-ref', '--sort=-committerdate', '--format=%(refname)%00%(refname:short)%00%(committerdate:relative)%00%(subject)', namespace])
12
+ .split('\n')
13
+ .filter(Boolean)
14
+ .map((l) => l.split('\0'))
15
+ // refs/remotes/<remote>/HEAD is a pointer to another branch, not a branch.
16
+ .filter(([full]) => !full.endsWith('/HEAD'))
17
+ .map(([, name, date, subject]) => ({ name, date, subject }));
18
+ }
19
+ export function listRefs() {
20
+ const branches = refList('refs/heads');
21
+ const remotes = refList('refs/remotes');
22
+ const names = new Set([...branches, ...remotes].map((b) => b.name));
23
+ const wipSha = gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim();
24
+ let stale = null;
25
+ if (wipSha) {
26
+ try {
27
+ stale = wipStaleness(wipSha);
28
+ }
29
+ catch {
30
+ // Staleness is advice; the snapshot is still viewable without it.
31
+ }
32
+ }
33
+ return {
34
+ repo: basename(git(['rev-parse', '--show-toplevel']).trim()),
35
+ current: gitTry(['symbolic-ref', '--short', '--quiet', 'HEAD'])?.trim() || null,
36
+ branches,
37
+ remotes,
38
+ defaultBase: ['main', 'master', 'origin/main', 'origin/master'].find((n) => names.has(n)) ?? null,
39
+ wip: wipSha ? { sha: wipSha, glossed: notedCommits(WIP_NOTES_REF).has(wipSha), stale } : null,
40
+ };
41
+ }
42
+ // Newest first: the commits of `head`, or only those not in `base` when given.
43
+ // `query` keeps the commits whose message or author contains it (any case).
44
+ export function listLog(head, base, limit, skip, query = '') {
45
+ const range = base ? `${base}..${head}` : head;
46
+ const format = '--format=%H%x00%an%x00%aI%x00%s%x00%P';
47
+ let out;
48
+ if (query) {
49
+ // git ANDs --grep with --author, so ask for each on its own and merge, then
50
+ // list the union by commit date (what --no-walk=sorted gives, which can
51
+ // differ from the order of an unfiltered log when dates are skewed).
52
+ const shas = new Set();
53
+ for (const filter of ['--grep', '--author']) {
54
+ const found = git(['log', '--format=%H', '--regexp-ignore-case', '--fixed-strings', `${filter}=${query}`, '--end-of-options', range, '--']);
55
+ for (const sha of found.split('\n').filter(Boolean))
56
+ shas.add(sha);
57
+ }
58
+ // No -n or --skip here: git then walks on to the parents, so page by hand.
59
+ out = shas.size
60
+ ? git(['log', '--no-walk=sorted', '--stdin', format], [...shas].join('\n') + '\n').split('\n').slice(skip, skip + limit + 1).join('\n')
61
+ : '';
62
+ }
63
+ else {
64
+ // One more than asked for, to know whether there are more.
65
+ out = git(['log', format, '-n', String(limit + 1), '--skip', String(skip), '--end-of-options', range, '--']);
66
+ }
67
+ const glossed = notedCommits(NOTES_REF);
68
+ const reviewed = notedCommits(REVIEWS_REF);
69
+ const rows = out.split('\n').filter(Boolean).map((l) => l.split('\0'));
70
+ return {
71
+ commits: rows.slice(0, limit).map(([sha, author, date, subject, parents]) => ({
72
+ sha, author, date, subject,
73
+ merge: parents.includes(' '),
74
+ glossed: glossed.has(sha),
75
+ reviewed: reviewed.has(sha),
76
+ })),
77
+ more: rows.length > limit,
78
+ total: base && !query ? Number(git(['rev-list', '--count', '--end-of-options', range, '--']).trim()) : null,
79
+ };
80
+ }
@@ -0,0 +1,401 @@
1
+ // The ary shell: pick a branch, a base to compare it with, and a commit (or the
2
+ // whole branch), and the chosen view loads in the frame. Text only goes
3
+ // through textContent; nothing from the repo is parsed as HTML.
4
+ (function () {
5
+ 'use strict'
6
+ var app = document.getElementById('app')
7
+ var PAGE = 100
8
+
9
+ // What is selected. head/base choose which commits are listed; rev is what
10
+ // the frame shows (a sha, "wip", a range, or anything typed in).
11
+ var state = { head: null, base: null, rev: null, q: '' }
12
+ var refs = null
13
+ var log = { commits: [], more: false, total: null }
14
+ var logError = null
15
+ var parts = {}
16
+
17
+ function el(tag, cls, text) {
18
+ var e = document.createElement(tag)
19
+ if (cls) e.className = cls
20
+ if (text !== undefined && text !== null) e.textContent = String(text)
21
+ return e
22
+ }
23
+ function add(parent) {
24
+ for (var i = 1; i < arguments.length; i++) if (arguments[i]) parent.appendChild(arguments[i])
25
+ return parent
26
+ }
27
+ function plural(n, word) { return n + ' ' + word + (n === 1 ? '' : 's') }
28
+ function api(path) {
29
+ return fetch(path).then(function (r) {
30
+ return r.json().then(function (body) {
31
+ if (!r.ok) throw new Error(body.error || r.statusText)
32
+ return body
33
+ })
34
+ })
35
+ }
36
+
37
+ // ---- location: the selection is kept in the hash, so reload and links work ----
38
+ function saveHash() {
39
+ var p = new URLSearchParams()
40
+ if (state.rev) p.set('rev', state.rev)
41
+ if (state.head) p.set('head', state.head)
42
+ if (state.base) p.set('base', state.base)
43
+ history.replaceState(null, '', '#' + p.toString())
44
+ }
45
+ function readHash() {
46
+ var p = new URLSearchParams(location.hash.slice(1))
47
+ return { rev: p.get('rev'), head: p.get('head'), base: p.get('base') }
48
+ }
49
+
50
+ // ---- sidebar ----
51
+ // A branch picker with a search box: a repo can have hundreds of branches,
52
+ // too many to scan in a <select>. Matching is case-insensitive and every
53
+ // space-separated word must appear in the name.
54
+ var SHOWN = 200
55
+ function picker(onPick) {
56
+ var root = el('div', 'combo')
57
+ var button = el('button', 'combo-btn')
58
+ button.type = 'button'
59
+ button.setAttribute('aria-haspopup', 'listbox')
60
+ var panel = el('div', 'combo-panel')
61
+ panel.hidden = true
62
+ var input = el('input')
63
+ input.type = 'search'
64
+ input.spellcheck = false
65
+ input.placeholder = 'Search branches…'
66
+ input.setAttribute('aria-label', 'Search branches')
67
+ var list = el('div', 'combo-list')
68
+ list.setAttribute('role', 'listbox')
69
+ add(panel, input, list)
70
+ add(root, button, panel)
71
+
72
+ var groups = []
73
+ var noneLabel = null
74
+ var value = null
75
+ var items = [] // the rows now shown: { value, node }
76
+ var active = 0
77
+
78
+ function matches(name, words) {
79
+ var n = name.toLowerCase()
80
+ return words.every(function (w) { return n.indexOf(w) >= 0 })
81
+ }
82
+ function paint() {
83
+ items.forEach(function (it, i) { it.node.classList.toggle('active', i === active) })
84
+ if (items[active]) items[active].node.scrollIntoView({ block: 'nearest' })
85
+ }
86
+ function render() {
87
+ var words = input.value.toLowerCase().split(/\s+/).filter(Boolean)
88
+ list.textContent = ''
89
+ items = []
90
+ var shown = 0
91
+ var hidden = 0
92
+ function item(label, v) {
93
+ var node = el('button', 'combo-item', label)
94
+ node.type = 'button'
95
+ node.setAttribute('role', 'option')
96
+ node.setAttribute('aria-selected', String(v === value))
97
+ node.addEventListener('click', function () { pick(v) })
98
+ items.push({ value: v, node: node })
99
+ return node
100
+ }
101
+ if (noneLabel && !words.length) list.appendChild(item(noneLabel, null))
102
+ groups.forEach(function (g) {
103
+ var found = g.names.filter(function (n) { return matches(n, words) })
104
+ var room = found.slice(0, Math.max(0, SHOWN - shown))
105
+ hidden += found.length - room.length
106
+ shown += room.length
107
+ if (!room.length) return
108
+ add(list, add(el('div', 'combo-group', g.label), el('span', 'count', found.length)))
109
+ room.forEach(function (n) { list.appendChild(item(n, n)) })
110
+ })
111
+ if (hidden) add(list, el('div', 'combo-more', hidden + ' more: keep typing to narrow the list'))
112
+ if (!items.length) add(list, el('div', 'combo-more', 'No branches match.'))
113
+ // Start on the current value, or on the first match when searching.
114
+ var at = words.length ? -1 : items.findIndex(function (it) { return it.value === value })
115
+ active = at >= 0 ? at : 0
116
+ paint()
117
+ }
118
+ function open() {
119
+ panel.hidden = false
120
+ button.setAttribute('aria-expanded', 'true')
121
+ input.value = ''
122
+ render()
123
+ input.focus()
124
+ }
125
+ function close() {
126
+ panel.hidden = true
127
+ button.setAttribute('aria-expanded', 'false')
128
+ }
129
+ function pick(v) {
130
+ close()
131
+ button.focus()
132
+ if (v !== value) onPick(v)
133
+ }
134
+ button.addEventListener('click', function () { if (panel.hidden) open(); else close() })
135
+ input.addEventListener('input', render)
136
+ input.addEventListener('keydown', function (ev) {
137
+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
138
+ ev.preventDefault()
139
+ if (items.length) active = (active + (ev.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length
140
+ paint()
141
+ } else if (ev.key === 'Enter') {
142
+ ev.preventDefault()
143
+ if (items[active]) pick(items[active].value)
144
+ } else if (ev.key === 'Escape') {
145
+ ev.preventDefault()
146
+ close()
147
+ button.focus()
148
+ }
149
+ })
150
+ document.addEventListener('mousedown', function (ev) { if (!panel.hidden && !root.contains(ev.target)) close() })
151
+
152
+ return {
153
+ root: root,
154
+ // Local and remote branches, and what choosing "no branch" is called (if allowed).
155
+ setOptions: function (g, none) { groups = g; noneLabel = none },
156
+ setValue: function (v) {
157
+ value = v
158
+ button.textContent = v || noneLabel || 'choose a branch'
159
+ button.title = button.textContent
160
+ },
161
+ }
162
+ }
163
+
164
+ function branchGroups() {
165
+ return [
166
+ { label: 'Local branches', names: refs.branches.map(function (b) { return b.name }) },
167
+ { label: 'Remote branches', names: refs.remotes.map(function (b) { return b.name }) },
168
+ ]
169
+ }
170
+
171
+ // One entry in the list: the title gets the full width (two lines at most),
172
+ // with the details and status marks on a line below it.
173
+ function row(title, details, marks, current, onClick) {
174
+ var b = el('button', 'row')
175
+ b.type = 'button'
176
+ b.setAttribute('aria-current', String(current))
177
+ b.title = title
178
+ add(b, el('div', 'subject', title))
179
+ var meta = el('div', 'meta')
180
+ details.forEach(function (d) { meta.appendChild(d) })
181
+ add(meta, add(el('span', 'marks'), marks[0], marks[1], marks[2]))
182
+ add(b, meta)
183
+ b.addEventListener('click', onClick)
184
+ return b
185
+ }
186
+ function mark(kind, text, tip) {
187
+ var m = el('span', 'mark ' + kind, text)
188
+ if (tip) m.title = tip
189
+ return m
190
+ }
191
+ function glossMark(glossed) {
192
+ return glossed ? mark('glossed', '● glossed', 'This commit has a gloss') : mark('none', '○ no gloss', 'This commit has no gloss')
193
+ }
194
+
195
+ function renderList() {
196
+ var list = parts.list
197
+ list.textContent = ''
198
+ if (refs.wip) {
199
+ add(list, el('h2', null, 'Uncommitted work'))
200
+ var w = refs.wip
201
+ add(list, row('wip snapshot', [el('span', null, w.stale || 'the latest snapshot of your working tree')],
202
+ [glossMark(w.glossed), w.stale ? mark('warn', 'stale', w.stale) : null], state.rev === 'wip', function () { select('wip') }))
203
+ }
204
+ add(list, el('h2', null, state.head ? 'Commits on ' + state.head : 'Commits'))
205
+ if (logError) { add(list, el('p', 'error', logError)); return }
206
+ // A search narrows the list to some commits, so it isn't the whole branch.
207
+ if (state.base && state.head && log.commits.length && !state.q) {
208
+ var range = state.base + '..' + state.head
209
+ add(list, row('Whole branch', [el('span', null, range + ' · ' + plural(log.total === null ? log.commits.length : log.total, 'commit'))], [], state.rev === range, function () { select(range) }))
210
+ }
211
+ if (!log.commits.length) {
212
+ add(list, el('p', 'empty', state.q ? 'No commits match “' + state.q + '”.' : state.base ? 'No commits beyond ' + state.base + '.' : 'No commits.'))
213
+ }
214
+ log.commits.forEach(function (c) {
215
+ add(list, row(c.subject,
216
+ [el('span', 'sha', c.sha.slice(0, 7)), el('span', null, ' · ' + c.author + ' · ' + new Date(c.date).toLocaleDateString())],
217
+ c.merge ? [mark('none', 'merge')] : [glossMark(c.glossed), c.reviewed ? mark('reviewed', '✓ reviewed', 'This commit has a recorded review') : null],
218
+ state.rev === c.sha, function () { select(c.sha) }))
219
+ })
220
+ if (log.more) {
221
+ var more = el('button', 'btn more', 'Load more')
222
+ more.type = 'button'
223
+ more.addEventListener('click', function () { loadLog(true) })
224
+ add(list, more)
225
+ }
226
+ }
227
+
228
+ function renderSide() {
229
+ parts.head.setOptions(branchGroups(), null)
230
+ parts.head.setValue(state.head)
231
+ parts.base.setOptions(branchGroups(), '(none: all of the branch’s history)')
232
+ parts.base.setValue(state.base)
233
+ parts.rev.value = state.rev && state.rev !== 'wip' ? state.rev : ''
234
+ renderList()
235
+ }
236
+
237
+ // ---- viewer ----
238
+ function renderViewer() {
239
+ var v = parts.viewer
240
+ v.textContent = ''
241
+ if (!state.rev) {
242
+ add(v, el('div', 'placeholder', 'Pick a commit, a whole branch, or type any revision or range.'))
243
+ return
244
+ }
245
+ var src = '/view?rev=' + encodeURIComponent(state.rev)
246
+ var open = el('a', null, 'open in a tab ↗')
247
+ open.href = src
248
+ open.target = '_blank'
249
+ open.rel = 'noopener'
250
+ var reload = el('button', 'btn', 'Reload')
251
+ reload.type = 'button'
252
+ var frame = el('iframe')
253
+ frame.title = 'gloss of ' + state.rev
254
+ frame.src = src
255
+ // So the page's keys (j, k, o, e, c) work without clicking into it first.
256
+ frame.addEventListener('load', function () { frame.contentWindow.focus() })
257
+ reload.addEventListener('click', function () { frame.src = src })
258
+ add(v, add(el('div', 'viewer-bar'), el('span', 'what', state.rev === 'wip' ? 'uncommitted work (wip)' : state.rev), reload, open), frame)
259
+ }
260
+
261
+ // ---- actions ----
262
+ function select(rev) {
263
+ state.rev = rev
264
+ saveHash()
265
+ renderList()
266
+ parts.rev.value = rev === 'wip' ? '' : rev
267
+ renderViewer()
268
+ }
269
+
270
+ function loadLog(append) {
271
+ if (!state.head) { log = { commits: [], more: false, total: null }; renderList(); return Promise.resolve() }
272
+ var q = new URLSearchParams({ head: state.head, limit: PAGE, skip: append ? log.commits.length : 0 })
273
+ if (state.base) q.set('base', state.base)
274
+ if (state.q) q.set('q', state.q)
275
+ var asked = state.head + '..' + state.base + '?' + state.q
276
+ return api('/api/log?' + q).then(function (data) {
277
+ if (asked !== state.head + '..' + state.base + '?' + state.q) return // the selection moved on
278
+ logError = null
279
+ log = append ? { commits: log.commits.concat(data.commits), more: data.more, total: data.total } : data
280
+ renderList()
281
+ }).catch(function (err) {
282
+ logError = err.message
283
+ log = { commits: [], more: false, total: null }
284
+ renderList()
285
+ })
286
+ }
287
+
288
+ function pickHead(name) {
289
+ state.head = name
290
+ // A feature branch is most useful against the usual base; the base itself
291
+ // is listed whole.
292
+ state.base = name === refs.defaultBase ? null : refs.defaultBase
293
+ saveHash()
294
+ renderSide()
295
+ loadLog(false)
296
+ }
297
+
298
+ function load() {
299
+ return api('/api/refs').then(function (data) {
300
+ parts.error.hidden = true
301
+ refs = data
302
+ var h = readHash()
303
+ // A link or bookmark may name a branch that has since been deleted or
304
+ // renamed: use the usual choice instead, and say so.
305
+ var known = {}
306
+ refs.branches.concat(refs.remotes).forEach(function (b) { known[b.name] = true })
307
+ var head = h.head && known[h.head] ? h.head : null
308
+ state.head = head || refs.current || refs.defaultBase || (refs.branches[0] && refs.branches[0].name) || null
309
+ var notice = null
310
+ if (h.head && !head) {
311
+ // The default base goes with the default branch.
312
+ state.base = state.head === refs.defaultBase ? null : refs.defaultBase
313
+ notice = 'Branch “' + h.head + '” no longer exists; showing ' + (state.head || 'the default') + ' instead.'
314
+ } else if (h.base && !known[h.base]) {
315
+ state.base = null
316
+ notice = 'The branch to compare with, “' + h.base + '”, no longer exists; showing all of ' + state.head + '’s history.'
317
+ } else if (head) {
318
+ state.base = h.base || null
319
+ } else {
320
+ state.base = state.head === refs.defaultBase ? null : refs.defaultBase
321
+ }
322
+ if (notice) {
323
+ parts.error.textContent = notice
324
+ parts.error.hidden = false
325
+ }
326
+ state.rev = h.rev || (refs.wip ? 'wip' : null)
327
+ parts.title.textContent = refs.repo
328
+ renderSide()
329
+ renderViewer()
330
+ return loadLog(false).then(function () {
331
+ // With nothing chosen, show the whole branch, or its newest commit.
332
+ if (!state.rev && state.head) select(state.base && log.commits.length ? state.base + '..' + state.head : log.commits.length ? log.commits[0].sha : null)
333
+ })
334
+ })
335
+ }
336
+
337
+ function build() {
338
+ parts.title = el('span', 'repo')
339
+ var refresh = el('button', 'btn', 'Refresh')
340
+ refresh.type = 'button'
341
+ refresh.title = 'Re-read branches and commits'
342
+ refresh.addEventListener('click', function () { load().catch(fail) })
343
+
344
+ parts.head = picker(pickHead)
345
+ parts.base = picker(function (name) {
346
+ state.base = name
347
+ parts.base.setValue(name)
348
+ saveHash()
349
+ loadLog(false)
350
+ })
351
+
352
+ parts.rev = el('input')
353
+ parts.rev.type = 'text'
354
+ parts.rev.spellcheck = false
355
+ parts.rev.placeholder = 'HEAD~3, abc123, main..feature'
356
+ var go = el('button', 'btn', 'View')
357
+ go.type = 'submit'
358
+ var form = add(el('form', 'rev-row'), parts.rev, go)
359
+ form.addEventListener('submit', function (ev) {
360
+ ev.preventDefault()
361
+ var v = parts.rev.value.trim()
362
+ if (v) select(v)
363
+ })
364
+
365
+ parts.search = el('input')
366
+ parts.search.type = 'search'
367
+ parts.search.spellcheck = false
368
+ parts.search.placeholder = 'Search commit messages and authors…'
369
+ parts.search.setAttribute('aria-label', 'Search commits')
370
+ var searching = null
371
+ parts.search.addEventListener('input', function () {
372
+ // Wait for a pause in typing: each search asks git.
373
+ clearTimeout(searching)
374
+ searching = setTimeout(function () {
375
+ state.q = parts.search.value.trim()
376
+ loadLog(false)
377
+ }, 250)
378
+ })
379
+
380
+ parts.error = el('p', 'error')
381
+ parts.error.hidden = true
382
+ parts.list = el('div', 'list')
383
+ parts.viewer = el('section', 'viewer')
384
+
385
+ var top = add(el('div', 'side-top'),
386
+ add(el('div', 'title'), el('strong', null, 'git gloss ary'), parts.title, refresh),
387
+ parts.error,
388
+ add(el('div', 'field'), el('span', null, 'Branch'), parts.head.root),
389
+ add(el('div', 'field'), el('span', null, 'Compare with'), parts.base.root),
390
+ add(el('label', 'field'), document.createTextNode('Any revision or range'), form))
391
+ add(app, add(el('div', 'shell'), add(el('aside', 'side'), top, add(el('div', 'search-bar'), parts.search), parts.list), parts.viewer))
392
+ }
393
+
394
+ function fail(err) {
395
+ parts.error.textContent = 'Could not read the repository: ' + err.message
396
+ parts.error.hidden = false
397
+ }
398
+
399
+ build()
400
+ load().catch(fail)
401
+ })()
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>git gloss ary</title>
7
+ <link rel="stylesheet" href="/ary.css">
8
+ </head>
9
+ <body>
10
+ <div id="app"><noscript>This page needs JavaScript.</noscript></div>
11
+ <script src="/ary.js"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,101 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createServer } from 'node:http';
3
+ import { GlossError, WIP_REF, gitTry, resolveCommits } from '../git.js';
4
+ import { buildPageData } from '../html/data.js';
5
+ import { escapeHtml, renderPage } from '../html/page.js';
6
+ import { listLog, listRefs } from './browse.js';
7
+ const asset = (name) => readFileSync(new URL(name, import.meta.url), 'utf8');
8
+ // A range page loads and lays out every commit in it, so a huge one would hang
9
+ // the browser. Narrowing it is a better answer than a spinner.
10
+ export const MAX_RANGE_COMMITS = 200;
11
+ // The shell page loads only its own script and style, and may frame only
12
+ // /view pages. Those carry their own policy: an inline script with a nonce.
13
+ const SHELL_CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-src 'self'; base-uri 'none'; form-action 'none'";
14
+ class BadRequest extends GlossError {
15
+ }
16
+ // A revision, branch or range typed by the user or taken from the sidebar. An
17
+ // option-looking one is refused so it can never be read by git as a flag.
18
+ function revParam(url, name, required = true) {
19
+ const v = url.searchParams.get(name);
20
+ if (!v) {
21
+ if (required)
22
+ throw new BadRequest(`missing ?${name}=`);
23
+ return null;
24
+ }
25
+ if (v.startsWith('-') || v.length > 500 || v.includes('\0'))
26
+ throw new BadRequest(`not a revision: ${v.slice(0, 80)}`);
27
+ return v;
28
+ }
29
+ const intParam = (url, name, fallback, min, max) => {
30
+ const n = Number(url.searchParams.get(name) ?? fallback);
31
+ return Number.isInteger(n) && n >= min ? Math.min(n, max) : fallback;
32
+ };
33
+ function send(res, status, type, body, headers = {}) {
34
+ res.writeHead(status, {
35
+ 'content-type': `${type}; charset=utf-8`,
36
+ // Everything reflects the repo as it is now.
37
+ 'cache-control': 'no-store',
38
+ 'x-content-type-options': 'nosniff',
39
+ 'referrer-policy': 'no-referrer',
40
+ ...headers,
41
+ });
42
+ res.end(body);
43
+ }
44
+ const sendJson = (res, data) => send(res, 200, 'application/json', JSON.stringify(data));
45
+ function viewPage(url) {
46
+ const rev = revParam(url, 'rev');
47
+ // "wip" is the latest snapshot of uncommitted work. The server never takes
48
+ // one (that is `git gloss hunks wip`): browsing doesn't change anything.
49
+ const target = rev === 'wip' ? gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim() : rev;
50
+ if (!target)
51
+ throw new GlossError('no snapshot of uncommitted work yet; run `git gloss hunks wip` first');
52
+ const { shas, merges, range } = resolveCommits(target);
53
+ if (shas.length > MAX_RANGE_COMMITS) {
54
+ throw new GlossError(`${rev} has ${shas.length} commits; a page shows at most ${MAX_RANGE_COMMITS}. Pick a narrower range.`);
55
+ }
56
+ return renderPage(buildPageData(shas, range, merges));
57
+ }
58
+ function route(req, res, url) {
59
+ const shell = (name, type) => send(res, 200, type, asset(name), type === 'text/html' ? { 'content-security-policy': SHELL_CSP } : {});
60
+ switch (url.pathname) {
61
+ case '/': return shell('./index.html', 'text/html');
62
+ case '/ary.js': return shell('./client.js', 'text/javascript');
63
+ case '/ary.css': return shell('./style.css', 'text/css');
64
+ case '/api/refs': return sendJson(res, listRefs());
65
+ case '/api/log':
66
+ return sendJson(res, listLog(revParam(url, 'head'), revParam(url, 'base', false), intParam(url, 'limit', 100, 1, 500), intParam(url, 'skip', 0, 0, 1e6), (url.searchParams.get('q') ?? '').slice(0, 200)));
67
+ case '/view':
68
+ // The page may only be framed by the shell, not by another site.
69
+ return send(res, 200, 'text/html', viewPage(url), { 'content-security-policy': "frame-ancestors 'self'" });
70
+ default: return send(res, 404, 'text/plain', 'not found');
71
+ }
72
+ }
73
+ export function createAry(port) {
74
+ return createServer((req, res) => {
75
+ // A web page on another site can make the browser send requests here, and
76
+ // with DNS rebinding even read the answers. Only our own origin's host
77
+ // names get through; the server is read-only besides.
78
+ const allowed = [`127.0.0.1:${port()}`, `localhost:${port()}`];
79
+ if (!allowed.includes((req.headers.host ?? '').toLowerCase()))
80
+ return send(res, 403, 'text/plain', `forbidden host: this server answers to ${allowed.join(' and ')} (it listens on this machine only; through an SSH tunnel, forward the same port number)`);
81
+ if (req.method !== 'GET' && req.method !== 'HEAD')
82
+ return send(res, 405, 'text/plain', 'read-only', { allow: 'GET, HEAD' });
83
+ const url = new URL(req.url ?? '/', 'http://localhost');
84
+ try {
85
+ route(req, res, url);
86
+ }
87
+ catch (err) {
88
+ if (!(err instanceof GlossError)) {
89
+ console.error(err);
90
+ return send(res, 500, 'text/plain', 'internal error');
91
+ }
92
+ const status = err instanceof BadRequest ? 400 : 422;
93
+ if (url.pathname === '/view') {
94
+ send(res, status, 'text/html', `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark"><pre style="white-space:pre-wrap;font:14px/1.5 system-ui;padding:16px">${escapeHtml(err.message)}</pre>`, { 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'" });
95
+ }
96
+ else {
97
+ send(res, status, 'application/json', JSON.stringify({ error: err.message }));
98
+ }
99
+ }
100
+ });
101
+ }
@@ -0,0 +1,115 @@
1
+ :root {
2
+ --bg: #f7f7f5;
3
+ --surface: #ffffff;
4
+ --surface-2: #f1f1ee;
5
+ --border: #e2e2dc;
6
+ --text: #1d1d1b;
7
+ --muted: #6b6b66;
8
+ --accent: #3b5bdb;
9
+ --ok: #1a7f37;
10
+ --warn-bg: #fff4e5;
11
+ --warn-border: #f0b35b;
12
+ --warn-text: #7a4300;
13
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
14
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
15
+ color-scheme: light;
16
+ }
17
+ @media (prefers-color-scheme: dark) {
18
+ :root {
19
+ --bg: #151514;
20
+ --surface: #1e1e1c;
21
+ --surface-2: #262624;
22
+ --border: #34342f;
23
+ --text: #e8e8e3;
24
+ --muted: #9a9a92;
25
+ --accent: #8ea2ff;
26
+ --ok: #57c47a;
27
+ --warn-bg: #33270f;
28
+ --warn-border: #a2701e;
29
+ --warn-text: #f3c77a;
30
+ color-scheme: dark;
31
+ }
32
+ }
33
+
34
+ * { box-sizing: border-box; }
35
+ html, body, #app { height: 100%; }
36
+ body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 var(--sans); }
37
+ button, input, select { font: inherit; color: inherit; }
38
+
39
+ .shell { display: grid; grid-template-columns: 340px minmax(0, 1fr); height: 100%; }
40
+ @media (max-width: 800px) {
41
+ .shell { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 45%) minmax(0, 1fr); }
42
+ }
43
+
44
+ /* Sidebar */
45
+ .side { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--border); background: var(--surface); }
46
+ .side-top { padding: 12px; border-bottom: 1px solid var(--border); display: grid; gap: 10px; }
47
+ .title { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
48
+ .title strong { font-size: 15px; }
49
+ .title .repo { color: var(--muted); font-family: var(--mono); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
50
+ label.field, div.field { display: grid; gap: 3px; color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; }
51
+ label.field > input, .rev-row input, .combo-btn, .combo-panel input {
52
+ width: 100%; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px;
53
+ background: var(--bg); text-transform: none; letter-spacing: 0; font-size: 13px;
54
+ }
55
+ .combo { position: relative; }
56
+ .combo-btn { text-align: left; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 24px; position: relative; }
57
+ .combo-btn::after { content: "▾"; position: absolute; right: 8px; color: var(--muted); }
58
+ .combo-panel {
59
+ position: absolute; z-index: 10; left: 0; right: 0; top: calc(100% + 4px);
60
+ display: grid; gap: 6px; padding: 6px; border: 1px solid var(--border); border-radius: 8px;
61
+ background: var(--surface); box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
62
+ }
63
+ .combo-panel[hidden] { display: none; }
64
+ .combo-list { max-height: min(50vh, 320px); overflow: auto; }
65
+ .combo-group {
66
+ margin-top: 8px; padding: 10px 8px 4px; border-top: 1px solid var(--border);
67
+ font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text);
68
+ }
69
+ .combo-group:first-child { margin-top: 0; padding-top: 4px; border-top: 0; }
70
+ .combo-group .count { margin-left: 6px; color: var(--muted); font-weight: 400; }
71
+ .combo-item { display: block; width: 100%; text-align: left; border: 0; background: none; border-radius: 5px; padding: 4px 8px 4px 20px; cursor: pointer; font-family: var(--mono); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
72
+ .combo-item:hover, .combo-item.active { background: var(--surface-2); }
73
+ .combo-item[aria-selected="true"] { color: var(--accent); font-weight: 600; }
74
+ .combo-more { padding: 6px 8px; color: var(--muted); font-size: 12.5px; }
75
+ .rev-row { display: flex; gap: 6px; }
76
+ .rev-row input { font-family: var(--mono); font-size: 12.5px; }
77
+ .btn {
78
+ border: 1px solid var(--border); background: var(--surface); border-radius: 6px;
79
+ padding: 4px 10px; cursor: pointer; white-space: nowrap;
80
+ }
81
+ .btn:hover { background: var(--surface-2); }
82
+ .error { margin: 0; padding: 8px 10px; border-radius: 6px; background: var(--warn-bg); border: 1px solid var(--warn-border); color: var(--warn-text); font-size: 12.5px; }
83
+
84
+ .list { flex: 1; min-height: 0; overflow: auto; padding: 6px; }
85
+ .list h2 { margin: 10px 8px 4px; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 600; }
86
+ .row {
87
+ display: block; width: 100%; text-align: left; cursor: pointer;
88
+ border: 1px solid transparent; background: none; border-radius: 6px; padding: 6px 8px;
89
+ }
90
+ .row:hover { background: var(--surface-2); }
91
+ .row[aria-current="true"] { background: var(--surface-2); border-color: var(--accent); }
92
+ .row .subject {
93
+ /* the whole point of the list: enough of the subject to tell commits apart */
94
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2;
95
+ overflow: hidden; overflow-wrap: anywhere;
96
+ }
97
+ .row .meta { display: flex; flex-wrap: wrap; align-items: baseline; margin-top: 2px; color: var(--muted); font-size: 12px; }
98
+ .row .marks { margin-left: auto; padding-left: 10px; display: inline-flex; gap: 10px; white-space: nowrap; }
99
+ .sha { font-family: var(--mono); font-size: 12px; }
100
+ .mark.glossed { color: var(--ok); }
101
+ .mark.reviewed { color: var(--accent); }
102
+ .mark.warn { color: var(--warn-text); background: var(--warn-bg); border-radius: 4px; padding: 0 5px; }
103
+ .search-bar { padding: 8px 12px; border-bottom: 1px solid var(--border); }
104
+ .search-bar input { width: 100%; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); font-size: 13px; }
105
+ .empty { color: var(--muted); padding: 8px; }
106
+ .more { margin: 6px 8px; }
107
+
108
+ /* Viewer */
109
+ .viewer { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
110
+ .viewer-bar { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--surface); }
111
+ .viewer-bar .what { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
112
+ .viewer-bar a { color: var(--accent); text-decoration: none; }
113
+ .viewer-bar a:hover { text-decoration: underline; }
114
+ iframe { flex: 1; width: 100%; min-height: 0; border: 0; background: var(--bg); }
115
+ .placeholder { flex: 1; display: grid; place-items: center; color: var(--muted); padding: 24px; text-align: center; }
package/dist/cli.js CHANGED
@@ -1,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';
@@ -13,7 +14,7 @@ import * as sync from './commands/sync.js';
13
14
  import * as versions from './commands/versions.js';
14
15
  import * as why from './commands/why.js';
15
16
  const COMMANDS = {
16
- init, hunks, add, adopt, carry, show, html, why, review, reviews, sync, versions, schema,
17
+ init, hunks, add, adopt, carry, show, html, ary, why, review, reviews, sync, versions, schema,
17
18
  };
18
19
  function help() {
19
20
  return 'usage: git gloss <command> [options]\n\n' +
@@ -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
+ }
@@ -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]];
@@ -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
- console.log('! git-gloss is not on PATH here; the hooks need it wherever git runs (see README: npm link)');
125
+ // Without it, every commit and push prints an error and the gloss isn't
126
+ // moved, synced or carried, so say that plainly.
127
+ console.log('! git-gloss is not on PATH in this shell, so the hooks will fail on every commit and push.');
128
+ console.log(' Their errors are only warnings, and glosses are not adopted, carried or synced until it is fixed.');
129
+ console.log(' Either install it globally (npm install -g @ok-ggwp/git-gloss), or, for a project install,');
130
+ console.log(' put its node_modules/.bin first on PATH in each hook (see README: "Project installs").');
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/git.js CHANGED
@@ -146,7 +146,8 @@ export function resolveCommit(rev) {
146
146
  // "wip": the latest snapshot of uncommitted work (made by `hunks wip`). A
147
147
  // branch or tag named wip still works when there is no snapshot, and can be
148
148
  // reached as heads/wip or tags/wip when there is.
149
- const named = gitTry(['rev-parse', '--verify', '--quiet', `${rev}^{commit}`])?.trim();
149
+ // --end-of-options: the revision is whatever the user typed, never a flag.
150
+ const named = gitTry(['rev-parse', '--verify', '--quiet', '--end-of-options', `${rev}^{commit}`])?.trim();
150
151
  if (rev === 'wip') {
151
152
  const sha = gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim();
152
153
  if (sha) {
package/dist/html/page.js CHANGED
@@ -6,7 +6,7 @@ const asset = (name) => readFileSync(new URL(name, import.meta.url), 'utf8');
6
6
  export function embedJson(data) {
7
7
  return JSON.stringify(data).replace(/</g, '\\u003c');
8
8
  }
9
- const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
9
+ export const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
10
10
  export function renderPage(data) {
11
11
  // The page only runs our own inline script. If escaping ever failed, an
12
12
  // injected script would lack the nonce and the CSP would block it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ok-ggwp/git-gloss",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Annotate agent-written diffs with intent and necessity, stored in git notes",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -25,6 +25,10 @@ Gloss changes **before they are committed**, unless the user asks you to commit
25
25
 
26
26
  To gloss a commit instead, use its revision (default `HEAD`) wherever these steps say `wip`.
27
27
 
28
+ ## Calling git-gloss
29
+
30
+ The commands below are written as `git gloss …`. That works only where git-gloss is on PATH. Check first with `command -v git-gloss`. If it isn't, look for a project install (`node_modules/.bin/git-gloss`, or a `package.json` that lists `@ok-ggwp/git-gloss`) and run the same commands as `pnpm exec git-gloss …`, `npx git-gloss …` or `node_modules/.bin/git-gloss …`. Use the package manager the repo uses. In a monorepo the install may sit in a subdirectory: run the commands from there, and give file paths to `why` relative to that directory. Don't install it globally just to run the skill.
31
+
28
32
  ## Steps
29
33
 
30
34
  1. Run `git gloss hunks wip` to snapshot the uncommitted work and see it split into hunks with ids `h1`, `h2`, …, with each hunk's changed (`+`/`-`) lines numbered from 1. If you change files afterwards, run it again: that takes a new snapshot, and a gloss must match the snapshot it was written for.