@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.
@@ -0,0 +1,192 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parseArgs } from 'node:util';
3
+ import { git, gitTry, GitError, NOTES_REF } from '../git.js';
4
+ import { carryNew } from '../origins.js';
5
+ import { carry } from './carry.js';
6
+ export const usage = 'sync [<remote>] fetch, merge and push glosses (default remote: origin)';
7
+ const MAX_ATTEMPTS = 3;
8
+ // Map of annotated object → note blob for a notes ref, or empty if it doesn't exist.
9
+ function noteList(ref) {
10
+ const out = gitTry(['notes', '--ref', ref, 'list']) ?? '';
11
+ return new Map(out.split('\n').filter(Boolean).map((line) => {
12
+ const [blob, object] = line.split(' ');
13
+ return [object, blob];
14
+ }));
15
+ }
16
+ // created_at from a note, or null if the note isn't JSON we can read (for
17
+ // example one written by a newer git-gloss). Notes are never validated or
18
+ // rewritten here, so sync passes through formats it doesn't understand.
19
+ function createdAt(blob) {
20
+ try {
21
+ const value = JSON.parse(git(['cat-file', 'blob', blob]))?.created_at;
22
+ return typeof value === 'string' ? value : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ // The notes at a commit of a notes ref, e.g. the merge base. Notes trees fan
29
+ // paths out (ab/cdef…), so joining the path gives the annotated object.
30
+ function notesAt(commit) {
31
+ const out = git(['ls-tree', '-r', '--full-tree', commit]);
32
+ return new Map(out.split('\n').filter(Boolean).map((line) => {
33
+ const [meta, path] = line.split('\t');
34
+ return [path.replaceAll('/', ''), meta.split(' ')[2]];
35
+ }));
36
+ }
37
+ // Commits whose glosses may be shared with the remote: those it already has
38
+ // (reachable from its remote-tracking branches) and, from the pre-push hook,
39
+ // those being pushed now. Glosses of other commits (private branches, local
40
+ // work) stay local.
41
+ function shareable(remote, pushing) {
42
+ const tips = (gitTry(['for-each-ref', '--format=%(objectname)', `refs/remotes/${remote}/`]) ?? '').split('\n').filter(Boolean);
43
+ const starts = [...tips, ...pushing];
44
+ if (!starts.length)
45
+ return new Set();
46
+ return new Set(git(['rev-list', ...starts, '--']).split('\n').filter(Boolean));
47
+ }
48
+ const PUSH_REF = 'refs/notes/gloss-sync-tmp';
49
+ // One fetch → merge → push round. Returns null if the push lost a race with
50
+ // someone else's push and the round should be retried.
51
+ function round(remote, pushing, carried) {
52
+ const tracking = `refs/notes/remotes/${remote}/gloss`;
53
+ const remoteHas = gitTry(['ls-remote', '--exit-code', remote, NOTES_REF]) !== null;
54
+ if (remoteHas)
55
+ git(['fetch', '-q', remote, `+${NOTES_REF}:${tracking}`]);
56
+ const local = noteList(NOTES_REF);
57
+ const theirs = remoteHas ? noteList(tracking) : new Map();
58
+ // The last state both sides shared. A gloss changed on one side only is
59
+ // simply taken from that side (as the merge below does); only a gloss
60
+ // changed on both sides since then is a conflict.
61
+ const hasLocal = gitTry(['rev-parse', '-q', '--verify', NOTES_REF]) !== null;
62
+ const baseCommit = remoteHas && hasLocal ? gitTry(['merge-base', NOTES_REF, tracking])?.trim() : null;
63
+ const base = baseCommit ? notesAt(baseCommit) : new Map();
64
+ let fetched = 0;
65
+ const conflicts = [];
66
+ const takeTheirs = [];
67
+ for (const object of new Set([...local.keys(), ...theirs.keys()])) {
68
+ const [mine, other, was] = [local.get(object), theirs.get(object), base.get(object)];
69
+ if (mine === other)
70
+ continue;
71
+ if (mine === was) {
72
+ if (other !== undefined)
73
+ fetched++;
74
+ continue;
75
+ } // only they changed it
76
+ if (other === was)
77
+ continue; // only we changed it: pushed below if shareable
78
+ // Deleted on one side, changed on the other: keep the gloss rather than
79
+ // lose a correction. (-s ours alone would keep a local deletion.)
80
+ if (mine === undefined) {
81
+ takeTheirs.push([object, other]);
82
+ fetched++;
83
+ conflicts.push(`${object.slice(0, 10)}: kept theirs (you deleted it, they changed it)`);
84
+ continue;
85
+ }
86
+ if (other === undefined) {
87
+ conflicts.push(`${object.slice(0, 10)}: kept yours (they deleted it, you changed it)`);
88
+ continue;
89
+ }
90
+ // Both sides changed it: keep the newer gloss, since a re-gloss with
91
+ // --force is normally a correction. Ties and unreadable notes keep yours.
92
+ const [a, b] = [createdAt(mine), createdAt(other)];
93
+ const theirsNewer = a !== null && b !== null && b > a;
94
+ if (theirsNewer) {
95
+ takeTheirs.push([object, other]);
96
+ fetched++;
97
+ }
98
+ conflicts.push(`${object.slice(0, 10)}: kept ${theirsNewer ? 'theirs' : 'yours'}` +
99
+ (a === null || b === null ? " (couldn't read both glosses' dates)" : ' (newer)'));
100
+ }
101
+ if (remoteHas) {
102
+ if (!hasLocal) {
103
+ git(['update-ref', NOTES_REF, tracking]);
104
+ }
105
+ else {
106
+ // -s ours makes the remote history an ancestor of ours, so the push
107
+ // below fast-forwards; the newer-wins choices are applied on top.
108
+ git(['notes', '--ref', NOTES_REF, 'merge', '-q', '-s', 'ours', tracking]);
109
+ }
110
+ for (const [object, blob] of takeTheirs)
111
+ git(['notes', '--ref', NOTES_REF, 'add', '-f', '-C', blob, object]);
112
+ }
113
+ // Commits made on the remote by squash or rebase merges (or being pushed
114
+ // after a local squash or cherry-pick) get the gloss of the commits they
115
+ // came from, so it goes out with the rest.
116
+ const tips = (gitTry(['for-each-ref', '--format=%(objectname)', `refs/remotes/${remote}/`]) ?? '').split('\n').filter(Boolean);
117
+ carried.push(...carryNew([...tips, ...pushing], carry));
118
+ // What to send: glosses that differ from the remote's, of commits it may
119
+ // see. They are added on top of the remote's notes history in a scratch
120
+ // ref, so the push fast-forwards and carries nothing else; local history
121
+ // (with any withheld glosses) never leaves the machine.
122
+ const allowed = shareable(remote, pushing);
123
+ const merged = noteList(NOTES_REF);
124
+ const toPush = [...merged].filter(([object, blob]) => allowed.has(object) && theirs.get(object) !== blob);
125
+ const withheld = [...merged.keys()].filter((o) => !allowed.has(o) && !theirs.has(o)).length;
126
+ if (!toPush.length)
127
+ return { fetched, pushed: 0, withheld, conflicts };
128
+ try {
129
+ if (remoteHas)
130
+ git(['update-ref', PUSH_REF, tracking]);
131
+ else
132
+ gitTry(['update-ref', '-d', PUSH_REF]);
133
+ for (const [object, blob] of toPush)
134
+ git(['notes', '--ref', PUSH_REF, 'add', '-f', '-C', blob, object]);
135
+ try {
136
+ // --no-verify: don't run the pre-push hook for the notes push itself,
137
+ // which would call sync again.
138
+ git(['push', '-q', '--no-verify', remote, `${PUSH_REF}:${NOTES_REF}`]);
139
+ }
140
+ catch (err) {
141
+ if (/\(fetch first\)|non-fast-forward/.test(err.message))
142
+ return null;
143
+ throw err;
144
+ }
145
+ // Record what the remote now has, so the next sync's merge base is right.
146
+ git(['notes', '--ref', NOTES_REF, 'merge', '-q', '-s', 'ours', PUSH_REF]);
147
+ }
148
+ finally {
149
+ gitTry(['update-ref', '-d', PUSH_REF]);
150
+ }
151
+ return { fetched, pushed: toPush.length, withheld, conflicts };
152
+ }
153
+ export function run(argv) {
154
+ // --pushing (from the pre-push hook): read the refs being pushed from stdin,
155
+ // as git gives them to the hook, so glosses of those commits go too.
156
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { pushing: { type: 'boolean' } } });
157
+ if (values.pushing && process.stdin.isTTY)
158
+ console.error('note: --pushing reads the refs being pushed from stdin, as the pre-push hook gives them; ignoring it here');
159
+ const pushing = values.pushing && !process.stdin.isTTY
160
+ ? readFileSync(0, 'utf8').split('\n').map((l) => l.split(' ')[1]).filter((sha) => sha && !/^0+$/.test(sha))
161
+ : [];
162
+ const remote = positionals[0] ?? 'origin';
163
+ // Only configured remotes: the name becomes part of a ref and an argument,
164
+ // and a URL has no stable place to keep the remote's glosses.
165
+ const remotes = (gitTry(['remote']) ?? '').split('\n').filter(Boolean);
166
+ if (!remotes.includes(remote)) {
167
+ throw new GitError(`no remote named "${remote}"${remotes.length ? `; remotes are ${remotes.join(', ')}` : ''}`);
168
+ }
169
+ const carried = [];
170
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
171
+ const outcome = round(remote, pushing, carried);
172
+ if (!outcome)
173
+ continue;
174
+ const parts = [`fetched ${outcome.fetched}`, `pushed ${outcome.pushed}`];
175
+ if (!outcome.fetched && !outcome.pushed && !outcome.conflicts.length) {
176
+ console.log(`glosses already in sync with ${remote}`);
177
+ }
178
+ else {
179
+ console.log(`synced glosses with ${remote}: ${parts.join(', ')}` +
180
+ (outcome.conflicts.length ? `, ${outcome.conflicts.length} glossed on both sides` : ''));
181
+ for (const c of outcome.conflicts)
182
+ console.log(` ${c}`);
183
+ }
184
+ for (const c of carried)
185
+ console.log(c);
186
+ if (outcome.withheld) {
187
+ console.log(` kept ${outcome.withheld} gloss${outcome.withheld === 1 ? '' : 'es'} local: their commits aren't on ${remote} yet`);
188
+ }
189
+ return 0;
190
+ }
191
+ throw new GitError(`gave up after ${MAX_ATTEMPTS} attempts: others kept pushing glosses to ${remote}; try again`);
192
+ }
@@ -0,0 +1,132 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { commitDiff, git, gitTry, GlossError, NOTES_REF, resolveCommit, WIP_NOTES_REF, WIP_REF, writeNote } from '../git.js';
3
+ import { location, parseDiff } from '../diff.js';
4
+ import { mergeGlosses } from '../merge.js';
5
+ import { noteFormat } from '../schema.js';
6
+ import { refLabel } from '../selection.js';
7
+ export const usage = 'versions [<commit> | wip] [--show <n> | --restore <n>] every gloss a commit has had (replaced, carried, merged), newest first; wip: every gloss of uncommitted work, including dropped groups';
8
+ // Every change to the notes in `ref`, newest first: a note's history is the
9
+ // notes ref's history, so a replaced or removed gloss is still there. Merges
10
+ // are skipped; each side's own commits carry the versions they brought.
11
+ function history(ref, only) {
12
+ if (!gitTry(['rev-parse', '-q', '--verify', ref]))
13
+ return [];
14
+ const out = [];
15
+ // One notes commit's changes, per object. When git reorganises the notes
16
+ // tree (ab/cdef… directories once there are many notes), each note shows
17
+ // as removed from one path and added at another; that pair is one entry,
18
+ // and none at all if the gloss itself didn't change.
19
+ let at = '', how = '', changes = new Map();
20
+ const flush = () => {
21
+ for (const [object, c] of changes) {
22
+ if (c.removed && c.added && c.removed === c.added)
23
+ continue;
24
+ out.push({ object, blob: c.added ?? null, at, how });
25
+ }
26
+ changes = new Map();
27
+ };
28
+ for (const line of git(['log', '--no-merges', '--raw', '--no-abbrev', '--no-renames', '--format=%x00%cI%x00%s', ref, '--']).split('\n')) {
29
+ if (line.startsWith('\0')) {
30
+ flush();
31
+ [, at, how] = line.split('\0');
32
+ continue;
33
+ }
34
+ const m = /^:\d+ \d+ ([0-9a-f]+) ([0-9a-f]+) (\w)\t(.+)$/.exec(line);
35
+ if (!m)
36
+ continue;
37
+ // Notes are stored under the object's id, possibly split into directories.
38
+ const object = m[4].replaceAll('/', '');
39
+ if (only && object !== only)
40
+ continue;
41
+ const c = changes.get(object) ?? {};
42
+ if (m[3] === 'D')
43
+ c.removed = m[1];
44
+ else {
45
+ c.added = m[2];
46
+ if (m[3] === 'M')
47
+ c.removed = m[1];
48
+ }
49
+ changes.set(object, c);
50
+ }
51
+ flush();
52
+ return out;
53
+ }
54
+ function summary(blob) {
55
+ try {
56
+ const note = JSON.parse(git(['cat-file', 'blob', blob]));
57
+ const a = note.annotation;
58
+ const from = note.sources ? `carried from ${note.sources.map((s) => s.slice(0, 7)).join(', ')}; ` : '';
59
+ return `${from}${a.groups.length} group${a.groups.length === 1 ? '' : 's'}: ${a.groups.map((g) => g.id).join(', ')}\n task: ${JSON.stringify(a.task)}`;
60
+ }
61
+ catch {
62
+ return '(not a readable gloss: joined notes from a squash, or not JSON)';
63
+ }
64
+ }
65
+ const when = (iso) => iso.slice(0, 16).replace('T', ' ');
66
+ export function run(argv) {
67
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { show: { type: 'string' }, restore: { type: 'string' } } });
68
+ const wip = positionals[0] === 'wip';
69
+ const ref = wip ? WIP_NOTES_REF : NOTES_REF;
70
+ const sha = wip ? null : resolveCommit(positionals[0] ?? 'HEAD');
71
+ const versions = history(ref, sha ?? undefined);
72
+ const label = sha ? sha.slice(0, 7) : 'uncommitted work';
73
+ if (!versions.length) {
74
+ console.log(`no glosses recorded for ${label}`);
75
+ return 0;
76
+ }
77
+ const pick = (arg, flag) => {
78
+ const n = Number(arg);
79
+ const v = versions[n - 1];
80
+ if (!Number.isInteger(n) || !v)
81
+ throw new GlossError(`${flag} takes a version number from 1 to ${versions.length}`);
82
+ if (!v.blob)
83
+ throw new GlossError(`version ${n} records the gloss being removed; pick a version with a gloss`);
84
+ return { n, v: v };
85
+ };
86
+ if (values.show !== undefined) {
87
+ process.stdout.write(git(['cat-file', 'blob', pick(values.show, '--show').v.blob]));
88
+ return 0;
89
+ }
90
+ if (values.restore !== undefined) {
91
+ const { n, v } = pick(values.restore, '--restore');
92
+ if (!wip) {
93
+ git(['notes', '--ref', ref, 'add', '-f', '-C', v.blob, sha]);
94
+ console.log(`restored version ${n} as the gloss of ${label}`);
95
+ return 0;
96
+ }
97
+ // A wip version was written for the snapshot of its time. Carry it onto
98
+ // the current snapshot line by line, as `carry` does, so it describes the
99
+ // work as it is now; copy it as is only if that old snapshot is gone.
100
+ const onto = gitTry(['rev-parse', '-q', '--verify', WIP_REF])?.trim();
101
+ if (!onto)
102
+ throw new GlossError('there is no snapshot of uncommitted work to restore onto; run git gloss hunks wip first');
103
+ const note = JSON.parse(git(['cat-file', 'blob', v.blob]));
104
+ if (v.object === onto || gitTry(['cat-file', '-e', `${v.object}^{commit}`]) === null) {
105
+ git(['notes', '--ref', ref, 'add', '-f', '-C', v.blob, onto]);
106
+ console.log(`restored version ${n} onto the wip snapshot ${onto.slice(0, 7)}` +
107
+ (v.object === onto ? '' : `; its snapshot ${v.object.slice(0, 7)} is gone, so hunk references may not match: check with git gloss show wip`));
108
+ return 0;
109
+ }
110
+ const current = parseDiff(commitDiff(onto));
111
+ const merged = mergeGlosses([{ sha: v.object, note, hunks: parseDiff(commitDiff(v.object)) }], current);
112
+ if (!merged.annotation)
113
+ throw new GlossError(`none of version ${n}'s glossed changes are in the uncommitted work any more; read it with --show ${n}`);
114
+ writeNote(onto, {
115
+ gloss: noteFormat(merged.annotation), commit: onto, created_at: new Date().toISOString(),
116
+ annotation: merged.annotation, hunks: current.map(location), carried_from: v.object,
117
+ }, WIP_NOTES_REF);
118
+ console.log(`restored ${merged.annotation.groups.length} group${merged.annotation.groups.length === 1 ? '' : 's'} of version ${n} onto the wip snapshot ${onto.slice(0, 7)}` +
119
+ (merged.dropped.length ? `\n not restored (their changes are no longer in the work): ${merged.dropped.map((d) => d.split(':')[1]).join(', ')}` : '') +
120
+ (merged.gaps.length ? `\n changes it doesn't explain: ${merged.gaps.map((u) => refLabel(u.hunk.id, u.lines)).join(', ')}` : ''));
121
+ return 0;
122
+ }
123
+ console.log(`glosses of ${label}, newest first:`);
124
+ versions.forEach((v, i) => {
125
+ const head = `${String(i + 1).padStart(3)} ${when(v.at)} ${v.how}${wip ? ` (snapshot ${v.object.slice(0, 7)})` : ''}`;
126
+ console.log(`${head}${i === 0 && v.blob && !wip ? ' [current]' : ''}`);
127
+ console.log(` ${v.blob ? summary(v.blob) : 'removed'}`);
128
+ });
129
+ const arg = positionals[0] ?? 'HEAD';
130
+ console.log(`\nread one: git gloss versions ${arg} --show <n> bring one back: git gloss versions ${arg} --restore <n>`);
131
+ return 0;
132
+ }
@@ -0,0 +1,209 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { blameRange, commitSubject, lineHistory } from '../git.js';
3
+ import { loadGloss } from '../loader.js';
4
+ import { servesOf } from '../schema.js';
5
+ import { field, groupHeader, style } from '../render.js';
6
+ import { bodyLines, formatRanges } from '../selection.js';
7
+ export const usage = 'why <file>:<line>[-<end>] [--history] [--rev <commit>] [--json] explain why code exists';
8
+ // Glosses are loaded at most once per commit, however many lines or steps
9
+ // refer to it; each load diffs the commit.
10
+ const glossCache = new Map();
11
+ function glossFor(commit) {
12
+ if (!glossCache.has(commit))
13
+ glossCache.set(commit, loadGloss(commit));
14
+ return glossCache.get(commit);
15
+ }
16
+ // Groups in a gloss that own `line` of `file` on the new side of the commit's
17
+ // diff: they claim the whole hunk, or that line's changed-line number.
18
+ function groupsForLine(gloss, file, line) {
19
+ return (gloss?.groups ?? [])
20
+ .filter((g) => g.hunks.some(({ current: h, lines }) => {
21
+ if (!h || h.file !== file || line < h.newStart || line >= h.newStart + h.newLines)
22
+ return false;
23
+ if (!lines)
24
+ return true;
25
+ const idx = bodyLines(h).find((l) => l.kind === 'add' && l.newNo === line)?.idx;
26
+ return idx !== undefined && idx !== null && lines.has(idx);
27
+ }))
28
+ .map((g) => g.group);
29
+ }
30
+ function step(commit, file, lines) {
31
+ const gloss = glossFor(commit);
32
+ const groups = [...new Map(lines.flatMap((l) => groupsForLine(gloss, file, l)).map((g) => [g.id, g])).values()];
33
+ return { commit, subject: gloss?.subject ?? commitSubject(commit), file, lines, gloss, groups };
34
+ }
35
+ // A change whose only explanation is mechanical (rename, reformat) says
36
+ // nothing about why the code exists, so it is looked through.
37
+ const mechanicalOnly = (s) => s.groups.length > 0 && s.groups.every((g) => g.necessity === 'mechanical');
38
+ // Every group a group serves, directly or through others, nearest first.
39
+ // `add` rejects cycles; the seen set guards glosses written before it did.
40
+ function chain(gloss, g) {
41
+ const byId = new Map(gloss?.note.annotation.groups.map((x) => [x.id, x]) ?? []);
42
+ const seen = new Set([g.id]);
43
+ const out = [];
44
+ const queue = servesOf(g);
45
+ while (queue.length) {
46
+ const next = byId.get(queue.shift());
47
+ if (!next || seen.has(next.id))
48
+ continue;
49
+ seen.add(next.id);
50
+ out.push(next);
51
+ queue.push(...servesOf(next));
52
+ }
53
+ return out;
54
+ }
55
+ function explain(run, history) {
56
+ const { commit, origFile } = run[0];
57
+ const orig = run.map((b) => b.origLine);
58
+ const latest = step(commit, origFile, orig);
59
+ const lines = run.map((b) => b.line);
60
+ // The run is contiguous in the blamed commit, so git log -L tracks exactly
61
+ // these lines and not others between them.
62
+ const start = orig[0], end = orig[orig.length - 1];
63
+ if (!history && !mechanicalOnly(latest)) {
64
+ // Only whether older history exists, without walking it.
65
+ const more = (lineHistory(origFile, start, end, commit, 2)?.length ?? 0) > 1;
66
+ return { lines, latest, before: [], moreHistory: more, note: null };
67
+ }
68
+ const trail = lineHistory(origFile, start, end, commit) ?? [];
69
+ // Normally the trail starts at the blamed commit. If they disagree (blame
70
+ // can skip commits, e.g. with blame.ignoreRevsFile), keep the trail's older
71
+ // commits anyway and say so rather than drop them.
72
+ const at = trail.findIndex((t) => t.commit === commit);
73
+ const older = at >= 0 ? trail.slice(at + 1) : trail;
74
+ const note = trail.length && at !== 0 ? 'git log -L traced these lines from a different commit than blame named; the history may be incomplete' : null;
75
+ const before = [];
76
+ for (const t of older) {
77
+ if (!history && !mechanicalOnly(before.at(-1) ?? latest))
78
+ break;
79
+ before.push(step(t.commit, t.file, t.lines));
80
+ }
81
+ return { lines, latest, before, moreHistory: older.length > before.length, note };
82
+ }
83
+ const stepJson = (s) => ({
84
+ commit: s.commit,
85
+ subject: s.subject,
86
+ file: s.file,
87
+ lines_in_commit: formatRanges(s.lines),
88
+ glossed: s.gloss !== null,
89
+ stale: s.gloss?.stale ?? false,
90
+ task: s.gloss?.note.annotation.task ?? null,
91
+ // serves is always a list here, whichever form the gloss used.
92
+ groups: s.groups.map((g) => ({ ...g, serves: servesOf(g), serves_chain: chain(s.gloss, g).map((c) => c.id) })),
93
+ });
94
+ export function run(argv) {
95
+ const { values, positionals } = parseArgs({
96
+ args: argv,
97
+ allowPositionals: true,
98
+ options: { rev: { type: 'string' }, json: { type: 'boolean' }, history: { type: 'boolean' } },
99
+ });
100
+ const m = /^(.+):(\d+)(?:-(\d+))?$/.exec(positionals[0] ?? '');
101
+ if (!m || Number(m[2]) < 1 || (m[3] && Number(m[3]) < Number(m[2]))) {
102
+ console.error('usage: git gloss ' + usage);
103
+ return 2;
104
+ }
105
+ const [, file, startStr, endStr] = m;
106
+ const start = Number(startStr), end = Number(endStr ?? startStr);
107
+ // One blame for the whole range, then one explanation per contiguous run of
108
+ // lines (in the blamed commit) that a commit last changed, in the order the
109
+ // runs appear. A commit's lines can be split by others' changes, and each
110
+ // run's history has to be traced separately.
111
+ const blamed = blameRange(file, start, end, values.rev);
112
+ const uncommitted = [];
113
+ const byCommit = new Map();
114
+ for (const b of blamed) {
115
+ if (/^0+$/.test(b.commit)) {
116
+ uncommitted.push(b.line);
117
+ continue;
118
+ }
119
+ if (!byCommit.has(b.commit))
120
+ byCommit.set(b.commit, []);
121
+ byCommit.get(b.commit).push(b);
122
+ }
123
+ const runs = [];
124
+ for (const group of byCommit.values()) {
125
+ group.sort((x, y) => x.origLine - y.origLine);
126
+ for (const b of group) {
127
+ const last = runs.at(-1);
128
+ if (last && last[0].commit === b.commit && b.origLine === last[last.length - 1].origLine + 1)
129
+ last.push(b);
130
+ else
131
+ runs.push([b]);
132
+ }
133
+ }
134
+ runs.sort((x, y) => Math.min(...x.map((b) => b.line)) - Math.min(...y.map((b) => b.line)));
135
+ const explanations = runs.map((run) => explain(run, !!values.history));
136
+ const short = blamed.length < end - start + 1 ? `the file ends at line ${start + blamed.length - 1}` : null;
137
+ if (values.json) {
138
+ console.log(JSON.stringify({
139
+ file, start, end,
140
+ explanations: explanations.map((e) => ({
141
+ lines: formatRanges(e.lines),
142
+ ...stepJson(e.latest),
143
+ risks: e.latest.gloss?.note.annotation.risks ?? [],
144
+ alternatives: e.latest.gloss?.note.annotation.alternatives ?? [],
145
+ // Older commits: mechanical changes looked through by default, every
146
+ // change with --history.
147
+ before: e.before.map(stepJson),
148
+ more_history: e.moreHistory,
149
+ note: e.note,
150
+ })),
151
+ uncommitted: uncommitted.length ? formatRanges(uncommitted) : null,
152
+ note: short,
153
+ }, null, 2));
154
+ return 0;
155
+ }
156
+ const out = [];
157
+ const span = start === end ? `${start}` : `${start}-${end}`;
158
+ const renderStep = (s, label, older) => {
159
+ // Where the lines were in an older commit: after edits or a file rename
160
+ // that differs from the query.
161
+ const then = older || s.file !== file ? style('dim', ` (${s.file}:${formatRanges(s.lines)} then)`) : '';
162
+ out.push(`${label}${style('yellow', s.commit.slice(0, 10))} ${s.subject}${then}`);
163
+ if (!s.gloss) {
164
+ out.push(style('dim', ' no gloss for this commit'));
165
+ return;
166
+ }
167
+ out.push(...field('task:', s.gloss.note.annotation.task));
168
+ for (const w of s.gloss.warnings)
169
+ out.push(style(['bold', 'red'], ` ⚠ ${w}`));
170
+ if (!s.groups.length)
171
+ out.push(style('dim', ' these lines are not in an annotated hunk'));
172
+ for (const g of s.groups) {
173
+ out.push(...groupHeader(g).map((l) => ' ' + l));
174
+ for (const parent of chain(s.gloss, g))
175
+ out.push(` ${style('dim', '↳ which serves:')} ${parent.intent} ${style('dim', `(${parent.id})`)}`);
176
+ }
177
+ };
178
+ explanations.forEach((e, i) => {
179
+ if (i > 0)
180
+ out.push('');
181
+ const label = runs.length > 1 || start !== end
182
+ ? style('bold', `line${e.lines.length === 1 ? '' : 's'} ${formatRanges(e.lines)} `)
183
+ : '';
184
+ renderStep(e.latest, label, false);
185
+ // Risks and rejected alternatives cover the whole commit, not just these
186
+ // lines, so text output points at them instead of printing them here.
187
+ const a = e.latest.gloss?.note.annotation;
188
+ const extras = [a?.risks?.length && `${a.risks.length} risk${a.risks.length === 1 ? '' : 's'}`,
189
+ a?.alternatives?.length && `${a.alternatives.length} rejected alternative${a.alternatives.length === 1 ? '' : 's'}`].filter(Boolean);
190
+ if (extras.length)
191
+ out.push(style('dim', ` the commit also lists ${extras.join(' and ')}: git gloss show ${e.latest.commit.slice(0, 7)} --full`));
192
+ e.before.forEach((s, j) => {
193
+ const prev = j === 0 ? e.latest : e.before[j - 1];
194
+ out.push(style('dim', mechanicalOnly(prev) ? ' ↓ that change was mechanical; before it:' : ' ↓ before that:'));
195
+ renderStep(s, ' ', true);
196
+ });
197
+ if (e.note)
198
+ out.push(style('dim', ` ${e.note}`));
199
+ });
200
+ if (!values.history && explanations.some((e) => e.moreHistory)) {
201
+ out.push('', style('dim', `Earlier changes to these lines: git gloss why ${file}:${span}${values.rev ? ` --rev ${values.rev}` : ''} --history`));
202
+ }
203
+ if (short)
204
+ out.push('', style('dim', short));
205
+ if (uncommitted.length)
206
+ out.push('', style('dim', `lines ${formatRanges(uncommitted)} are not committed yet`));
207
+ console.log(out.join('\n'));
208
+ return 0;
209
+ }
package/dist/diff.js ADDED
@@ -0,0 +1,144 @@
1
+ import { createHash } from 'node:crypto';
2
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
3
+ const ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };
4
+ // git C-quotes paths with unusual characters: "q\"x.js", "a\tb", and octal
5
+ // bytes such as \303\251 for non-ASCII when core.quotePath is on.
6
+ export function unquote(quoted) {
7
+ const body = quoted.slice(1, -1);
8
+ const parts = [];
9
+ let last = 0;
10
+ for (const m of body.matchAll(/\\([0-7]{3}|.)/g)) {
11
+ parts.push(Buffer.from(body.slice(last, m.index), 'utf8'));
12
+ const e = m[1];
13
+ parts.push(Buffer.from([e.length === 3 ? parseInt(e, 8) : (ESCAPES[e] ?? e.charCodeAt(0))]));
14
+ last = m.index + m[0].length;
15
+ }
16
+ parts.push(Buffer.from(body.slice(last), 'utf8'));
17
+ return Buffer.concat(parts).toString('utf8');
18
+ }
19
+ // A path from a "--- a/x" or "+++ b/x" line: null for /dev/null, without the
20
+ // a/ b/ prefix, unquoted, and without the tab git appends to paths that
21
+ // contain spaces.
22
+ export function parsePath(raw) {
23
+ let p = raw.replace(/\t$/, '');
24
+ if (p === '/dev/null')
25
+ return null;
26
+ if (p.startsWith('"') && p.endsWith('"'))
27
+ p = unquote(p);
28
+ return p.replace(/^[ab]\//, '');
29
+ }
30
+ export function parseDiff(diff) {
31
+ const lines = diff.split('\n');
32
+ const hunks = [];
33
+ let file = null;
34
+ let i = 0;
35
+ const flush = () => {
36
+ if (!file)
37
+ return;
38
+ if (file.hunks.length === 0) {
39
+ // Still give header-only changes an id so they can be annotated.
40
+ hunks.push({
41
+ id: '',
42
+ file: file.newFile ?? file.oldFile ?? '?',
43
+ oldFile: file.oldFile,
44
+ oldStart: 0, oldLines: 0, newStart: 0, newLines: 0,
45
+ text: file.meta.join('\n'),
46
+ hash: '',
47
+ added: 0, removed: 0,
48
+ });
49
+ }
50
+ hunks.push(...file.hunks);
51
+ file = null;
52
+ };
53
+ while (i < lines.length) {
54
+ const line = lines[i];
55
+ if (line.startsWith('diff --git ')) {
56
+ flush();
57
+ const m = /^diff --git a\/(.*) b\/(.*)$/.exec(line);
58
+ file = { oldFile: m?.[1] ?? null, newFile: m?.[2] ?? null, meta: [line], hunks: [] };
59
+ i++;
60
+ continue;
61
+ }
62
+ if (!file) {
63
+ i++;
64
+ continue;
65
+ }
66
+ if (line.startsWith('--- ')) {
67
+ file.oldFile = parsePath(line.slice(4));
68
+ file.meta.push(line);
69
+ i++;
70
+ continue;
71
+ }
72
+ if (line.startsWith('+++ ')) {
73
+ file.newFile = parsePath(line.slice(4));
74
+ file.meta.push(line);
75
+ i++;
76
+ continue;
77
+ }
78
+ if (line.startsWith('new file mode')) {
79
+ file.oldFile = null;
80
+ }
81
+ if (line.startsWith('deleted file mode')) {
82
+ file.newFile = null;
83
+ }
84
+ if (line.startsWith('rename from '))
85
+ file.oldFile = line.slice(12);
86
+ if (line.startsWith('rename to '))
87
+ file.newFile = line.slice(10);
88
+ const h = HUNK_HEADER.exec(line);
89
+ if (!h) {
90
+ file.meta.push(line);
91
+ i++;
92
+ continue;
93
+ }
94
+ const oldStart = Number(h[1]), oldLines = h[2] === undefined ? 1 : Number(h[2]);
95
+ const newStart = Number(h[3]), newLines = h[4] === undefined ? 1 : Number(h[4]);
96
+ const body = [line];
97
+ let oldLeft = oldLines, newLeft = newLines, added = 0, removed = 0;
98
+ i++;
99
+ // Consume by line counts, not by prefix: a removed line "-- x" looks like a
100
+ // "--- " file header and would otherwise end the hunk early.
101
+ while (i < lines.length && (oldLeft > 0 || newLeft > 0 || lines[i].startsWith('\\'))) {
102
+ const l = lines[i];
103
+ if (l.startsWith('+')) {
104
+ newLeft--;
105
+ added++;
106
+ }
107
+ else if (l.startsWith('-')) {
108
+ oldLeft--;
109
+ removed++;
110
+ }
111
+ else if (l.startsWith('\\')) { /* "" */ }
112
+ else {
113
+ oldLeft--;
114
+ newLeft--;
115
+ }
116
+ body.push(l);
117
+ i++;
118
+ }
119
+ file.hunks.push({
120
+ id: '',
121
+ file: file.newFile ?? file.oldFile ?? '?',
122
+ oldFile: file.oldFile,
123
+ oldStart, oldLines, newStart, newLines,
124
+ text: body.join('\n'),
125
+ hash: '',
126
+ added, removed,
127
+ });
128
+ }
129
+ flush();
130
+ hunks.forEach((h, n) => {
131
+ h.id = `h${n + 1}`;
132
+ // Hash only what the hunk changes, so a rebase that shifts line numbers or
133
+ // context lines doesn't make an otherwise identical hunk look different.
134
+ const body = h.text.startsWith('@@')
135
+ ? h.text.split('\n').slice(1).filter((l) => l[0] === '+' || l[0] === '-' || l[0] === '\\').join('\n')
136
+ : h.text;
137
+ h.hash = createHash('sha256').update(h.file + '\0' + body).digest('hex').slice(0, 16);
138
+ });
139
+ return hunks;
140
+ }
141
+ export function location(h) {
142
+ const { id, file, oldFile, oldStart, oldLines, newStart, newLines, hash } = h;
143
+ return { id, file, oldFile, oldStart, oldLines, newStart, newLines, hash };
144
+ }