@ok-ggwp/git-gloss 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,136 @@
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 schema print the annotation JSON Schema
34
+ ```
35
+
36
+ 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.
37
+
38
+ 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.
39
+
40
+ ## Setup
41
+
42
+ Requires Node 20.12+ and git.
43
+
44
+ ```sh
45
+ npm install -g @ok-ggwp/git-gloss # puts git-gloss on PATH, so `git gloss` works
46
+ ln -s "$(npm root -g)/@ok-ggwp/git-gloss/skills/gloss" ~/.claude/skills/gloss # teach Claude Code to write glosses
47
+ ```
48
+
49
+ 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.
50
+
51
+ For a team, add it to the project instead, so everyone gets the same version with `npm install`:
52
+
53
+ ```sh
54
+ npm install -D @ok-ggwp/git-gloss
55
+ npx git-gloss init --hooks
56
+ ```
57
+
58
+ 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.
59
+
60
+ 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+.
61
+
62
+ ### Setting up a repo
63
+
64
+ In each repo you use it in:
65
+
66
+ 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.
67
+ 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:
68
+
69
+ ```markdown
70
+ ## git-gloss
71
+ After making a non-trivial code change, wip-gloss it before reporting back: leave
72
+ the changes uncommitted and gloss them with git-gloss, following the gloss skill.
73
+ Don't commit unless asked.
74
+ ```
75
+
76
+ Claude Code finds the procedure in the `gloss` skill (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`.
77
+
78
+ 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.
79
+
80
+ `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.
81
+
82
+ ## Glossing before you commit
83
+
84
+ You don't have to commit to gloss or review. `wip` stands for your uncommitted work:
85
+
86
+ ```sh
87
+ git gloss hunks wip # snapshot tracked and untracked changes (respects .gitignore)
88
+ git gloss add wip -f a.json # gloss the snapshot
89
+ git gloss show wip # read it; html wip and review wip work too
90
+ git commit # with init --hooks, the gloss moves onto the commit
91
+ git gloss adopt # ...or move it by hand
92
+ ```
93
+
94
+ 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.
95
+
96
+ ## Glosses that follow your history
97
+
98
+ 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`:
99
+
100
+ | You | What happens to the gloss |
101
+ |---|---|
102
+ | amend, or rebase without changing a commit's diff | copied to the new commit (`notes.rewriteRef`) |
103
+ | 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 |
104
+ | squash or fixup commits in a rebase | the glosses of all of them are combined into one |
105
+ | cherry-pick (with or without `-x`) | carried from the picked commit |
106
+ | `git merge --squash`, keeping git's message | carried from the squashed commits |
107
+ | 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` |
108
+
109
+ 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.
110
+
111
+ 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).
112
+
113
+ ## Agent reviews
114
+
115
+ `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.
116
+
117
+ To have agents review in a sandbox, 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.
118
+
119
+ ## HTML view
120
+
121
+ `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.
122
+
123
+ 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.
124
+
125
+ 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.
126
+
127
+ ## Caveats
128
+
129
+ - 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>`.
130
+ - 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.
131
+ - Merge commits are diffed against their first parent.
132
+ - `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.
133
+
134
+ ## License
135
+
136
+ 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,47 @@
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 schema from './commands/schema.js';
10
+ import * as show from './commands/show.js';
11
+ import * as sync from './commands/sync.js';
12
+ import * as versions from './commands/versions.js';
13
+ import * as why from './commands/why.js';
14
+ const COMMANDS = {
15
+ init, hunks, add, adopt, carry, show, html, why, review, sync, versions, schema,
16
+ };
17
+ function help() {
18
+ return 'usage: git gloss <command> [options]\n\n' +
19
+ Object.values(COMMANDS)
20
+ .map((c) => {
21
+ const [synopsis, desc] = c.usage.split(/ {3}/);
22
+ return ` ${synopsis}\n ${desc}`;
23
+ })
24
+ .join('\n');
25
+ }
26
+ export async function main(argv) {
27
+ const [cmd, ...rest] = argv;
28
+ if (!cmd || cmd === '-h' || cmd === '--help' || cmd === 'help') {
29
+ console.log(help());
30
+ return 0;
31
+ }
32
+ const command = COMMANDS[cmd];
33
+ if (!command) {
34
+ console.error(`unknown command "${cmd}"\n\n${help()}`);
35
+ return 2;
36
+ }
37
+ try {
38
+ return command.run(rest);
39
+ }
40
+ catch (err) {
41
+ if (err instanceof GlossError || err.code?.startsWith('ERR_PARSE_ARGS')) {
42
+ console.error(`error: ${err.message}`);
43
+ return 1;
44
+ }
45
+ throw err;
46
+ }
47
+ }
@@ -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,90 @@
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
+ export const usage = 'adopt [<commit>] [--force] move the gloss of uncommitted work (wip) onto a commit made from it';
10
+ // Carry a wip snapshot's gloss onto a real commit made from it (see
11
+ // carryGloss): a commit takes the groups whose hunks it contains, so the work
12
+ // can be committed in several commits, and its changes the wip gloss doesn't
13
+ // cover are reported.
14
+ export function run(argv) {
15
+ const { values, positionals } = parseArgs({
16
+ args: argv,
17
+ allowPositionals: true,
18
+ // --quiet-unless-adopted (for the post-commit hook): print nothing when
19
+ // there was nothing to adopt.
20
+ options: { force: { type: 'boolean' }, 'quiet-unless-adopted': { type: 'boolean' } },
21
+ });
22
+ const quiet = values['quiet-unless-adopted'];
23
+ const say = (msg) => console.log(msg);
24
+ const skip = (msg) => { if (!quiet)
25
+ console.log(msg); return 0; };
26
+ if (positionals[0] === 'wip') {
27
+ throw new GlossError('adopt moves the wip gloss onto a commit made from it; give that commit (default HEAD), not wip');
28
+ }
29
+ const wip = gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim();
30
+ const wipNote = wip ? readNote(wip) : null;
31
+ if (!wip || !wipNote)
32
+ return skip('nothing to adopt: there is no glossed snapshot of uncommitted work');
33
+ const sha = resolveCommit(positionals[0] ?? 'HEAD');
34
+ if (!values.force && readNote(sha)) {
35
+ if (quiet)
36
+ return 0; // the commit was glossed directly
37
+ throw new GlossError(`${sha.slice(0, 7)} already has a gloss; pass --force to replace it with the adopted one`);
38
+ }
39
+ // Only commits descended from the snapshot's base can contain its work.
40
+ // During a rebase, the hook stays out: replayed commits' glosses follow them
41
+ // through notes.rewriteRef instead.
42
+ const base = git(['log', '-1', '--format=%P', wip]).trim().split(' ')[0];
43
+ if (base && gitTry(['merge-base', '--is-ancestor', base, sha]) === null) {
44
+ return skip(`nothing to adopt for ${sha.slice(0, 7)}: it isn't built on the commit the wip snapshot was taken from`);
45
+ }
46
+ const rebasing = ['rebase-merge', 'rebase-apply'].some((d) => existsSync(git(['rev-parse', '--git-path', d]).trim()));
47
+ if (quiet && rebasing)
48
+ return 0;
49
+ const current = parseDiff(commitDiff(sha));
50
+ const carried = carryGloss(wipNote, current, parseDiff(commitDiff(wip)));
51
+ if (!carried.annotation) {
52
+ // A glossed hunk split across commits (or edited after glossing) can't be
53
+ // matched; say so even under the hook, since the gloss would otherwise
54
+ // never reach any commit without a word.
55
+ if (carried.partialMatch) {
56
+ console.log(`git-gloss: ${sha.slice(0, 7)} changes glossed code, but no hunk matches the wip gloss exactly ` +
57
+ '(a hunk split across commits, or edited after glossing); gloss this commit with: git gloss add');
58
+ return 0;
59
+ }
60
+ return skip(`nothing to adopt for ${sha.slice(0, 7)}: none of its changes are in the wip gloss`);
61
+ }
62
+ const { annotation, gaps } = carried;
63
+ const real = carriedErrors(annotation, current);
64
+ if (real.length)
65
+ throw new GlossError(`the adopted gloss doesn't fit ${sha.slice(0, 7)}:\n${real.map((e) => ` ✖ ${e}`).join('\n')}`);
66
+ writeNote(sha, {
67
+ gloss: noteFormat(annotation),
68
+ commit: sha,
69
+ created_at: new Date().toISOString(),
70
+ annotation,
71
+ hunks: current.map(location),
72
+ });
73
+ // Once nothing uncommitted is left, the snapshot has served its purpose.
74
+ const head = gitTry(['rev-parse', '--verify', '--quiet', 'HEAD'])?.trim();
75
+ const done = head === sha && git(['status', '--porcelain']).trim() === '';
76
+ const left = carried.total - carried.kept;
77
+ say(`adopted ${carried.kept} of ${carried.total} wip groups onto ${sha.slice(0, 7)}` +
78
+ (left > 0 && !done ? `; the other ${left} stay in the wip gloss for later commits` : ''));
79
+ if (gaps.length) {
80
+ say(` not explained by the wip gloss (edited after glossing, or never glossed): ${gaps.map((u) => refLabel(u.hunk.id, u.lines)).join(', ')}`);
81
+ say(` gloss them with: git gloss add ${sha.slice(0, 7)} --force -f <annotation including them>`);
82
+ }
83
+ if (done) {
84
+ archiveSnapshot(wip);
85
+ dropWipNote(wip);
86
+ git(['update-ref', '-d', WIP_REF]);
87
+ say(' all work is committed; cleared the wip snapshot');
88
+ }
89
+ return 0;
90
+ }
@@ -0,0 +1,172 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { commitDiff, git, GlossError, readNote, resolveCommit, resolveCommits, writeNote } from '../git.js';
4
+ import { matchHunks } from '../loader.js';
5
+ import { findOrigins } from '../origins.js';
6
+ import { location, parseDiff } from '../diff.js';
7
+ import { mergeGlosses } from '../merge.js';
8
+ import { noteFormat } from '../schema.js';
9
+ import { refLabel } from '../selection.js';
10
+ import { carriedErrors } from '../validate.js';
11
+ export const usage = 'carry <source>... [--to <commit>] [--force] | carry --find [--squash] [<commit>...] gloss a commit from the glosses of the commits its changes came from (a squash, cherry-pick or rebase); --find works out which commits those are';
12
+ // The target's current gloss: none, one written by hand, one carried, or one
13
+ // that can't be read (such as the joined notes a squash leaves). Carrying may
14
+ // replace all but a hand-written one without --force.
15
+ export function targetState(sha) {
16
+ try {
17
+ const note = readNote(sha);
18
+ // A carried gloss is a starting point that a better one may replace, as add allows.
19
+ return note ? (note.sources || note.carried_from ? 'carried' : 'glossed') : 'none';
20
+ }
21
+ catch (err) {
22
+ if (err instanceof GlossError)
23
+ return 'broken';
24
+ throw err;
25
+ }
26
+ }
27
+ // Gloss `target` from the glosses of `sources`. Sources without a readable
28
+ // gloss are skipped. Writes nothing if no source explains any of its changes.
29
+ export function carry(sourceShas, target) {
30
+ const short = target.slice(0, 7);
31
+ const sources = [];
32
+ // Sources whose gloss can't be read are named, so their lines aren't
33
+ // mistaken for changes nobody glossed.
34
+ const unreadable = [];
35
+ for (const sha of sourceShas) {
36
+ if (sha === target)
37
+ continue;
38
+ let note;
39
+ try {
40
+ note = readNote(sha);
41
+ }
42
+ catch (err) {
43
+ if (!(err instanceof GlossError))
44
+ throw err;
45
+ unreadable.push(`${sha.slice(0, 7)} (${err.message.replace(/^the gloss on \S+ /, '')})`);
46
+ continue;
47
+ }
48
+ if (note)
49
+ sources.push({ sha, note, hunks: parseDiff(commitDiff(sha)) });
50
+ }
51
+ const skipped = unreadable.length ? `\n skipped sources whose gloss can't be read: ${unreadable.join('; ')}` : '';
52
+ if (!sources.length)
53
+ return { written: false, message: `nothing to carry onto ${short}: none of the sources has a readable gloss${skipped}` };
54
+ const current = parseDiff(commitDiff(target));
55
+ const merged = mergeGlosses(sources, current);
56
+ const stale = merged.stale.length ? `\n skipped glossed hunks that no longer match their commit's diff: ${merged.stale.join(', ')}` : '';
57
+ if (!merged.annotation)
58
+ return { written: false, message: `nothing to carry onto ${short}: none of its changes match the sources' glossed changes${skipped}${stale}` };
59
+ // Changes no source explains are reported below, not errors.
60
+ const errors = carriedErrors(merged.annotation, current);
61
+ if (errors.length)
62
+ throw new GlossError(`the carried gloss doesn't fit ${short}:\n${errors.map((e) => ` ✖ ${e}`).join('\n')}`);
63
+ writeNote(target, {
64
+ gloss: noteFormat(merged.annotation),
65
+ commit: target,
66
+ created_at: new Date().toISOString(),
67
+ annotation: merged.annotation,
68
+ hunks: current.map(location),
69
+ sources: merged.used,
70
+ });
71
+ const lines = [`carried ${merged.annotation.groups.length} group${merged.annotation.groups.length === 1 ? '' : 's'} from ` +
72
+ `${merged.used.map((s) => s.slice(0, 7)).join(', ')} onto ${short}`];
73
+ if (merged.dropped.length)
74
+ lines.push(` groups with no change left in ${short}: ${merged.dropped.join(', ')}`);
75
+ if (skipped)
76
+ lines.push(skipped.slice(1));
77
+ if (stale)
78
+ lines.push(stale.slice(1));
79
+ if (merged.gaps.length) {
80
+ lines.push(` not explained by the sources (edited while combining, never glossed${skipped || stale ? ', or in a skipped source' : ''}): ${merged.gaps.map((u) => refLabel(u.hunk.id, u.lines)).join(', ')}`, ` gloss them with: git gloss add ${short} -f <annotation including them>`);
81
+ }
82
+ return { written: true, message: lines.join('\n') };
83
+ }
84
+ // Whether a commit's gloss was written for different changes than the
85
+ // commit now has (it was copied onto a rewritten commit whose diff changed).
86
+ function isStale(sha) {
87
+ const note = readNote(sha);
88
+ if (!note)
89
+ return false;
90
+ return [...matchHunks(note.hunks, parseDiff(commitDiff(sha))).values()].some((r) => r.status !== 'ok');
91
+ }
92
+ // For the post-rewrite hook: git gives "<old> <new>" lines for an amend or a
93
+ // rebase. A commit made from several (squash, fixup) is glossed from all of
94
+ // them, replacing the joined notes notes.rewriteRef leaves; a commit made from
95
+ // one keeps its copied gloss unless the rewrite changed its diff, in which
96
+ // case it is glossed line by line from the original.
97
+ function rewritten(kind, input) {
98
+ // During a rebase, each squash step also reports an amend of a temporary
99
+ // commit; the rebase's own report at the end covers them all.
100
+ const rebasing = ['rebase-merge', 'rebase-apply'].some((d) => existsSync(git(['rev-parse', '--git-path', d]).trim()));
101
+ if (kind === 'amend' && rebasing)
102
+ return [];
103
+ const from = new Map();
104
+ for (const line of input.split('\n')) {
105
+ const [old, now] = line.trim().split(/\s+/);
106
+ if (!old || !now)
107
+ continue;
108
+ from.set(now, [...(from.get(now) ?? []), old]);
109
+ }
110
+ const out = [];
111
+ for (const [now, olds] of from) {
112
+ if (olds.length === 1 && ['glossed', 'carried'].includes(targetState(now)) && !isStale(now))
113
+ continue;
114
+ const res = carry(olds, now);
115
+ if (res.written)
116
+ out.push(res.message);
117
+ }
118
+ return out;
119
+ }
120
+ export function run(argv) {
121
+ const { values, positionals } = parseArgs({
122
+ args: argv,
123
+ allowPositionals: true,
124
+ options: {
125
+ to: { type: 'string' },
126
+ force: { type: 'boolean' },
127
+ find: { type: 'boolean' },
128
+ squash: { type: 'boolean' },
129
+ rewritten: { type: 'string' },
130
+ quiet: { type: 'boolean' },
131
+ },
132
+ });
133
+ // Hooks print only what they carried, prefixed so it's clear where it came from.
134
+ const report = (msgs) => { for (const m of msgs)
135
+ console.log(values.quiet ? `git-gloss: ${m}` : m); };
136
+ if (values.rewritten) {
137
+ report(rewritten(values.rewritten, readFileSync(0, 'utf8')));
138
+ return 0;
139
+ }
140
+ if (values.find) {
141
+ const out = [];
142
+ for (const target of (positionals.length ? positionals : ['HEAD']).map((p) => resolveCommit(p))) {
143
+ if (targetState(target) === 'glossed' && !values.force) {
144
+ if (!values.quiet)
145
+ out.push(`${target.slice(0, 7)} already has a gloss`);
146
+ continue;
147
+ }
148
+ const origins = findOrigins(target, { squash: values.squash });
149
+ if (!origins) {
150
+ if (!values.quiet)
151
+ out.push(`found no glossed commits that ${target.slice(0, 7)}'s changes came from` + (values.squash ? '' : ' (add --squash to look for a squashed branch)'));
152
+ continue;
153
+ }
154
+ const res = carry(origins, target);
155
+ if (res.written || !values.quiet)
156
+ out.push(res.message);
157
+ }
158
+ report(out);
159
+ return 0;
160
+ }
161
+ if (!positionals.length)
162
+ throw new GlossError('name the commits the changes came from, e.g. git gloss carry main..feature --to HEAD');
163
+ const target = resolveCommit(values.to ?? 'HEAD');
164
+ const state = targetState(target);
165
+ if (state === 'glossed' && !values.force) {
166
+ throw new GlossError(`${target.slice(0, 7)} already has a gloss; pass --force to replace it (the old one stays in the notes history)`);
167
+ }
168
+ const sources = positionals.flatMap((p) => resolveCommits(p).shas);
169
+ const res = carry(sources, target);
170
+ console.log(res.message);
171
+ return res.written ? 0 : 1;
172
+ }