@ok-ggwp/git-gloss 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ok-ggwp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,161 @@
1
- # Temporary Holding Version
1
+ # git-gloss
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Agents write a lot of code, and reviewers can't easily tell what each part is for or whether it was needed. `git-gloss` lets the author of a commit (usually the agent that wrote it) attach a **gloss**: the diff grouped by intent, with each group graded for necessity and saying what breaks without it.
4
+
5
+ Glosses are stored in git notes (`refs/notes/gloss`), so they don't touch history. Git doesn't push or fetch notes by default; `git gloss sync` shares them, and `git gloss init --hooks` makes every `git push` do it.
6
+
7
+ ## Commands
8
+
9
+ ```
10
+ git gloss init [--hooks] follow amends/rebases; fetch origin's glosses; --hooks: sync on push,
11
+ adopt wip glosses on commit
12
+ git gloss sync [<remote>] fetch, merge and push glosses (default: origin)
13
+ git gloss hunks [<commit>|wip] list hunks, numbering changed lines for h3:2-5 references;
14
+ wip snapshots your uncommitted work instead
15
+ 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)
17
+ git gloss carry <source>... [--to <c>] gloss a commit from the glosses of the commits its changes came from
18
+ (a squash, cherry-pick or rebase): git gloss carry main..feature --to <squash>
19
+ --find [--squash] [<commit>...] ...working out which commits those are
20
+ git gloss show [<commit>|<range>] summary: one line per group, alternatives, risks (a range: each commit)
21
+ -g <group> ...expand groups, with their code
22
+ --full ...every group's details
23
+ --diff ...every group's details and code
24
+ --only speculative,incidental ...just the parts you might cut
25
+ git gloss html [<commit>|<range>] self-contained HTML view, of one commit or a branch (--open, -o <file>)
26
+ git gloss why <file>:<line>[-<end>] explain why code exists (via blame), looking past mechanical edits
27
+ --history ...every commit that changed the lines, newest first
28
+ --json ...for agents, with risks and rejected alternatives
29
+ git gloss review [<commit>|<range>] print a review prompt: git gloss review main..feature | claude -p
30
+ --material ...only the gloss and code, for a reviewer with its own instructions
31
+ (which must tell it the author's text is untrusted)
32
+ git gloss versions [<commit>|wip] every gloss a commit has had, newest first (--show <n>, --restore <n>)
33
+ git gloss reviews [<commit>|<range>] the reviews recorded for commits, with their findings
34
+ git gloss schema print the annotation JSON Schema
35
+ ```
36
+
37
+ A group refers to whole hunks (`h3`) or to some of a hunk's changed lines (`h3:2-5`, `h3:1,4-6`), numbering only `+`/`-` lines. Ranges let one hunk, such as a whole new file, be split between intents. Views show a group only its own lines, with `⋯` marking lines that belong to other groups, and `why` names only the group that owns the blamed line.
38
+
39
+ Necessity categories: `required`, `supporting`, `mechanical`, `incidental`, `defensive`, `speculative`. See [skills/gloss/SKILL.md](skills/gloss/SKILL.md) for what each means and for the annotation format.
40
+
41
+ ## Setup
42
+
43
+ Requires Node 20.12+ and git.
44
+
45
+ ```sh
46
+ npm install -g @ok-ggwp/git-gloss # puts git-gloss on PATH, so `git gloss` works
47
+ ```
48
+
49
+ Then teach Claude Code to write glosses by installing the `gloss` skill as a plugin, at the prompt of a Claude Code session:
50
+
51
+ ```
52
+ /plugin install git-gloss --marketplace ok-ggwp/git-gloss
53
+ ```
54
+
55
+ Answer `y` to add the marketplace and choose the user scope. From a shell, the same is `claude plugin install git-gloss --marketplace ok-ggwp/git-gloss`. `/plugin update` picks up new versions of the skill. (Without the plugin, a symlink works too: `ln -s "$(npm root -g)/@ok-ggwp/git-gloss/skills/gloss" ~/.claude/skills/gloss`.)
56
+
57
+ git finds `git-gloss` on PATH, so `git gloss` works wherever npm's global folder is on PATH, as it is after a normal Node install. On Windows this should work the same way (npm installs a shell launcher git can run), but it hasn't been tested yet.
58
+
59
+ For a team, add it to the project instead, so everyone gets the same version with `npm install`:
60
+
61
+ ```sh
62
+ npm install -D @ok-ggwp/git-gloss
63
+ npx git-gloss init --hooks
64
+ ```
65
+
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
+
68
+ 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
+ ### Setting up a repo
71
+
72
+ In each repo you use it in:
73
+
74
+ 1. Run `git gloss init --hooks`. Glosses then follow amends and rebases, `git commit` moves a wip-gloss onto the new commit, and `git push` to a named remote also syncs glosses with it. The hooks call `git gloss`, so it must be on the PATH of whatever runs git (a GUI client may not have it); `init` warns if it isn't. Without the hooks, run `git gloss adopt` after committing and `git gloss sync` to share.
75
+ 2. Tell your coding agent to wip-gloss its changes. Add this to the repo's `CLAUDE.md` (Claude Code), or `AGENTS.md` for agents that read that file:
76
+
77
+ ```markdown
78
+ ## git-gloss
79
+ After making a non-trivial code change, wip-gloss it before reporting back: leave
80
+ the changes uncommitted and gloss them with git-gloss, following the gloss skill.
81
+ Don't commit unless asked.
82
+ ```
83
+
84
+ Claude Code finds the procedure in the `gloss` skill (the plugin installed above). Other agents won't have the skill, so for them add a pointer in `AGENTS.md` to a copy of `skills/gloss/SKILL.md`.
85
+
86
+ Put the line in the repo rather than in your user-level `CLAUDE.md`: it only makes sense where `git gloss init` has been run.
87
+
88
+ `sync` fetches the remote's glosses into `refs/notes/remotes/<remote>/gloss`, merges them into yours, and pushes the glosses of commits the remote has (or, from the hook, is being sent), retrying if someone else pushed in between. Glosses of commits that exist only on your machine, such as a private branch, stay local until those commits are pushed. Glosses on different commits simply combine, and a gloss changed on only one side since you last synced is taken from that side. Only when both sides changed the same commit's gloss since then is it a conflict: the newer gloss (by its `created_at`) wins, because a re-gloss is normally a correction, and if either can't be read, yours is kept. It prints what it fetched, pushed and decided. The hook never blocks a push: if syncing fails it prints a warning and the push goes ahead. It does nothing for `git push --dry-run`. It syncs before the branch is pushed (so glosses arrive even if the branch push is then rejected), and it disables credential prompts so a remote that needs one fails fast instead of stalling the push. If you already have a `pre-push` or `post-commit` hook, `init --hooks` leaves it alone and prints the lines to add. It also won't install into a `core.hooksPath` outside the repository, since other repositories may share that directory.
89
+
90
+ ## Glossing before you commit
91
+
92
+ You don't have to commit to gloss or review. `wip` stands for your uncommitted work:
93
+
94
+ ```sh
95
+ git gloss hunks wip # snapshot tracked and untracked changes (respects .gitignore)
96
+ git gloss add wip -f a.json # gloss the snapshot
97
+ git gloss show wip # read it; html wip and review wip work too
98
+ git commit # with init --hooks, the gloss moves onto the commit
99
+ git gloss adopt # ...or move it by hand
100
+ ```
101
+
102
+ The snapshot is a commit object built from a temporary index, kept by the private ref `refs/worktree/gloss/wip` (one per linked worktree); your branch, index and files are never touched. Taking a new snapshot keeps the gloss of groups whose changes are unchanged, so after committing part of the work or editing one part, only the rest needs glossing again. Its gloss lives in `refs/notes/gloss-wip`, which `sync` doesn't push. Untracked files that look like secrets (`.env`, `*.key`, `*.pem`, `credentials.json`, …) are left out of the snapshot, with a note, because snapshots are shown and sent to reviewers; stage one with `git add` to include it. If a branch or tag is called `wip`, `wip` still means it when there's no snapshot; otherwise use `heads/wip`. Commands warn when you've edited files or committed since the snapshot. `adopt` matches the snapshot's hunks to the commit's by content, so the work can be committed in several commits (each takes the groups whose changes it contains), and changes made after glossing are reported as unexplained rather than silently covered. Once everything is committed, the snapshot is cleared.
103
+
104
+ ## Glosses that follow your history
105
+
106
+ A gloss is attached to a commit, so anything that makes new commits from old ones has to bring it along. With `git gloss init --hooks`:
107
+
108
+ | You | What happens to the gloss |
109
+ |---|---|
110
+ | amend, or rebase without changing a commit's diff | copied to the new commit (`notes.rewriteRef`) |
111
+ | amend or rebase that changes the diff (a conflict, an edit) | re-glossed from the original, line by line; changes it doesn't explain are listed |
112
+ | squash or fixup commits in a rebase | the glosses of all of them are combined into one |
113
+ | cherry-pick (with or without `-x`) | carried from the picked commit |
114
+ | `git merge --squash`, keeping git's message | carried from the squashed commits |
115
+ | a squash or rebase merge on GitHub, or any other squash (your own message, `reset --soft`) | carried on your next `git gloss sync` or push, if you have the original commits (the author does): it looks for unglossed commits whose diff matches a glossed branch's combined diff (80% of changed lines in common), or a glossed commit's patch with the same author and date. By hand: `git gloss carry --find --squash` |
116
+
117
+ Carrying matches each changed line to a glossed line with the same text in the original commits, and gives it that line's group: intents, grades and notes survive, and anything changed along the way is reported as unexplained rather than guessed. `git gloss carry` does the same by hand.
118
+
119
+ Nothing is deleted. Replacing a gloss, a group dropped from a wip gloss, or the losing side of a sync conflict all stay in the notes history, and glossed wip snapshots stay reachable from `refs/worktree/gloss/archive` so `git gc` keeps them. `git gloss versions <commit>` (or `wip`) lists every version, and `--restore <n>` brings one back; a wip version is carried onto the current snapshot. The archive keeps those snapshots' files too, including anything since removed from them; `git update-ref -d refs/worktree/gloss/archive` lets `git gc` collect them (the glosses' text stays in the notes history).
120
+
121
+ ## Agent reviews
122
+
123
+ `git gloss review | claude -p` gives a reviewer the gloss and the code, and asks it whether the gloss is honest: does each group do what it says, is its necessity graded fairly, what could be cut.
124
+
125
+ To have agents review in a disposable clone, where they can run the code and verify what they find, use [git-gud](https://github.com/ok-ggwp/git-gud). `git gud review [<commit>|<range>|wip]` runs this honesty review, a blind bug review that doesn't see the gloss, and an improvements review informed by it, with Claude Code, Codex or Cursor as the reviewer. git-gloss itself never runs an agent.
126
+
127
+ ### Recorded reviews
128
+
129
+ git-gud records each review beside the glosses (`refs/notes/gloss-reviews`), so whoever reads a branch later can see whether its glosses were checked and what the checks found:
130
+
131
+ ```
132
+ $ git gloss show main..feature
133
+ main..feature: 4 commits, 4 glossed, 3 honesty-reviewed (1 with issues)
134
+ ...
135
+ reviews: honesty: no issues (claude, 2026-10-18) · bugs: defects found (claude, 2026-10-18)
136
+ ```
137
+
138
+ `git gloss reviews <commit>` prints the findings, and the HTML view shows each commit's reviews, with their reports a click away. A review records what it saw: a fingerprint of each changed line and of each gloss group. So it keeps counting through rebases, squashes and a wip gloss moved onto commits. If the code or the gloss changed after the review, the line says so ("gloss changed since review") instead of vouching for it.
139
+
140
+ 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.
141
+
142
+ 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
+
144
+ ## HTML view
145
+
146
+ `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.
147
+
148
+ Give a range (`git gloss html main..feature --open`) for a branch or PR view: every non-merge commit in order (merges are listed as not reviewed; `A...B` is rejected), a necessity bar across the whole branch, and the sidebar grouped by commit. Commits without a gloss appear with their whole diff marked as unexplained. On range pages a group's link is `#g-<commit>-<group>`. `git gloss review main..feature` likewise covers every commit and adds branch-level questions, such as later commits that undo earlier ones.
149
+
150
+ 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
+
152
+ ## Caveats
153
+
154
+ - 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>`.
155
+ - A gloss records each hunk's location and a hash of its content. If a commit is amended or rebased so its diff changes, `show`, `review` and `why` flag the gloss as stale instead of showing it against the wrong code.
156
+ - Merge commits are diffed against their first parent.
157
+ - `git gloss html` writes to `~/.cache/git-gloss/<commit>.html` by default (or `$XDG_CACHE_HOME`). The page is a snapshot; regenerate it after re-glossing.
158
+
159
+ ## License
160
+
161
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ // From a checkout, on a Node that runs TypeScript, use the source directly, so
3
+ // development needs no build; otherwise (an installed package, an older Node)
4
+ // the compiled JavaScript in dist/.
5
+ import { existsSync } from 'node:fs'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ const src = new URL('../src/cli.ts', import.meta.url)
9
+ const dist = new URL('../dist/cli.js', import.meta.url)
10
+ if (!process.features.typescript && !existsSync(dist)) {
11
+ console.error(`${process.argv[1]}: no build found; run \`npm run build\` in ${fileURLToPath(new URL('..', import.meta.url))}, or use Node 22.18+ to run the source`)
12
+ process.exit(1)
13
+ }
14
+ const { main } = process.features.typescript && existsSync(src) ? await import(src.href) : await import(dist.href)
15
+ process.exitCode = await main(process.argv.slice(2))
package/dist/carry.js ADDED
@@ -0,0 +1,56 @@
1
+ import { servesOf } from './schema.js';
2
+ import { bodyLines, changedCount, parseRef, selections, uncovered } from './selection.js';
3
+ import { alsoServed } from './validate.js';
4
+ const changedText = (h) => new Set(bodyLines(h).filter((l) => l.idx !== null).map((l) => l.raw));
5
+ // Move a gloss onto another diff: a commit made from a wip snapshot, or a new
6
+ // snapshot of work that has partly changed. Each glossed hunk is matched to
7
+ // the new hunk with the same changes (the content hash used to follow
8
+ // rebases); a group keeps its references to the hunks that matched, renamed
9
+ // to the new ids, with line ranges unchanged because matched hunks have the
10
+ // same changed lines. Groups with no matching hunk are left behind.
11
+ // `previous` is the diff the gloss was written for, used to compare content
12
+ // (line numbers from two different bases can't be compared).
13
+ export function carryGloss(note, current, previous = []) {
14
+ const used = new Set();
15
+ const idMap = new Map();
16
+ for (const stored of note.hunks) {
17
+ const match = stored.hash ? current.find((h) => !used.has(h) && h.hash === stored.hash && h.file === stored.file) : undefined;
18
+ if (match) {
19
+ used.add(match);
20
+ idMap.set(stored.id, match);
21
+ }
22
+ }
23
+ const unmatchedOld = note.hunks.filter((s) => !idMap.has(s.id))
24
+ .flatMap((s) => previous.filter((p) => p.id === s.id && p.hash === s.hash && p.file === s.file))
25
+ .map((p) => ({ file: p.file, lines: changedText(p) }));
26
+ const partialMatch = current.some((h) => !used.has(h) &&
27
+ unmatchedOld.some((p) => p.file === h.file && [...changedText(h)].some((l) => p.lines.has(l))));
28
+ const a = note.annotation;
29
+ const kept = a.groups.flatMap((g) => {
30
+ const hunks = g.hunks.flatMap((ref) => {
31
+ const r = parseRef(ref);
32
+ const h = r && idMap.get(r.hunk);
33
+ return h ? [r.ranges ? `${h.id}:${r.ranges.map(([x, y]) => (x === y ? `${x}` : `${x}-${y}`)).join(',')}` : h.id] : [];
34
+ });
35
+ return hunks.length ? [{ ...g, hunks }] : [];
36
+ });
37
+ const dropped = a.groups.filter((g) => !kept.some((k) => k.id === g.id)).map((g) => g.id);
38
+ if (!kept.length)
39
+ return { annotation: null, kept: 0, total: a.groups.length, dropped, gaps: [], partialMatch };
40
+ // A group may serve one that was left behind; point it at the task instead
41
+ // and say so, rather than leave a reference validation would reject.
42
+ const keptIds = new Set(kept.map((g) => g.id));
43
+ const groups = kept.map((g) => {
44
+ const targets = servesOf(g);
45
+ const lost = targets.filter((t) => t !== 'task' && !keptIds.has(t));
46
+ if (!lost.length)
47
+ return g;
48
+ const remaining = targets.filter((t) => t === 'task' || keptIds.has(t));
49
+ const note = alsoServed(lost, 'glossed with other changes');
50
+ return { ...g, serves: remaining.length > 1 ? remaining : remaining[0] ?? 'task', notes: g.notes ? `${g.notes} ${note}` : note };
51
+ });
52
+ const byId = new Map(current.map((h) => [h.id, h]));
53
+ const claims = groups.flatMap((g) => [...selections(g.hunks, (id) => { const h = byId.get(id); return h && changedCount(h); })]
54
+ .flatMap(([id, lines]) => { const h = byId.get(id); return h ? [{ hunk: h, lines }] : []; }));
55
+ return { annotation: { ...a, groups }, kept: groups.length, total: a.groups.length, dropped, gaps: uncovered(current, claims), partialMatch };
56
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,48 @@
1
+ import { GlossError } from './git.js';
2
+ import * as add from './commands/add.js';
3
+ import * as adopt from './commands/adopt.js';
4
+ import * as carry from './commands/carry.js';
5
+ import * as html from './commands/html.js';
6
+ import * as hunks from './commands/hunks.js';
7
+ import * as init from './commands/init.js';
8
+ import * as review from './commands/review.js';
9
+ import * as reviews from './commands/reviews.js';
10
+ import * as schema from './commands/schema.js';
11
+ import * as show from './commands/show.js';
12
+ import * as sync from './commands/sync.js';
13
+ import * as versions from './commands/versions.js';
14
+ import * as why from './commands/why.js';
15
+ const COMMANDS = {
16
+ init, hunks, add, adopt, carry, show, html, why, review, reviews, sync, versions, schema,
17
+ };
18
+ function help() {
19
+ return 'usage: git gloss <command> [options]\n\n' +
20
+ Object.values(COMMANDS)
21
+ .map((c) => {
22
+ const [synopsis, desc] = c.usage.split(/ {3}/);
23
+ return ` ${synopsis}\n ${desc}`;
24
+ })
25
+ .join('\n');
26
+ }
27
+ export async function main(argv) {
28
+ const [cmd, ...rest] = argv;
29
+ if (!cmd || cmd === '-h' || cmd === '--help' || cmd === 'help') {
30
+ console.log(help());
31
+ return 0;
32
+ }
33
+ const command = COMMANDS[cmd];
34
+ if (!command) {
35
+ console.error(`unknown command "${cmd}"\n\n${help()}`);
36
+ return 2;
37
+ }
38
+ try {
39
+ return command.run(rest);
40
+ }
41
+ catch (err) {
42
+ if (err instanceof GlossError || err.code?.startsWith('ERR_PARSE_ARGS')) {
43
+ console.error(`error: ${err.message}`);
44
+ return 1;
45
+ }
46
+ throw err;
47
+ }
48
+ }
@@ -0,0 +1,78 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parseArgs } from 'node:util';
3
+ import { z } from 'zod';
4
+ import { commitDiff, isWipSnapshot, NOTES_REF, readNote, resolveCommit, WIP_NOTES_REF, writeNote } from '../git.js';
5
+ import { location, parseDiff } from '../diff.js';
6
+ import { Annotation, noteFormat } from '../schema.js';
7
+ import { validate } from '../validate.js';
8
+ export const usage = 'add [<commit>] [-f <file>] [--force] [--partial] [--dry-run] attach an annotation (JSON from file or stdin)';
9
+ export function run(argv) {
10
+ const { values, positionals } = parseArgs({
11
+ args: argv,
12
+ allowPositionals: true,
13
+ options: {
14
+ file: { type: 'string', short: 'f' },
15
+ force: { type: 'boolean' },
16
+ partial: { type: 'boolean' },
17
+ 'dry-run': { type: 'boolean' },
18
+ },
19
+ });
20
+ const sha = resolveCommit(positionals[0] ?? 'HEAD');
21
+ if (!values.file && process.stdin.isTTY) {
22
+ console.error('error: pipe annotation JSON on stdin or pass -f <file> (see `git gloss schema`)');
23
+ return 2;
24
+ }
25
+ const raw = readFileSync(values.file ?? 0, 'utf8');
26
+ let json;
27
+ try {
28
+ json = JSON.parse(raw);
29
+ }
30
+ catch (err) {
31
+ console.error(`error: annotation is not valid JSON: ${err.message}`);
32
+ return 1;
33
+ }
34
+ const parsed = Annotation.safeParse(json);
35
+ if (!parsed.success) {
36
+ console.error('error: annotation does not match the schema:\n' + z.prettifyError(parsed.error));
37
+ return 1;
38
+ }
39
+ const hunks = parseDiff(commitDiff(sha));
40
+ const { errors, uncovered } = validate(parsed.data, hunks);
41
+ if (uncovered.length && !values.partial) {
42
+ errors.push(`hunks not assigned to any group: ${uncovered.join(', ')} (every hunk needs a group, or pass --partial)`);
43
+ }
44
+ if (errors.length) {
45
+ console.error('error: annotation is inconsistent with the commit:\n' + errors.map((e) => ` ✖ ${e}`).join('\n'));
46
+ return 1;
47
+ }
48
+ // Not an error: long intents still work, but they make the summary rows tall.
49
+ for (const g of parsed.data.groups.filter((g) => g.intent.length > 100)) {
50
+ console.error(`note: group "${g.id}" has a ${g.intent.length}-character intent; keep it under ~80 and move detail to "notes"`);
51
+ }
52
+ if (values['dry-run']) {
53
+ console.log(`ok: annotation is valid for ${sha.slice(0, 10)} (${parsed.data.groups.length} groups, ${hunks.length} hunks)`);
54
+ return 0;
55
+ }
56
+ // A gloss carried over from an earlier snapshot, or by `carry` from the
57
+ // commits these changes came from, is a starting point; a fresh gloss
58
+ // replaces it without --force (the old one stays in the notes history).
59
+ const existing = values.force ? null : readNote(sha);
60
+ if (existing && !existing.carried_from && !existing.sources) {
61
+ console.error(`error: ${sha.slice(0, 10)} already has a gloss; pass --force to replace it`);
62
+ return 1;
63
+ }
64
+ // Store a one-item serves list as a plain string: same meaning, and it keeps
65
+ // the note readable by versions without list support (format below 3).
66
+ for (const g of parsed.data.groups)
67
+ if (Array.isArray(g.serves) && g.serves.length === 1)
68
+ g.serves = g.serves[0];
69
+ writeNote(sha, {
70
+ gloss: noteFormat(parsed.data),
71
+ commit: sha,
72
+ created_at: new Date().toISOString(),
73
+ annotation: parsed.data,
74
+ hunks: hunks.map(location),
75
+ }, isWipSnapshot(sha) ? WIP_NOTES_REF : NOTES_REF);
76
+ console.log(`glossed ${sha.slice(0, 10)}: ${parsed.data.groups.length} groups over ${hunks.length} hunks`);
77
+ return 0;
78
+ }
@@ -0,0 +1,93 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { parseArgs } from 'node:util';
3
+ import { archiveSnapshot, commitDiff, dropWipNote, git, gitTry, GlossError, readNote, resolveCommit, WIP_REF, writeNote } from '../git.js';
4
+ import { carryGloss } from '../carry.js';
5
+ import { location, parseDiff } from '../diff.js';
6
+ import { noteFormat } from '../schema.js';
7
+ import { refLabel } from '../selection.js';
8
+ import { carriedErrors } from '../validate.js';
9
+ import { copyReviews } from '../reviews.js';
10
+ export const usage = 'adopt [<commit>] [--force] move the gloss of uncommitted work (wip) onto a commit made from it';
11
+ // Carry a wip snapshot's gloss onto a real commit made from it (see
12
+ // carryGloss): a commit takes the groups whose hunks it contains, so the work
13
+ // can be committed in several commits, and its changes the wip gloss doesn't
14
+ // cover are reported.
15
+ export function run(argv) {
16
+ const { values, positionals } = parseArgs({
17
+ args: argv,
18
+ allowPositionals: true,
19
+ // --quiet-unless-adopted (for the post-commit hook): print nothing when
20
+ // there was nothing to adopt.
21
+ options: { force: { type: 'boolean' }, 'quiet-unless-adopted': { type: 'boolean' } },
22
+ });
23
+ const quiet = values['quiet-unless-adopted'];
24
+ const say = (msg) => console.log(msg);
25
+ const skip = (msg) => { if (!quiet)
26
+ console.log(msg); return 0; };
27
+ if (positionals[0] === 'wip') {
28
+ throw new GlossError('adopt moves the wip gloss onto a commit made from it; give that commit (default HEAD), not wip');
29
+ }
30
+ const wip = gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim();
31
+ const wipNote = wip ? readNote(wip) : null;
32
+ if (!wip || !wipNote)
33
+ return skip('nothing to adopt: there is no glossed snapshot of uncommitted work');
34
+ const sha = resolveCommit(positionals[0] ?? 'HEAD');
35
+ if (!values.force && readNote(sha)) {
36
+ if (quiet)
37
+ return 0; // the commit was glossed directly
38
+ throw new GlossError(`${sha.slice(0, 7)} already has a gloss; pass --force to replace it with the adopted one`);
39
+ }
40
+ // Only commits descended from the snapshot's base can contain its work.
41
+ // During a rebase, the hook stays out: replayed commits' glosses follow them
42
+ // through notes.rewriteRef instead.
43
+ const base = git(['log', '-1', '--format=%P', wip]).trim().split(' ')[0];
44
+ if (base && gitTry(['merge-base', '--is-ancestor', base, sha]) === null) {
45
+ return skip(`nothing to adopt for ${sha.slice(0, 7)}: it isn't built on the commit the wip snapshot was taken from`);
46
+ }
47
+ const rebasing = ['rebase-merge', 'rebase-apply'].some((d) => existsSync(git(['rev-parse', '--git-path', d]).trim()));
48
+ if (quiet && rebasing)
49
+ return 0;
50
+ const current = parseDiff(commitDiff(sha));
51
+ const carried = carryGloss(wipNote, current, parseDiff(commitDiff(wip)));
52
+ if (!carried.annotation) {
53
+ // A glossed hunk split across commits (or edited after glossing) can't be
54
+ // matched; say so even under the hook, since the gloss would otherwise
55
+ // never reach any commit without a word.
56
+ if (carried.partialMatch) {
57
+ console.log(`git-gloss: ${sha.slice(0, 7)} changes glossed code, but no hunk matches the wip gloss exactly ` +
58
+ '(a hunk split across commits, or edited after glossing); gloss this commit with: git gloss add');
59
+ return 0;
60
+ }
61
+ return skip(`nothing to adopt for ${sha.slice(0, 7)}: none of its changes are in the wip gloss`);
62
+ }
63
+ const { annotation, gaps } = carried;
64
+ const real = carriedErrors(annotation, current);
65
+ if (real.length)
66
+ throw new GlossError(`the adopted gloss doesn't fit ${sha.slice(0, 7)}:\n${real.map((e) => ` ✖ ${e}`).join('\n')}`);
67
+ writeNote(sha, {
68
+ gloss: noteFormat(annotation),
69
+ commit: sha,
70
+ created_at: new Date().toISOString(),
71
+ annotation,
72
+ hunks: current.map(location),
73
+ });
74
+ // Reviews of the uncommitted work cover the parts of it this commit holds.
75
+ copyReviews([wip], sha);
76
+ // Once nothing uncommitted is left, the snapshot has served its purpose.
77
+ const head = gitTry(['rev-parse', '--verify', '--quiet', 'HEAD'])?.trim();
78
+ const done = head === sha && git(['status', '--porcelain']).trim() === '';
79
+ const left = carried.total - carried.kept;
80
+ say(`adopted ${carried.kept} of ${carried.total} wip groups onto ${sha.slice(0, 7)}` +
81
+ (left > 0 && !done ? `; the other ${left} stay in the wip gloss for later commits` : ''));
82
+ if (gaps.length) {
83
+ say(` not explained by the wip gloss (edited after glossing, or never glossed): ${gaps.map((u) => refLabel(u.hunk.id, u.lines)).join(', ')}`);
84
+ say(` gloss them with: git gloss add ${sha.slice(0, 7)} --force -f <annotation including them>`);
85
+ }
86
+ if (done) {
87
+ archiveSnapshot(wip);
88
+ dropWipNote(wip);
89
+ git(['update-ref', '-d', WIP_REF]);
90
+ say(' all work is committed; cleared the wip snapshot');
91
+ }
92
+ return 0;
93
+ }