@ok-ggwp/git-gloss 0.3.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 +72 -4
- package/dist/ary/browse.js +4 -61
- package/dist/ary/picker.js +163 -0
- package/dist/ary/server.js +87 -37
- package/dist/attention.js +34 -0
- package/dist/carry.js +4 -1
- package/dist/cli.js +2 -1
- package/dist/commands/add.js +17 -1
- package/dist/commands/ary.js +3 -3
- package/dist/commands/hunks.js +8 -1
- package/dist/commands/review.js +5 -4
- package/dist/commands/reviews.js +58 -5
- package/dist/commands/show.js +56 -20
- package/dist/commands/signals.js +89 -0
- package/dist/commands/sync.js +14 -2
- package/dist/commands/why.js +3 -2
- package/dist/config.js +248 -0
- package/dist/floor.js +148 -0
- package/dist/git.js +29 -3
- package/dist/glob.js +76 -0
- package/dist/html/client.js +1032 -296
- package/dist/html/data.js +26 -1
- package/dist/html/page.js +1 -1
- package/dist/html/style.css +310 -164
- package/dist/loader.js +7 -3
- package/dist/merge.js +5 -0
- package/dist/plain.js +9 -0
- package/dist/render.js +55 -5
- package/dist/reviews.js +98 -4
- package/dist/safe-match.js +30 -0
- package/dist/schema.js +55 -4
- package/dist/selection.js +21 -0
- package/dist/signals.js +302 -0
- package/dist/validate.js +10 -1
- package/package.json +1 -1
- package/skills/gloss/SKILL.md +42 -2
- package/dist/ary/client.js +0 -401
- package/dist/ary/index.html +0 -13
- package/dist/ary/style.css +0 -115
package/README.md
CHANGED
|
@@ -12,8 +12,11 @@ git gloss init [--hooks] follow amends/rebases; fetch origin's glos
|
|
|
12
12
|
git gloss sync [<remote>] fetch, merge and push glosses (default: origin)
|
|
13
13
|
git gloss hunks [<commit>|wip] list hunks, numbering changed lines for h3:2-5 references;
|
|
14
14
|
wip snapshots your uncommitted work instead
|
|
15
|
+
git gloss signals [<commit>|<range>|wip] [--json]
|
|
16
|
+
what the diff itself says about attention: path classes, pattern
|
|
17
|
+
hits, dependency bumps and the computed floor
|
|
15
18
|
git gloss adopt [<commit>] move the gloss of uncommitted work onto the commit made from it
|
|
16
|
-
git gloss add [<commit>] -f a.json attach an annotation (validated against the diff)
|
|
19
|
+
git gloss add [<commit>] -f a.json attach an annotation (validated against the diff; --allow-underrate)
|
|
17
20
|
git gloss carry <source>... [--to <c>] gloss a commit from the glosses of the commits its changes came from
|
|
18
21
|
(a squash, cherry-pick or rebase): git gloss carry main..feature --to <squash>
|
|
19
22
|
--find [--squash] [<commit>...] ...working out which commits those are
|
|
@@ -21,7 +24,8 @@ git gloss show [<commit>|<range>] summary: one line per group, alternatives,
|
|
|
21
24
|
-g <group> ...expand groups, with their code
|
|
22
25
|
--full ...every group's details
|
|
23
26
|
--diff ...every group's details and code
|
|
24
|
-
--only speculative,incidental ...just the parts you might cut
|
|
27
|
+
--only speculative,incidental ...just the parts you might cut (impact values work too: --only contract,sensitive; naming both kinds needs both to match)
|
|
28
|
+
--sort impact ...most consequential groups first
|
|
25
29
|
git gloss html [<commit>|<range>] self-contained HTML view, of one commit or a branch (--open, -o <file>)
|
|
26
30
|
git gloss ary [--port <n>] [--no-open] browse branches, commits and wip in a local web page (see "Browsing")
|
|
27
31
|
git gloss why <file>:<line>[-<end>] explain why code exists (via blame), looking past mechanical edits
|
|
@@ -31,7 +35,10 @@ git gloss review [<commit>|<range>] print a review prompt: git gloss review ma
|
|
|
31
35
|
--material ...only the gloss and code, for a reviewer with its own instructions
|
|
32
36
|
(which must tell it the author's text is untrusted)
|
|
33
37
|
git gloss versions [<commit>|wip] every gloss a commit has had, newest first (--show <n>, --restore <n>)
|
|
34
|
-
git gloss reviews [<commit>|<range>] the reviews recorded for commits, with their findings
|
|
38
|
+
git gloss reviews [<commit>|<range>] the reviews recorded for commits, with their findings and decisions
|
|
39
|
+
--decide <review-id> --finding <text> --outcome fixed|follow-up|rejected|accepted [--note <text>]
|
|
40
|
+
record what was decided about one finding
|
|
41
|
+
--decide <review-id> --complete the arbiter has finished with that review
|
|
35
42
|
git gloss schema print the annotation JSON Schema
|
|
36
43
|
```
|
|
37
44
|
|
|
@@ -155,8 +162,69 @@ reviews: honesty: no issues (claude, 2026-10-18) · bugs: defects found (claude,
|
|
|
155
162
|
|
|
156
163
|
A review says that something was checked, not that it passed. Each review ends with a verdict (`VERDICT: CLEAN` or `VERDICT: ISSUES`). For each part of the commit, the latest review that saw it decides, and a clean result that followed one with issues says so.
|
|
157
164
|
|
|
165
|
+
### Decisions
|
|
166
|
+
|
|
167
|
+
A review holds a report and a verdict, so by itself it cannot tell "3 fixed, 2 deferred" from "5 ignored". Whoever arbitrates a review (the `gg-deliver` arbiter, or a person) records **decisions** about it, in the same notes ref and on the same commits as the review. A decision is one JSON line:
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
{"decision":1,"review":"<review id>","at":"…","by":"…","finding":"1","outcome":"fixed","note":"…"}
|
|
171
|
+
{"decision":1,"review":"<review id>","at":"…","by":"…","complete":true}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`finding` is free text naming the finding (reports are free text, so git-gloss doesn't parse findings out of them); `outcome` is `fixed`, `follow-up`, `rejected` or `accepted`; `note` is optional. The second line says the arbiter has finished and no more decisions are coming, which is what separates "all arbitrated" from "5 ignored". The marker is advisory: decisions added after it are still accepted and counted. A review whose verdict is clean needs none.
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
git gloss reviews wip --decide <review-id> --finding "Rework: wip rule claim" --outcome follow-up --note "next release"
|
|
178
|
+
git gloss reviews wip --decide <review-id> --complete
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The target (`wip`, a commit, a range) is as for `reviews`; the decision is added to every commit there that holds that review, and an unknown review id is an error. `by` is `--by <name>`, else `git config user.name`, else "unknown". `reviews` prints each review's id in its header line (the id `--decide` takes) and lists its decisions under it with a summary ("3 fixed, 2 follow-up · complete", or "4 decided · not marked complete"), and `show` carries the same summary on its reviews line. The HTML view does not show decisions yet. Decisions follow their reviews through `adopt` (so decisions made on `wip` survive committing), `carry`, rebases and `sync`. Text from notes (decisions and review records) and commit subjects is shown with control characters and escape sequences removed, since it can arrive from a remote; decision lines over 16 KB are skipped silently, and review lines over 8 MiB or with findings text over 256 KiB are skipped with a warning; unknown outcomes and decisions for a review that is not on the commit are ignored.
|
|
182
|
+
|
|
183
|
+
Known cost: decisions share the review note, and a git-gloss without this feature ignores decision lines when reading but does not know to keep them: when it copies review records onto another commit (`adopt` and `carry`), it writes only the review records, so the decisions are lost on that copy (git's own note rewriting on an amend or rebase copies whole notes, so that path keeps them). Keep everyone who arbitrates on the same version.
|
|
184
|
+
|
|
158
185
|
Like glosses, reviews follow rebased commits in repos set up with `git gloss init`. Reviews are shared by `git gloss sync` (and so by every push with the hooks) like glosses, for commits the remote has. They never conflict: everyone's reviews are kept. Other tools can record reviews too: `git gloss reviews --stamp <rev>` before the review, then `git gloss reviews --record` with the stamp and the report as JSON on stdin.
|
|
159
186
|
|
|
187
|
+
## Impact and attention
|
|
188
|
+
|
|
189
|
+
Necessity and consequence are separate: a `required` one-line fix to a login helper matters more than a `required` rename in a leaf component. Two optional fields say so. Each group may have `impact` (`local < module < shared < contract < sensitive`, the farthest its effect reaches if it is wrong) and an `impact_reason` of up to 120 characters. The annotation may have `attention`: `level` (`skim`, `look`, `careful` or `must-read`), one to three `focus` group ids to read first, `reasons`, and an optional `override_reason`. `git gloss schema` lists them all.
|
|
190
|
+
|
|
191
|
+
`show` leads with `attention: CAREFUL (declared) · floor: careful · focus: a, b` and the reasons, and badges each group with its impact; `html` shows the same as a banner whose focus links jump to the groups. Where nothing is declared, `show` and `html` show a value computed from the diff and mark it, as in `attention: LOOK (derived)` (a group's derived impact is in parentheses); a gloss reads as `attention: unrated` only where no signals can be computed. When glosses are combined (`carry`, a squash, a rebase) the attention level is the highest of them and the focus groups are the most impactful three, with the reasons joined; if any of them declared none, the combined gloss declares none and shows a derived one.
|
|
192
|
+
|
|
193
|
+
Notes using these fields are format 4; a git-gloss that predates them refuses such a note ("written by a newer git-gloss") rather than silently dropping the fields. A gloss that doesn't use them is still written in its old format. Derived values and floors are computed on every read and never stored in a note.
|
|
194
|
+
|
|
195
|
+
### Computed signals and the floor
|
|
196
|
+
|
|
197
|
+
`git gloss signals [<commit>|<range>|wip] [--json]` reports, per hunk, file and change: the path classes (`sensitive`, `shared`, `contract`, `generated`, `test`, `docs`, plus `lockfile` and `manifest`), pattern hits, stats (files, hunks, lines, test files, share of hunks in tests), dependency changes, and the floor. `wip` is the working tree as it is now (no snapshot is taken). `hunks` ends with a short summary of the same, so read it before writing a gloss. Signals are cheap heuristics from globs and regexes, not a security scanner.
|
|
198
|
+
|
|
199
|
+
- **Pattern hits** are regexes (case-insensitive unless the pattern sets `flags`) matched against the *added and removed* lines of a hunk only: never unchanged context lines, never the `@@` header. In test, docs and lockfile paths they are *damped*: still reported, but they don't count towards the floor (a `password` in a test file is not sensitive).
|
|
200
|
+
- **Dependencies.** Edits to manifests and lockfiles are listed. `package-lock.json` and `pnpm-lock.yaml` are parsed from their changed lines for version bumps, and a bump of a package that no `package.json` change names is flagged as transitive. Other lockfiles are `contract` with no version detail.
|
|
201
|
+
- **Floor of a hunk**: the highest of its path class (`sensitive`, `contract`, `shared`; lockfiles and manifests are at least `contract`; otherwise `module`) and its undamped pattern hits. Test, docs and generated files cap at `local` (a pattern hit in generated code still counts). A group's floor is the highest floor of its hunks; for a group that claims only some lines of a hunk, only the hits on those lines count.
|
|
202
|
+
- **Floor of the change**: skim for `local`, look for `module`, careful for `shared` and `contract`, must-read for `sensitive`, and at least careful when more than `attention.largeChangeFiles` (default 50) files changed.
|
|
203
|
+
- **Derived attention** (when the gloss declares none): start from the highest group impact (declared, else the group's floor) with the same mapping, then raise it one step (capped at must-read, once however many apply) for a behaviour-changing group that no test group serves, for a speculative or defensive group outside tests, or for more than `largeChangeFiles` files; never below the floor. A group is *behaviour-changing* when its necessity is not `mechanical` and not all its hunks are tests, docs, generated files or lockfiles. It is *served by tests* when a group made only of test-file hunks names it in `serves`, directly or through groups it serves; a test group serving `"task"` doesn't count.
|
|
204
|
+
|
|
205
|
+
`add` checks a gloss against the floor. A group whose declared `impact` is below its floor (above plain `module`) is a warning naming the signal. A declared `attention.level` below the floor is an error unless `attention.override_reason` says why; `--allow-underrate` turns that error into a warning. Leaving `impact` or `attention` out is always allowed. An `override_reason` is shown by `show` and `html` next to the floor, flagged as a mismatch.
|
|
206
|
+
|
|
207
|
+
### Config
|
|
208
|
+
|
|
209
|
+
The built-in defaults name no language, framework or tool, only common file names. A repo adds to them with `.gloss/config.json`, committed in that repo. The file is read from the base branch (the merge base of the checked-out `HEAD` and `origin/main`, `main`, `origin/master` or `master`, so a long-lived branch sees the config as of where it forked; a commit on the base branch itself reads its own config, flagged `sensitive` if it edits the file), never from the working tree, so a change cannot reclassify its own paths; a config edit only takes effect once it is on the base branch. With none of those branches the defaults apply, with a warning. Editing the file is itself always a `sensitive` path. A pattern that takes over 250 ms on a hunk is cut off and from then on counts as matching every changed line (with a warning), so a slow or padded line raises the floor and never lowers it:
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{
|
|
213
|
+
"attention": { "largeChangeFiles": 80 },
|
|
214
|
+
"paths": {
|
|
215
|
+
"shared": ["packages/shared/**", "tsconfig.base.json"],
|
|
216
|
+
"sensitive": ["src/devices/**"],
|
|
217
|
+
"test": { "extends": false, "globs": ["**/*.spec.*", "e2e/**"] }
|
|
218
|
+
},
|
|
219
|
+
"patterns": {
|
|
220
|
+
"sensitive": [{ "regex": "\\bdevice\\.write\\(", "name": "device write" }],
|
|
221
|
+
"contract": ["<regex for the repo's public-API markers>"]
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
A class is a list that extends the default list, or `{ "extends": false, "globs": [...] }` (`"regexes"` for patterns) to replace it. Path classes: `sensitive`, `shared`, `contract`, `generated`, `test`, `docs`, `lockfile`, `manifest`; pattern classes: `sensitive`, `contract`, `shared`. Globs support `**`, `*`, `?` and `{a,b}`; a glob without a `/` matches that name at any depth. `git config gloss.attention.largeChangeFiles <n>` overrides the file. A malformed config is an error naming the file and key (`add` and `signals` stop; `show` falls back to declared values and "unrated").
|
|
227
|
+
|
|
160
228
|
## HTML view
|
|
161
229
|
|
|
162
230
|
`git gloss html --open` writes a single HTML file (no network access, works offline) and opens it. It shows the task and summary, a bar of how the changed lines split across necessity categories, and a card per group with its rationale; code is collapsed until you ask for it. Click categories to filter, use the sidebar to jump between groups, or press `j`/`k` to move, `o` to show code, and `e`/`c` to expand or collapse everything. `#g-<group>` in the URL opens that group.
|
|
@@ -167,7 +235,7 @@ All gloss and diff text is rendered as text, and a Content-Security-Policy with
|
|
|
167
235
|
|
|
168
236
|
## Browsing
|
|
169
237
|
|
|
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.
|
|
238
|
+
`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. The page is the review desk that `git gloss html` writes, with the pickers in its top bar: choose 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. With a base, the desk's own sidebar lists the commits of the range; with "none" (or when there is nothing beyond the base, or more than 200 commits) the page shows just the branch's newest commit and its groups. 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, which is what opens first once it has been snapshotted (`git gloss hunks wip`); to view a branch or tag called `wip`, type `heads/wip` or `tags/wip`. Every choice loads a new page (`/?rev=…&head=…&base=…`), regenerated from the repo each time, so reloading picks up new glosses and reviews, and the address keeps the selection (with a random port, a bookmark lasts only until you stop the server; pass `--port` for a stable address).
|
|
171
239
|
|
|
172
240
|
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
241
|
|
package/dist/ary/browse.js
CHANGED
|
@@ -1,80 +1,23 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
|
-
import {
|
|
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
|
-
}
|
|
2
|
+
import { git, gitTry } from '../git.js';
|
|
10
3
|
function refList(namespace) {
|
|
11
|
-
return git(['for-each-ref', '--sort=-committerdate', '--format=%(refname)%00%(refname:short)
|
|
4
|
+
return git(['for-each-ref', '--sort=-committerdate', '--format=%(refname)%00%(refname:short)', namespace])
|
|
12
5
|
.split('\n')
|
|
13
6
|
.filter(Boolean)
|
|
14
7
|
.map((l) => l.split('\0'))
|
|
15
8
|
// refs/remotes/<remote>/HEAD is a pointer to another branch, not a branch.
|
|
16
9
|
.filter(([full]) => !full.endsWith('/HEAD'))
|
|
17
|
-
.map(([, name
|
|
10
|
+
.map(([, name]) => name);
|
|
18
11
|
}
|
|
19
12
|
export function listRefs() {
|
|
20
13
|
const branches = refList('refs/heads');
|
|
21
14
|
const remotes = refList('refs/remotes');
|
|
22
|
-
const names = new Set([...branches, ...remotes]
|
|
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
|
-
}
|
|
15
|
+
const names = new Set([...branches, ...remotes]);
|
|
33
16
|
return {
|
|
34
17
|
repo: basename(git(['rev-parse', '--show-toplevel']).trim()),
|
|
35
18
|
current: gitTry(['symbolic-ref', '--short', '--quiet', 'HEAD'])?.trim() || null,
|
|
36
19
|
branches,
|
|
37
20
|
remotes,
|
|
38
21
|
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
22
|
};
|
|
80
23
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The branch pickers and revision box in the desk's top bar when served by
|
|
2
|
+
// `git gloss ary`. Included by page.ts only in that mode, ahead of the desk's
|
|
3
|
+
// script, in the same nonce'd <script>; `el` and `add` are the desk's helpers. It makes no requests: the branches
|
|
4
|
+
// arrive in the page data. Choosing something loads the URL for it, and the
|
|
5
|
+
// server renders that page. Text only goes through textContent.
|
|
6
|
+
function aryBar(ary, el, add) {
|
|
7
|
+
'use strict'
|
|
8
|
+
// A branch picker with a search box: a repo can have hundreds of branches,
|
|
9
|
+
// too many to scan in a <select>. Matching is case-insensitive and every
|
|
10
|
+
// space-separated word must appear in the name.
|
|
11
|
+
var SHOWN = 200
|
|
12
|
+
function picker(onPick) {
|
|
13
|
+
var root = el('div', 'combo')
|
|
14
|
+
var button = el('button', 'combo-btn')
|
|
15
|
+
button.type = 'button'
|
|
16
|
+
button.setAttribute('aria-haspopup', 'listbox')
|
|
17
|
+
var panel = el('div', 'combo-panel')
|
|
18
|
+
panel.hidden = true
|
|
19
|
+
var input = el('input')
|
|
20
|
+
input.type = 'search'
|
|
21
|
+
input.spellcheck = false
|
|
22
|
+
input.placeholder = 'Search branches…'
|
|
23
|
+
input.setAttribute('aria-label', 'Search branches')
|
|
24
|
+
var list = el('div', 'combo-list')
|
|
25
|
+
list.setAttribute('role', 'listbox')
|
|
26
|
+
add(panel, input, list)
|
|
27
|
+
add(root, button, panel)
|
|
28
|
+
|
|
29
|
+
var groups = []
|
|
30
|
+
var noneLabel = null
|
|
31
|
+
var value = null
|
|
32
|
+
var items = [] // the rows now shown: { value, node }
|
|
33
|
+
var active = 0
|
|
34
|
+
|
|
35
|
+
function matches(name, words) {
|
|
36
|
+
var n = name.toLowerCase()
|
|
37
|
+
return words.every(function (w) { return n.indexOf(w) >= 0 })
|
|
38
|
+
}
|
|
39
|
+
function paint() {
|
|
40
|
+
items.forEach(function (it, i) { it.node.classList.toggle('active', i === active) })
|
|
41
|
+
if (items[active]) items[active].node.scrollIntoView({ block: 'nearest' })
|
|
42
|
+
}
|
|
43
|
+
function render() {
|
|
44
|
+
var words = input.value.toLowerCase().split(/\s+/).filter(Boolean)
|
|
45
|
+
list.textContent = ''
|
|
46
|
+
items = []
|
|
47
|
+
var shown = 0
|
|
48
|
+
var hidden = 0
|
|
49
|
+
function item(label, v) {
|
|
50
|
+
var node = el('button', 'combo-item', label)
|
|
51
|
+
node.type = 'button'
|
|
52
|
+
node.setAttribute('role', 'option')
|
|
53
|
+
node.setAttribute('aria-selected', String(v === value))
|
|
54
|
+
node.addEventListener('click', function () { pick(v) })
|
|
55
|
+
items.push({ value: v, node: node })
|
|
56
|
+
return node
|
|
57
|
+
}
|
|
58
|
+
if (noneLabel && !words.length) list.appendChild(item(noneLabel, null))
|
|
59
|
+
groups.forEach(function (g) {
|
|
60
|
+
var found = g.names.filter(function (n) { return matches(n, words) })
|
|
61
|
+
var room = found.slice(0, Math.max(0, SHOWN - shown))
|
|
62
|
+
hidden += found.length - room.length
|
|
63
|
+
shown += room.length
|
|
64
|
+
if (!room.length) return
|
|
65
|
+
add(list, add(el('div', 'combo-group', g.label), el('span', 'count', found.length)))
|
|
66
|
+
room.forEach(function (n) { list.appendChild(item(n, n)) })
|
|
67
|
+
})
|
|
68
|
+
if (hidden) add(list, el('div', 'combo-more', hidden + ' more: keep typing to narrow the list'))
|
|
69
|
+
if (!items.length) add(list, el('div', 'combo-more', 'No branches match.'))
|
|
70
|
+
// Start on the current value, or on the first match when searching.
|
|
71
|
+
var at = words.length ? -1 : items.findIndex(function (it) { return it.value === value })
|
|
72
|
+
active = at >= 0 ? at : 0
|
|
73
|
+
paint()
|
|
74
|
+
}
|
|
75
|
+
function open() {
|
|
76
|
+
panel.hidden = false
|
|
77
|
+
button.setAttribute('aria-expanded', 'true')
|
|
78
|
+
input.value = ''
|
|
79
|
+
render()
|
|
80
|
+
input.focus()
|
|
81
|
+
}
|
|
82
|
+
function close() {
|
|
83
|
+
panel.hidden = true
|
|
84
|
+
button.setAttribute('aria-expanded', 'false')
|
|
85
|
+
}
|
|
86
|
+
function pick(v) {
|
|
87
|
+
close()
|
|
88
|
+
button.focus()
|
|
89
|
+
if (v !== value) onPick(v)
|
|
90
|
+
}
|
|
91
|
+
button.addEventListener('click', function () { if (panel.hidden) open(); else close() })
|
|
92
|
+
input.addEventListener('input', render)
|
|
93
|
+
input.addEventListener('keydown', function (ev) {
|
|
94
|
+
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
95
|
+
ev.preventDefault()
|
|
96
|
+
if (items.length) active = (active + (ev.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length
|
|
97
|
+
paint()
|
|
98
|
+
} else if (ev.key === 'Enter') {
|
|
99
|
+
ev.preventDefault()
|
|
100
|
+
if (items[active]) pick(items[active].value)
|
|
101
|
+
} else if (ev.key === 'Escape') {
|
|
102
|
+
ev.preventDefault()
|
|
103
|
+
close()
|
|
104
|
+
button.focus()
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
document.addEventListener('mousedown', function (ev) { if (!panel.hidden && !root.contains(ev.target)) close() })
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
root: root,
|
|
111
|
+
// Local and remote branches, and what choosing "no branch" is called (if allowed).
|
|
112
|
+
setOptions: function (g, none) { groups = g; noneLabel = none },
|
|
113
|
+
setValue: function (v) {
|
|
114
|
+
value = v
|
|
115
|
+
button.textContent = v || noneLabel || 'choose a branch'
|
|
116
|
+
button.title = button.textContent
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function branchGroups() {
|
|
122
|
+
return [
|
|
123
|
+
{ label: 'Local branches', names: ary.branches },
|
|
124
|
+
{ label: 'Remote branches', names: ary.remotes },
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
function go(head, base, rev) {
|
|
128
|
+
var p = new URLSearchParams()
|
|
129
|
+
if (rev) p.set('rev', rev)
|
|
130
|
+
if (head) p.set('head', head)
|
|
131
|
+
if (base) p.set('base', base)
|
|
132
|
+
location.assign('/?' + p.toString())
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
var headPicker = picker(function (name) {
|
|
136
|
+
// A feature branch is most useful against the usual base; the base itself is listed whole.
|
|
137
|
+
go(name, name === ary.defaultBase ? null : ary.defaultBase, null)
|
|
138
|
+
})
|
|
139
|
+
headPicker.setOptions(branchGroups(), null)
|
|
140
|
+
headPicker.setValue(ary.head)
|
|
141
|
+
var basePicker = picker(function (name) { go(ary.head, name, null) })
|
|
142
|
+
basePicker.setOptions(branchGroups(), '(none: just the newest commit)')
|
|
143
|
+
basePicker.setValue(ary.base)
|
|
144
|
+
|
|
145
|
+
var input = el('input')
|
|
146
|
+
input.type = 'text'
|
|
147
|
+
input.spellcheck = false
|
|
148
|
+
input.placeholder = 'HEAD~3, abc123, main..feature, wip'
|
|
149
|
+
input.setAttribute('aria-label', 'Any revision or range')
|
|
150
|
+
input.value = ary.rev || ''
|
|
151
|
+
var form = add(el('form', 'ary-rev'), input)
|
|
152
|
+
form.addEventListener('submit', function (ev) {
|
|
153
|
+
ev.preventDefault()
|
|
154
|
+
var v = input.value.trim()
|
|
155
|
+
if (v) go(ary.head, ary.base, v)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
function field(label, node) { return add(el('div', 'ary-field'), el('span', null, label), node) }
|
|
159
|
+
var bar = add(el('div', 'ary-bar'), el('span', 'ary-repo', ary.repo),
|
|
160
|
+
field('Branch', headPicker.root), field('Compare with', basePicker.root), field('Revision', form))
|
|
161
|
+
if (ary.notice) bar.appendChild(el('p', 'ary-notice', ary.notice))
|
|
162
|
+
return bar
|
|
163
|
+
}
|
package/dist/ary/server.js
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
1
|
import { createServer } from 'node:http';
|
|
3
|
-
import { GlossError, WIP_REF, gitTry, resolveCommits } from '../git.js';
|
|
2
|
+
import { GlossError, WIP_REF, git, gitTry, resolveCommits } from '../git.js';
|
|
4
3
|
import { buildPageData } from '../html/data.js';
|
|
5
4
|
import { escapeHtml, renderPage } from '../html/page.js';
|
|
6
|
-
import {
|
|
7
|
-
const asset = (name) => readFileSync(new URL(name, import.meta.url), 'utf8');
|
|
5
|
+
import { listRefs } from './browse.js';
|
|
8
6
|
// A range page loads and lays out every commit in it, so a huge one would hang
|
|
9
7
|
// the browser. Narrowing it is a better answer than a spinner.
|
|
10
8
|
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
9
|
class BadRequest extends GlossError {
|
|
15
10
|
}
|
|
16
11
|
// A revision, branch or range typed by the user or taken from the sidebar. An
|
|
@@ -26,10 +21,6 @@ function revParam(url, name, required = true) {
|
|
|
26
21
|
throw new BadRequest(`not a revision: ${v.slice(0, 80)}`);
|
|
27
22
|
return v;
|
|
28
23
|
}
|
|
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
24
|
function send(res, status, type, body, headers = {}) {
|
|
34
25
|
res.writeHead(status, {
|
|
35
26
|
'content-type': `${type}; charset=utf-8`,
|
|
@@ -41,9 +32,74 @@ function send(res, status, type, body, headers = {}) {
|
|
|
41
32
|
});
|
|
42
33
|
res.end(body);
|
|
43
34
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
35
|
+
// The page may not be framed by anyone: it shows the code of your commits.
|
|
36
|
+
const PAGE_HEADERS = { 'content-security-policy': "frame-ancestors 'none'" };
|
|
37
|
+
function errorPage(res, status, message) {
|
|
38
|
+
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(message)}</pre><p style="font:14px/1.5 system-ui;padding:0 16px"><a href="/">Back to the default view</a></p>`, { 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'" });
|
|
39
|
+
}
|
|
40
|
+
// The commits of `head` the page shows when nothing else is chosen: those
|
|
41
|
+
// beyond `base` (when a page can show them all), or else the newest one (as a sha, so a branch called "wip"
|
|
42
|
+
// doesn't mean the snapshot).
|
|
43
|
+
function defaultRev(head, base) {
|
|
44
|
+
if (!head)
|
|
45
|
+
return null;
|
|
46
|
+
if (base) {
|
|
47
|
+
// A range beyond what a page shows would only be an error page: show the newest commit.
|
|
48
|
+
const n = Number(git(['rev-list', '--count', '--end-of-options', `${base}..${head}`, '--']).trim());
|
|
49
|
+
if (n > 0 && n <= MAX_RANGE_COMMITS)
|
|
50
|
+
return `${base}..${head}`;
|
|
51
|
+
}
|
|
52
|
+
return gitTry(['rev-parse', '--verify', '--quiet', `${head}^{commit}`])?.trim() || null;
|
|
53
|
+
}
|
|
54
|
+
// The page for ?rev=&head=&base=. head and base only fill the pickers and
|
|
55
|
+
// choose the default rev; the rev alone decides which commits are shown.
|
|
56
|
+
function deskPage(url, res) {
|
|
57
|
+
const refs = listRefs();
|
|
58
|
+
const known = new Set([...refs.branches, ...refs.remotes]);
|
|
59
|
+
const wantHead = revParam(url, 'head', false);
|
|
60
|
+
const wantBase = revParam(url, 'base', false);
|
|
61
|
+
let rev = revParam(url, 'rev', false);
|
|
62
|
+
// The default base goes with the default branch.
|
|
63
|
+
const baseFor = (head) => (head === refs.defaultBase ? null : refs.defaultBase);
|
|
64
|
+
// A link or bookmark may name a branch that has since been deleted or
|
|
65
|
+
// renamed: use the usual choice instead, and say so.
|
|
66
|
+
let head = wantHead && known.has(wantHead) ? wantHead : null;
|
|
67
|
+
const fallback = refs.current || refs.defaultBase || refs.branches[0] || null;
|
|
68
|
+
let base;
|
|
69
|
+
let notice = null;
|
|
70
|
+
if (wantHead && !head) {
|
|
71
|
+
head = fallback;
|
|
72
|
+
base = baseFor(head);
|
|
73
|
+
notice = `Branch \u201c${wantHead}\u201d no longer exists; showing ${head ?? 'the default'} instead.`;
|
|
74
|
+
}
|
|
75
|
+
else if (wantBase && !known.has(wantBase)) {
|
|
76
|
+
head ??= fallback;
|
|
77
|
+
base = null;
|
|
78
|
+
notice = `The branch to compare with, \u201c${wantBase}\u201d, no longer exists; showing the newest commit of ${head ?? 'the default'}.`;
|
|
79
|
+
}
|
|
80
|
+
else if (head) {
|
|
81
|
+
base = wantBase;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
head = fallback;
|
|
85
|
+
base = wantBase ?? baseFor(head);
|
|
86
|
+
}
|
|
87
|
+
if (!rev) {
|
|
88
|
+
// Opening with nothing chosen shows the uncommitted work, if snapshotted;
|
|
89
|
+
// choosing a branch shows that branch.
|
|
90
|
+
const wip = !wantHead && !wantBase && gitTry(['rev-parse', '--verify', '--quiet', WIP_REF]);
|
|
91
|
+
rev = wip ? 'wip' : defaultRev(head, base);
|
|
92
|
+
if (!rev)
|
|
93
|
+
throw new GlossError(head ? `${head} has no commits to show` : 'there are no branches to show; pass ?rev= to view a commit');
|
|
94
|
+
if (!notice) {
|
|
95
|
+
const p = new URLSearchParams({ rev });
|
|
96
|
+
if (head)
|
|
97
|
+
p.set('head', head);
|
|
98
|
+
if (base)
|
|
99
|
+
p.set('base', base);
|
|
100
|
+
return send(res, 302, 'text/plain', 'redirecting', { location: `/?${p}` });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
47
103
|
// "wip" is the latest snapshot of uncommitted work. The server never takes
|
|
48
104
|
// one (that is `git gloss hunks wip`): browsing doesn't change anything.
|
|
49
105
|
const target = rev === 'wip' ? gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim() : rev;
|
|
@@ -53,22 +109,14 @@ function viewPage(url) {
|
|
|
53
109
|
if (shas.length > MAX_RANGE_COMMITS) {
|
|
54
110
|
throw new GlossError(`${rev} has ${shas.length} commits; a page shows at most ${MAX_RANGE_COMMITS}. Pick a narrower range.`);
|
|
55
111
|
}
|
|
56
|
-
|
|
112
|
+
const data = buildPageData(shas, range, merges);
|
|
113
|
+
data.ary = { repo: refs.repo, branches: refs.branches, remotes: refs.remotes, defaultBase: refs.defaultBase, head, base, rev, notice };
|
|
114
|
+
send(res, 200, 'text/html', renderPage(data), PAGE_HEADERS);
|
|
57
115
|
}
|
|
58
|
-
function route(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
}
|
|
116
|
+
function route(res, url) {
|
|
117
|
+
if (url.pathname === '/')
|
|
118
|
+
return deskPage(url, res);
|
|
119
|
+
send(res, 404, 'text/plain', 'not found');
|
|
72
120
|
}
|
|
73
121
|
export function createAry(port) {
|
|
74
122
|
return createServer((req, res) => {
|
|
@@ -80,9 +128,16 @@ export function createAry(port) {
|
|
|
80
128
|
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
129
|
if (req.method !== 'GET' && req.method !== 'HEAD')
|
|
82
130
|
return send(res, 405, 'text/plain', 'read-only', { allow: 'GET, HEAD' });
|
|
83
|
-
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
84
131
|
try {
|
|
85
|
-
|
|
132
|
+
// A request target need not be a URL ("//[" is not), and a throw here would end the process.
|
|
133
|
+
let url;
|
|
134
|
+
try {
|
|
135
|
+
url = new URL(req.url ?? '/', 'http://localhost');
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
throw new BadRequest('not a valid request target');
|
|
139
|
+
}
|
|
140
|
+
route(res, url);
|
|
86
141
|
}
|
|
87
142
|
catch (err) {
|
|
88
143
|
if (!(err instanceof GlossError)) {
|
|
@@ -90,12 +145,7 @@ export function createAry(port) {
|
|
|
90
145
|
return send(res, 500, 'text/plain', 'internal error');
|
|
91
146
|
}
|
|
92
147
|
const status = err instanceof BadRequest ? 400 : 422;
|
|
93
|
-
|
|
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
|
-
}
|
|
148
|
+
errorPage(res, status, err.message);
|
|
99
149
|
}
|
|
100
150
|
});
|
|
101
151
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ATTENTION, IMPACT } from './schema.js';
|
|
2
|
+
// Rank of a group's impact; a group with none is below every level.
|
|
3
|
+
export const impactRank = (i) => (i ? IMPACT.indexOf(i) : -1);
|
|
4
|
+
export const maxImpact = (a, b) => (impactRank(b) > impactRank(a) ? b : a);
|
|
5
|
+
export const attentionRank = (a) => (a ? ATTENTION.indexOf(a) : -1);
|
|
6
|
+
export const maxAttention = (a, b) => (attentionRank(b) > attentionRank(a) ? b : a);
|
|
7
|
+
// One step up, capped at must-read.
|
|
8
|
+
export const raise = (a) => ATTENTION[Math.min(ATTENTION.length - 1, ATTENTION.indexOf(a) + 1)];
|
|
9
|
+
// The attention a given impact calls for, before any raising.
|
|
10
|
+
const ATTENTION_FOR_IMPACT = {
|
|
11
|
+
local: 'skim', module: 'look', shared: 'careful', contract: 'careful', sensitive: 'must-read',
|
|
12
|
+
};
|
|
13
|
+
export const attentionForImpact = (i) => ATTENTION_FOR_IMPACT[i];
|
|
14
|
+
// What the reviewer should do when glosses are combined (a squash, a rebase):
|
|
15
|
+
// the highest level of any of them. Focus groups of all of them, renamed to
|
|
16
|
+
// their new ids (`rename` returns undefined for a group that is gone), most
|
|
17
|
+
// impactful first, without repeats, at most three. Reasons are unioned.
|
|
18
|
+
export function combineAttention(parts, rename, groups) {
|
|
19
|
+
if (!parts.length)
|
|
20
|
+
return undefined;
|
|
21
|
+
const level = parts.map((p) => p.level).reduce((a, b) => (ATTENTION.indexOf(b) > ATTENTION.indexOf(a) ? b : a));
|
|
22
|
+
const impactOf = new Map(groups.map((g) => [g.id, g.impact]));
|
|
23
|
+
const focus = [...new Set(parts.flatMap((p, i) => (p.focus ?? []).flatMap((id) => rename(i, id) ?? [])))]
|
|
24
|
+
.sort((a, b) => impactRank(impactOf.get(b)) - impactRank(impactOf.get(a)))
|
|
25
|
+
.slice(0, 3);
|
|
26
|
+
const reasons = [...new Set(parts.flatMap((p) => p.reasons ?? []))];
|
|
27
|
+
const overrides = [...new Set(parts.flatMap((p) => (p.override_reason && p.level === level ? [p.override_reason] : [])))];
|
|
28
|
+
return {
|
|
29
|
+
level,
|
|
30
|
+
...(focus.length ? { focus } : {}),
|
|
31
|
+
...(reasons.length ? { reasons } : {}),
|
|
32
|
+
...(overrides.length ? { override_reason: overrides.join('; ') } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
package/dist/carry.js
CHANGED
|
@@ -52,5 +52,8 @@ export function carryGloss(note, current, previous = []) {
|
|
|
52
52
|
const byId = new Map(current.map((h) => [h.id, h]));
|
|
53
53
|
const claims = groups.flatMap((g) => [...selections(g.hunks, (id) => { const h = byId.get(id); return h && changedCount(h); })]
|
|
54
54
|
.flatMap(([id, lines]) => { const h = byId.get(id); return h ? [{ hunk: h, lines }] : []; }));
|
|
55
|
-
|
|
55
|
+
// Focus groups that were left behind can't be pointed at.
|
|
56
|
+
const focus = a.attention?.focus?.filter((id) => keptIds.has(id));
|
|
57
|
+
const attention = a.attention && { ...a.attention, focus: focus?.length ? focus : undefined };
|
|
58
|
+
return { annotation: { ...a, groups, ...(attention ? { attention } : {}) }, kept: groups.length, total: a.groups.length, dropped, gaps: uncovered(current, claims), partialMatch };
|
|
56
59
|
}
|
package/dist/cli.js
CHANGED
|
@@ -10,11 +10,12 @@ import * as review from './commands/review.js';
|
|
|
10
10
|
import * as reviews from './commands/reviews.js';
|
|
11
11
|
import * as schema from './commands/schema.js';
|
|
12
12
|
import * as show from './commands/show.js';
|
|
13
|
+
import * as signals from './commands/signals.js';
|
|
13
14
|
import * as sync from './commands/sync.js';
|
|
14
15
|
import * as versions from './commands/versions.js';
|
|
15
16
|
import * as why from './commands/why.js';
|
|
16
17
|
const COMMANDS = {
|
|
17
|
-
init, hunks, add, adopt, carry, show, html, ary, why, review, reviews, sync, versions, schema,
|
|
18
|
+
init, hunks, signals, add, adopt, carry, show, html, ary, why, review, reviews, sync, versions, schema,
|
|
18
19
|
};
|
|
19
20
|
function help() {
|
|
20
21
|
return 'usage: git gloss <command> [options]\n\n' +
|