@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.
@@ -0,0 +1,210 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { commitSubject, gitTry, readNote, resolveCommits } from '../git.js';
3
+ import { refLabel, sliceStats, sliceText } from '../selection.js';
4
+ import { loadGloss } from '../loader.js';
5
+ import { NECESSITY } from '../schema.js';
6
+ import { current, reviewStatus, statusPhrase, verdict } from '../reviews.js';
7
+ import { colorDiff, field, groupHeader, hunkLabel, necessityWord, noteFooter, page, style, termWidth, wrap, } from '../render.js';
8
+ export const usage = 'show [<commit> | <range>] [-g <group>]... [--full] [--diff] [--only <necessity,...>] summarise a gloss, or each commit in a range';
9
+ // Counts over the lines each part selects, not whole hunks, so groups that
10
+ // split a hunk don't both count all of it.
11
+ function stats(parts) {
12
+ const s = parts.map((p) => sliceStats(p.hunk, p.lines));
13
+ return {
14
+ count: parts.length,
15
+ added: s.reduce((n, x) => n + x.added, 0),
16
+ removed: s.reduce((n, x) => n + x.removed, 0),
17
+ };
18
+ }
19
+ const currentParts = (g) => g.hunks.flatMap((h) => (h.current ? [{ hunk: h.current, lines: h.lines }] : []));
20
+ export function run(argv) {
21
+ const { values, positionals } = parseArgs({
22
+ args: argv,
23
+ allowPositionals: true,
24
+ options: {
25
+ group: { type: 'string', short: 'g', multiple: true },
26
+ full: { type: 'boolean' },
27
+ diff: { type: 'boolean' },
28
+ only: { type: 'string' },
29
+ },
30
+ });
31
+ const only = values.only?.split(',').map((s) => s.trim());
32
+ const unknown = only?.filter((n) => !NECESSITY.includes(n));
33
+ if (unknown?.length) {
34
+ console.error(`error: unknown necessity ${unknown.join(', ')}; expected ${NECESSITY.join(', ')}`);
35
+ return 2;
36
+ }
37
+ const { shas, merges, range } = resolveCommits(positionals[0] ?? 'HEAD');
38
+ if (!range) {
39
+ const gloss = loadGloss(shas[0]);
40
+ if (!gloss) {
41
+ console.error(`${shas[0].slice(0, 10)} has no gloss (annotate it with \`git gloss add\`)`);
42
+ return 1;
43
+ }
44
+ const sha = shas[0];
45
+ const ids = gloss.groups.map((g) => g.group.id);
46
+ const missing = values.group?.filter((id) => !ids.includes(id));
47
+ if (missing?.length) {
48
+ // Usually the group is in an earlier commit's gloss, e.g. one shown just
49
+ // before HEAD moved. Look a little way back for one that has them all.
50
+ const earlier = (gitTry(['rev-list', '--max-count=30', `${sha}~1`]) ?? '').split('\n').filter(Boolean).find((c) => {
51
+ try {
52
+ const groups = readNote(c)?.annotation.groups.map((g) => g.id) ?? [];
53
+ return missing.every((id) => groups.includes(id));
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ });
59
+ const flags = missing.map((id) => `-g ${id}`).join(' ');
60
+ console.error(`error: the gloss on ${sha.slice(0, 7)} (${gloss.subject}) has no group ${missing.join(', ')}; its groups are ${ids.join(', ')}` +
61
+ (earlier ? `\n found in ${earlier.slice(0, 7)} (${commitSubject(earlier)}): git gloss show ${earlier.slice(0, 7)} ${flags}` : ''));
62
+ return 2;
63
+ }
64
+ const out = renderCommit(sha, gloss, values, only, true);
65
+ if (!out) {
66
+ console.error(`no groups match ${[values.group && `-g ${values.group.join(',')}`, only && `--only ${only.join(',')}`].filter(Boolean).join(' and ')}`);
67
+ return 1;
68
+ }
69
+ page(out.join('\n'));
70
+ return 0;
71
+ }
72
+ // A range: each commit in turn, oldest first. Group ids repeat across
73
+ // commits, so -g is for single commits.
74
+ if (values.group) {
75
+ console.error('error: -g needs a single commit; pick one from the range, e.g. git gloss show <commit> -g <group>');
76
+ return 2;
77
+ }
78
+ const glosses = shas.map((sha) => loadGloss(sha));
79
+ const glossed = glosses.filter(Boolean).length;
80
+ const stale = glosses.filter((g) => g?.stale).length;
81
+ // How many glossed commits had their gloss checked: an honesty review that
82
+ // saw this code and this gloss.
83
+ const honesty = shas.map((sha, i) => (glosses[i] ? reviewStatus(sha).find((r) => r.stage === 'honesty' && current(r)) : undefined));
84
+ const vetted = honesty.filter(Boolean).length;
85
+ const flagged = honesty.filter((r) => r && verdict(r) === 'issues').length;
86
+ const out = [
87
+ style('bold', `${range}: ${shas.length} commit${shas.length === 1 ? '' : 's'}, ${glossed} glossed`) +
88
+ (glossed ? style(vetted === glossed ? 'green' : 'yellow', `, ${vetted} honesty-reviewed`) : '') +
89
+ (flagged ? style('yellow', ` (${flagged} with issues)`) : '') +
90
+ (stale ? style('red', ` (${stale} stale)`) : '') +
91
+ (merges.length ? style('dim', ` (${merges.length} merge${merges.length === 1 ? '' : 's'} not shown)`) : ''),
92
+ ];
93
+ shas.forEach((sha, i) => {
94
+ out.push('', style('dim', '─'.repeat(Math.min(termWidth(), 60))));
95
+ const gloss = glosses[i];
96
+ if (!gloss) {
97
+ out.push(`${style('yellow', sha.slice(0, 10))} ${style('bold', commitSubject(sha))}`, style('dim', 'no gloss for this commit'));
98
+ return;
99
+ }
100
+ // An --only filter can leave a commit with nothing to show; say so and move on.
101
+ out.push(...(renderCommit(sha, gloss, values, only, false) ??
102
+ [`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`, style('dim', `no ${only?.join('/')} groups`)]));
103
+ });
104
+ if (glossed)
105
+ out.push('', style('dim', 'One commit in detail: git gloss show <commit> --full (or -g <group> for its code)'));
106
+ page(out.join('\n'));
107
+ return 0;
108
+ }
109
+ // One line on whether the commit as it is now was reviewed, per stage.
110
+ function reviewsLine(sha) {
111
+ const statuses = reviewStatus(sha);
112
+ if (!statuses.length)
113
+ return style('dim', 'reviews: none recorded');
114
+ const color = (s) => (!current(s) || verdict(s) === 'issues' ? 'yellow' : verdict(s) === 'clean' ? 'green' : 'dim');
115
+ return `reviews: ${statuses.map((s) => style(color(s), statusPhrase(s))).join(style('dim', ' · '))}` +
116
+ style('dim', ` (findings: git gloss reviews ${sha.slice(0, 7)})`);
117
+ }
118
+ // One commit's view, or null if no group matches -g/--only. `hints` adds the
119
+ // footer on how to expand, which a range prints once instead.
120
+ function renderCommit(sha, gloss, values, only, hints) {
121
+ const a = gloss.note.annotation;
122
+ const short = sha.slice(0, 7);
123
+ const out = [`${style('yellow', sha.slice(0, 10))} ${style('bold', gloss.subject)}`];
124
+ out.push(...field('task:', a.task, 0));
125
+ if (a.summary)
126
+ out.push(...field('summary:', a.summary, 0));
127
+ for (const w of gloss.warnings)
128
+ out.push(...field('⚠', w, 0).map((l) => style('red', l)));
129
+ out.push(reviewsLine(sha));
130
+ const groups = gloss.groups
131
+ .filter((g) => !only || only.includes(g.group.necessity))
132
+ .filter((g) => !values.group || values.group.includes(g.group.id));
133
+ if (!groups.length)
134
+ return null;
135
+ // -g: chosen groups with their code. --full: every group's details, plus
136
+ // alternatives and risks. --diff: --full plus code.
137
+ if (values.group || values.full || values.diff) {
138
+ const withCode = values.diff || !!values.group;
139
+ for (const g of groups) {
140
+ out.push('', ...groupHeader(g.group));
141
+ if (!withCode)
142
+ continue;
143
+ for (const h of g.hunks) {
144
+ if (!h.current) {
145
+ out.push('', style('red', ` ${h.id}: no longer in this commit's diff`));
146
+ continue;
147
+ }
148
+ if (h.status === 'changed')
149
+ out.push('', style('red', ` ${h.id}: content changed since it was glossed`));
150
+ out.push('', ' ' + hunkLabel(h.current, h.lines), colorDiff(sliceText(h.current, h.lines), ' '));
151
+ }
152
+ }
153
+ if (gloss.unannotated.length && !only && !values.group) {
154
+ out.push('', style(['bold', 'red'], '■ Unannotated changes'));
155
+ for (const u of gloss.unannotated) {
156
+ out.push(' ' + hunkLabel(u.hunk, u.lines));
157
+ if (withCode)
158
+ out.push(colorDiff(sliceText(u.hunk, u.lines), ' '), '');
159
+ }
160
+ }
161
+ if (!values.group)
162
+ out.push(...noteFooter(gloss.note));
163
+ return out;
164
+ }
165
+ // Summary: one line per group.
166
+ const sorted = [...groups].sort((x, y) => NECESSITY.indexOf(x.group.necessity) - NECESSITY.indexOf(y.group.necessity));
167
+ const tally = NECESSITY.map((n) => [n, gloss.groups.filter((g) => g.group.necessity === n).length])
168
+ .filter(([, c]) => c > 0)
169
+ .map(([n, c]) => `${c} ${necessityWord(n)}`);
170
+ const showing = groups.length < gloss.groups.length ? style('dim', ` (showing ${groups.length}: --only ${only?.join(',')})`) : '';
171
+ out.push('', `${style('bold', `${gloss.groups.length} groups:`)} ${tally.join(style('dim', ' · '))}${showing}`, '');
172
+ const rows = sorted.map((g) => {
173
+ const s = stats(currentParts(g));
174
+ const stale = g.hunks.filter((h) => h.status !== 'ok').length;
175
+ return {
176
+ g, stale,
177
+ stat: `${s.count} hunk${s.count === 1 ? '' : 's'}`, added: `+${s.added}`, removed: `-${s.removed}`,
178
+ };
179
+ });
180
+ const un = stats(gloss.unannotated.map((u) => ({ hunk: u.hunk, lines: u.lines })));
181
+ const idW = Math.max(...rows.map((r) => r.g.group.id.length));
182
+ const statW = Math.max(...rows.map((r) => r.stat.length), un.count ? 8 : 0);
183
+ const addW = Math.max(...rows.map((r) => r.added.length));
184
+ const remW = Math.max(...rows.map((r) => r.removed.length));
185
+ const intentCol = 2 + 11 + 2 + idW + 2;
186
+ const statsW = 2 + statW + 1 + addW + 1 + remW;
187
+ const intentW = Math.max(20, termWidth() - intentCol - statsW);
188
+ for (const r of rows) {
189
+ // Intents wrap within their column rather than being cut off; the stats
190
+ // stay on the first line so the numbers still line up.
191
+ const [first, ...rest] = wrap(r.g.group.intent, intentW);
192
+ out.push(` ${necessityWord(r.g.group.necessity, 11)} ${style('bold', r.g.group.id.padEnd(idW))} ` +
193
+ `${first.padEnd(intentW)} ` +
194
+ style('dim', r.stat.padStart(statW)) + ' ' + style('green', r.added.padStart(addW)) + ' ' + style('red', r.removed.padStart(remW)), ...rest.map((l) => ' '.repeat(intentCol) + l));
195
+ if (r.stale) {
196
+ out.push(' '.repeat(intentCol) + style('red', `⚠ ${r.stale} of ${r.g.hunks.length} hunk(s) changed or gone since glossing`));
197
+ }
198
+ }
199
+ if (un.count && !only) {
200
+ const where = gloss.unannotated.map((u) => refLabel(u.hunk.id, u.lines)).join(', ');
201
+ out.push(` ${style(['bold', 'red'], 'unannotated'.padEnd(11))} ${style('red', `+${un.added} -${un.removed} not in any group: ${where}`)}`);
202
+ }
203
+ // Rejected alternatives and risks: short, and often what a reader most
204
+ // lacks, so they're in the summary rather than only behind --full.
205
+ out.push(...noteFooter(gloss.note));
206
+ if (hints) {
207
+ out.push('', style('dim', `Expand a group: git gloss show ${short} -g <group>`), style('dim', `Everything: --full (each group's evidence and notes) --diff (and code)`));
208
+ }
209
+ return out;
210
+ }
@@ -0,0 +1,245 @@
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 { REVIEWS_REF } from '../reviews.js';
6
+ import { carry } from './carry.js';
7
+ export const usage = 'sync [<remote>] fetch, merge and push glosses (default remote: origin)';
8
+ const MAX_ATTEMPTS = 3;
9
+ // Map of annotated object → note blob for a notes ref, or empty if it doesn't exist.
10
+ function noteList(ref) {
11
+ const out = gitTry(['notes', '--ref', ref, 'list']) ?? '';
12
+ return new Map(out.split('\n').filter(Boolean).map((line) => {
13
+ const [blob, object] = line.split(' ');
14
+ return [object, blob];
15
+ }));
16
+ }
17
+ // created_at from a note, or null if the note isn't JSON we can read (for
18
+ // example one written by a newer git-gloss). Notes are never validated or
19
+ // rewritten here, so sync passes through formats it doesn't understand.
20
+ function createdAt(blob) {
21
+ try {
22
+ const value = JSON.parse(git(['cat-file', 'blob', blob]))?.created_at;
23
+ return typeof value === 'string' ? value : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ // The notes at a commit of a notes ref, e.g. the merge base. Notes trees fan
30
+ // paths out (ab/cdef…), so joining the path gives the annotated object.
31
+ function notesAt(commit) {
32
+ const out = git(['ls-tree', '-r', '--full-tree', commit]);
33
+ return new Map(out.split('\n').filter(Boolean).map((line) => {
34
+ const [meta, path] = line.split('\t');
35
+ return [path.replaceAll('/', ''), meta.split(' ')[2]];
36
+ }));
37
+ }
38
+ // Commits whose glosses may be shared with the remote: those it already has
39
+ // (reachable from its remote-tracking branches) and, from the pre-push hook,
40
+ // those being pushed now. Glosses of other commits (private branches, local
41
+ // work) stay local.
42
+ function shareable(remote, pushing) {
43
+ const tips = (gitTry(['for-each-ref', '--format=%(objectname)', `refs/remotes/${remote}/`]) ?? '').split('\n').filter(Boolean);
44
+ const starts = [...tips, ...pushing];
45
+ if (!starts.length)
46
+ return new Set();
47
+ return new Set(git(['rev-list', ...starts, '--']).split('\n').filter(Boolean));
48
+ }
49
+ const PUSH_REF = 'refs/notes/gloss-sync-tmp';
50
+ // One fetch → merge → push round. Returns null if the push lost a race with
51
+ // someone else's push and the round should be retried.
52
+ function round(remote, pushing, carried) {
53
+ const tracking = `refs/notes/remotes/${remote}/gloss`;
54
+ const remoteHas = gitTry(['ls-remote', '--exit-code', remote, NOTES_REF]) !== null;
55
+ if (remoteHas)
56
+ git(['fetch', '-q', remote, `+${NOTES_REF}:${tracking}`]);
57
+ const local = noteList(NOTES_REF);
58
+ const theirs = remoteHas ? noteList(tracking) : new Map();
59
+ // The last state both sides shared. A gloss changed on one side only is
60
+ // simply taken from that side (as the merge below does); only a gloss
61
+ // changed on both sides since then is a conflict.
62
+ const hasLocal = gitTry(['rev-parse', '-q', '--verify', NOTES_REF]) !== null;
63
+ const baseCommit = remoteHas && hasLocal ? gitTry(['merge-base', NOTES_REF, tracking])?.trim() : null;
64
+ const base = baseCommit ? notesAt(baseCommit) : new Map();
65
+ let fetched = 0;
66
+ const conflicts = [];
67
+ const takeTheirs = [];
68
+ for (const object of new Set([...local.keys(), ...theirs.keys()])) {
69
+ const [mine, other, was] = [local.get(object), theirs.get(object), base.get(object)];
70
+ if (mine === other)
71
+ continue;
72
+ if (mine === was) {
73
+ if (other !== undefined)
74
+ fetched++;
75
+ continue;
76
+ } // only they changed it
77
+ if (other === was)
78
+ continue; // only we changed it: pushed below if shareable
79
+ // Deleted on one side, changed on the other: keep the gloss rather than
80
+ // lose a correction. (-s ours alone would keep a local deletion.)
81
+ if (mine === undefined) {
82
+ takeTheirs.push([object, other]);
83
+ fetched++;
84
+ conflicts.push(`${object.slice(0, 10)}: kept theirs (you deleted it, they changed it)`);
85
+ continue;
86
+ }
87
+ if (other === undefined) {
88
+ conflicts.push(`${object.slice(0, 10)}: kept yours (they deleted it, you changed it)`);
89
+ continue;
90
+ }
91
+ // Both sides changed it: keep the newer gloss, since a re-gloss with
92
+ // --force is normally a correction. Ties and unreadable notes keep yours.
93
+ const [a, b] = [createdAt(mine), createdAt(other)];
94
+ const theirsNewer = a !== null && b !== null && b > a;
95
+ if (theirsNewer) {
96
+ takeTheirs.push([object, other]);
97
+ fetched++;
98
+ }
99
+ conflicts.push(`${object.slice(0, 10)}: kept ${theirsNewer ? 'theirs' : 'yours'}` +
100
+ (a === null || b === null ? " (couldn't read both glosses' dates)" : ' (newer)'));
101
+ }
102
+ if (remoteHas) {
103
+ if (!hasLocal) {
104
+ git(['update-ref', NOTES_REF, tracking]);
105
+ }
106
+ else {
107
+ // -s ours makes the remote history an ancestor of ours, so the push
108
+ // below fast-forwards; the newer-wins choices are applied on top.
109
+ git(['notes', '--ref', NOTES_REF, 'merge', '-q', '-s', 'ours', tracking]);
110
+ }
111
+ for (const [object, blob] of takeTheirs)
112
+ git(['notes', '--ref', NOTES_REF, 'add', '-f', '-C', blob, object]);
113
+ }
114
+ // Commits made on the remote by squash or rebase merges (or being pushed
115
+ // after a local squash or cherry-pick) get the gloss of the commits they
116
+ // came from, so it goes out with the rest.
117
+ const tips = (gitTry(['for-each-ref', '--format=%(objectname)', `refs/remotes/${remote}/`]) ?? '').split('\n').filter(Boolean);
118
+ carried.push(...carryNew([...tips, ...pushing], carry));
119
+ // What to send: glosses that differ from the remote's, of commits it may
120
+ // see. They are added on top of the remote's notes history in a scratch
121
+ // ref, so the push fast-forwards and carries nothing else; local history
122
+ // (with any withheld glosses) never leaves the machine.
123
+ const allowed = shareable(remote, pushing);
124
+ const merged = noteList(NOTES_REF);
125
+ const toPush = [...merged].filter(([object, blob]) => allowed.has(object) && theirs.get(object) !== blob);
126
+ const withheld = [...merged.keys()].filter((o) => !allowed.has(o) && !theirs.has(o)).length;
127
+ if (!toPush.length)
128
+ return { fetched, pushed: 0, withheld, conflicts };
129
+ try {
130
+ if (remoteHas)
131
+ git(['update-ref', PUSH_REF, tracking]);
132
+ else
133
+ gitTry(['update-ref', '-d', PUSH_REF]);
134
+ for (const [object, blob] of toPush)
135
+ git(['notes', '--ref', PUSH_REF, 'add', '-f', '-C', blob, object]);
136
+ try {
137
+ // --no-verify: don't run the pre-push hook for the notes push itself,
138
+ // which would call sync again.
139
+ git(['push', '-q', '--no-verify', remote, `${PUSH_REF}:${NOTES_REF}`]);
140
+ }
141
+ catch (err) {
142
+ if (/\(fetch first\)|non-fast-forward/.test(err.message))
143
+ return null;
144
+ throw err;
145
+ }
146
+ // Record what the remote now has, so the next sync's merge base is right.
147
+ git(['notes', '--ref', NOTES_REF, 'merge', '-q', '-s', 'ours', PUSH_REF]);
148
+ }
149
+ finally {
150
+ gitTry(['update-ref', '-d', PUSH_REF]);
151
+ }
152
+ return { fetched, pushed: toPush.length, withheld, conflicts };
153
+ }
154
+ const REVIEWS_PUSH_REF = 'refs/notes/gloss-reviews-sync-tmp';
155
+ // Reviews are shared like glosses, but never conflict: each review is a line,
156
+ // and both sides' lines are kept (git's cat_sort_uniq). Only reviews of
157
+ // commits the remote may see are sent. Returns null if the push lost a race.
158
+ function syncReviews(remote, pushing) {
159
+ const tracking = `refs/notes/remotes/${remote}/gloss-reviews`;
160
+ const remoteHas = gitTry(['ls-remote', '--exit-code', remote, REVIEWS_REF]) !== null;
161
+ if (remoteHas)
162
+ git(['fetch', '-q', remote, `+${REVIEWS_REF}:${tracking}`]);
163
+ const before = noteList(REVIEWS_REF);
164
+ const theirs = remoteHas ? noteList(tracking) : new Map();
165
+ if (remoteHas) {
166
+ if (gitTry(['rev-parse', '-q', '--verify', REVIEWS_REF]) === null)
167
+ git(['update-ref', REVIEWS_REF, tracking]);
168
+ else
169
+ git(['notes', '--ref', REVIEWS_REF, 'merge', '-q', '-s', 'cat_sort_uniq', tracking]);
170
+ }
171
+ const merged = noteList(REVIEWS_REF);
172
+ const fetched = [...merged].filter(([object, blob]) => before.get(object) !== blob).length;
173
+ const allowed = shareable(remote, pushing);
174
+ const toPush = [...merged].filter(([object, blob]) => allowed.has(object) && theirs.get(object) !== blob);
175
+ if (!toPush.length)
176
+ return { fetched, pushed: 0 };
177
+ try {
178
+ if (remoteHas)
179
+ git(['update-ref', REVIEWS_PUSH_REF, tracking]);
180
+ else
181
+ gitTry(['update-ref', '-d', REVIEWS_PUSH_REF]);
182
+ for (const [object, blob] of toPush)
183
+ git(['notes', '--ref', REVIEWS_PUSH_REF, 'add', '-f', '-C', blob, object]);
184
+ try {
185
+ git(['push', '-q', '--no-verify', remote, `${REVIEWS_PUSH_REF}:${REVIEWS_REF}`]);
186
+ }
187
+ catch (err) {
188
+ if (/\(fetch first\)|non-fast-forward/.test(err.message))
189
+ return null;
190
+ throw err;
191
+ }
192
+ git(['notes', '--ref', REVIEWS_REF, 'merge', '-q', '-s', 'cat_sort_uniq', REVIEWS_PUSH_REF]);
193
+ }
194
+ finally {
195
+ gitTry(['update-ref', '-d', REVIEWS_PUSH_REF]);
196
+ }
197
+ return { fetched, pushed: toPush.length };
198
+ }
199
+ export function run(argv) {
200
+ // --pushing (from the pre-push hook): read the refs being pushed from stdin,
201
+ // as git gives them to the hook, so glosses of those commits go too.
202
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { pushing: { type: 'boolean' } } });
203
+ if (values.pushing && process.stdin.isTTY)
204
+ console.error('note: --pushing reads the refs being pushed from stdin, as the pre-push hook gives them; ignoring it here');
205
+ const pushing = values.pushing && !process.stdin.isTTY
206
+ ? readFileSync(0, 'utf8').split('\n').map((l) => l.split(' ')[1]).filter((sha) => sha && !/^0+$/.test(sha))
207
+ : [];
208
+ const remote = positionals[0] ?? 'origin';
209
+ // Only configured remotes: the name becomes part of a ref and an argument,
210
+ // and a URL has no stable place to keep the remote's glosses.
211
+ const remotes = (gitTry(['remote']) ?? '').split('\n').filter(Boolean);
212
+ if (!remotes.includes(remote)) {
213
+ throw new GitError(`no remote named "${remote}"${remotes.length ? `; remotes are ${remotes.join(', ')}` : ''}`);
214
+ }
215
+ const carried = [];
216
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
217
+ const outcome = round(remote, pushing, carried);
218
+ if (!outcome)
219
+ continue;
220
+ const parts = [`fetched ${outcome.fetched}`, `pushed ${outcome.pushed}`];
221
+ if (!outcome.fetched && !outcome.pushed && !outcome.conflicts.length) {
222
+ console.log(`glosses already in sync with ${remote}`);
223
+ }
224
+ else {
225
+ console.log(`synced glosses with ${remote}: ${parts.join(', ')}` +
226
+ (outcome.conflicts.length ? `, ${outcome.conflicts.length} glossed on both sides` : ''));
227
+ for (const c of outcome.conflicts)
228
+ console.log(` ${c}`);
229
+ }
230
+ for (const c of carried)
231
+ console.log(c);
232
+ let reviews = null;
233
+ for (let tries = 0; tries < MAX_ATTEMPTS && !reviews; tries++)
234
+ reviews = syncReviews(remote, pushing);
235
+ if (!reviews)
236
+ console.log(` reviews not synced: others kept pushing reviews to ${remote}; run git gloss sync again`);
237
+ else if (reviews.fetched || reviews.pushed)
238
+ console.log(` reviews: fetched ${reviews.fetched}, pushed ${reviews.pushed}`);
239
+ if (outcome.withheld) {
240
+ console.log(` kept ${outcome.withheld} gloss${outcome.withheld === 1 ? '' : 'es'} local: their commits aren't on ${remote} yet`);
241
+ }
242
+ return 0;
243
+ }
244
+ throw new GitError(`gave up after ${MAX_ATTEMPTS} attempts: others kept pushing glosses to ${remote}; try again`);
245
+ }
@@ -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
+ }