@ok-ggwp/git-gloss 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -3
- package/dist/ary/browse.js +80 -0
- package/dist/ary/client.js +401 -0
- package/dist/ary/index.html +13 -0
- package/dist/ary/server.js +101 -0
- package/dist/ary/style.css +115 -0
- package/dist/attention.js +34 -0
- package/dist/carry.js +4 -1
- package/dist/cli.js +3 -1
- package/dist/commands/add.js +17 -1
- package/dist/commands/ary.js +35 -0
- package/dist/commands/html.js +1 -1
- package/dist/commands/hunks.js +8 -1
- package/dist/commands/init.js +6 -1
- package/dist/commands/show.js +44 -16
- package/dist/commands/signals.js +88 -0
- package/dist/config.js +192 -0
- package/dist/floor.js +148 -0
- package/dist/git.js +13 -2
- package/dist/glob.js +76 -0
- package/dist/html/client.js +1016 -296
- package/dist/html/data.js +26 -1
- package/dist/html/page.js +1 -1
- package/dist/html/style.css +284 -164
- package/dist/loader.js +1 -1
- package/dist/merge.js +5 -0
- package/dist/render.js +51 -3
- package/dist/safe-match.js +28 -0
- package/dist/schema.js +46 -2
- package/dist/signals.js +292 -0
- package/dist/validate.js +6 -0
- package/package.json +1 -1
- package/skills/gloss/SKILL.md +31 -1
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,8 +24,10 @@ 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>)
|
|
30
|
+
git gloss ary [--port <n>] [--no-open] browse branches, commits and wip in a local web page (see "Browsing")
|
|
26
31
|
git gloss why <file>:<line>[-<end>] explain why code exists (via blame), looking past mechanical edits
|
|
27
32
|
--history ...every commit that changed the lines, newest first
|
|
28
33
|
--json ...for agents, with risks and rejected alternatives
|
|
@@ -63,10 +68,25 @@ npm install -D @ok-ggwp/git-gloss
|
|
|
63
68
|
npx git-gloss init --hooks
|
|
64
69
|
```
|
|
65
70
|
|
|
66
|
-
The hooks run `git gloss`, so it must be on PATH wherever git runs
|
|
71
|
+
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
72
|
|
|
68
73
|
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
74
|
|
|
75
|
+
### Project installs
|
|
76
|
+
|
|
77
|
+
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:
|
|
78
|
+
|
|
79
|
+
- **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`):
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
PATH="$PWD/node_modules/.bin:$PATH"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
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.
|
|
86
|
+
- **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.
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
70
90
|
### Setting up a repo
|
|
71
91
|
|
|
72
92
|
In each repo you use it in:
|
|
@@ -141,6 +161,47 @@ A review says that something was checked, not that it passed. Each review ends w
|
|
|
141
161
|
|
|
142
162
|
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.
|
|
143
163
|
|
|
164
|
+
## Impact and attention
|
|
165
|
+
|
|
166
|
+
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.
|
|
167
|
+
|
|
168
|
+
`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.
|
|
169
|
+
|
|
170
|
+
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.
|
|
171
|
+
|
|
172
|
+
### Computed signals and the floor
|
|
173
|
+
|
|
174
|
+
`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.
|
|
175
|
+
|
|
176
|
+
- **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).
|
|
177
|
+
- **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.
|
|
178
|
+
- **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.
|
|
179
|
+
- **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.
|
|
180
|
+
- **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.
|
|
181
|
+
|
|
182
|
+
`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.
|
|
183
|
+
|
|
184
|
+
### Config
|
|
185
|
+
|
|
186
|
+
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:
|
|
187
|
+
|
|
188
|
+
```json
|
|
189
|
+
{
|
|
190
|
+
"attention": { "largeChangeFiles": 80 },
|
|
191
|
+
"paths": {
|
|
192
|
+
"shared": ["packages/shared/**", "tsconfig.base.json"],
|
|
193
|
+
"sensitive": ["src/devices/**"],
|
|
194
|
+
"test": { "extends": false, "globs": ["**/*.spec.*", "e2e/**"] }
|
|
195
|
+
},
|
|
196
|
+
"patterns": {
|
|
197
|
+
"sensitive": [{ "regex": "\\bdevice\\.write\\(", "name": "device write" }],
|
|
198
|
+
"contract": ["<regex for the repo's public-API markers>"]
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
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").
|
|
204
|
+
|
|
144
205
|
## HTML view
|
|
145
206
|
|
|
146
207
|
`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.
|
|
@@ -149,6 +210,12 @@ Give a range (`git gloss html main..feature --open`) for a branch or PR view: ev
|
|
|
149
210
|
|
|
150
211
|
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
212
|
|
|
213
|
+
## Browsing
|
|
214
|
+
|
|
215
|
+
`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).
|
|
216
|
+
|
|
217
|
+
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.
|
|
218
|
+
|
|
152
219
|
## Caveats
|
|
153
220
|
|
|
154
221
|
- 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>
|