@ok-ggwp/git-gloss 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +160 -2
- package/bin/git-gloss.js +15 -0
- package/dist/carry.js +56 -0
- package/dist/cli.js +48 -0
- package/dist/commands/add.js +78 -0
- package/dist/commands/adopt.js +93 -0
- package/dist/commands/carry.js +175 -0
- package/dist/commands/html.js +45 -0
- package/dist/commands/hunks.js +64 -0
- package/dist/commands/init.js +142 -0
- package/dist/commands/review.js +118 -0
- package/dist/commands/reviews.js +70 -0
- package/dist/commands/schema.js +7 -0
- package/dist/commands/show.js +210 -0
- package/dist/commands/sync.js +245 -0
- package/dist/commands/versions.js +132 -0
- package/dist/commands/why.js +209 -0
- package/dist/diff.js +144 -0
- package/dist/git.js +315 -0
- package/dist/html/client.js +383 -0
- package/dist/html/data.js +147 -0
- package/dist/html/page.js +35 -0
- package/dist/html/style.css +229 -0
- package/dist/loader.js +62 -0
- package/dist/merge.js +191 -0
- package/dist/origins.js +156 -0
- package/dist/render.js +131 -0
- package/dist/reviews.js +205 -0
- package/dist/schema.js +75 -0
- package/dist/selection.js +147 -0
- package/dist/validate.js +85 -0
- package/package.json +50 -4
- package/skills/gloss/SKILL.md +106 -0
|
@@ -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
|
+
}
|