@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,229 @@
1
+ :root {
2
+ --bg: #f7f7f5;
3
+ --surface: #ffffff;
4
+ --surface-2: #f1f1ee;
5
+ --border: #e2e2dc;
6
+ --ok: #1a7f37;
7
+ --text: #1d1d1b;
8
+ --muted: #6b6b66;
9
+ --accent: #3b5bdb;
10
+ --warn-bg: #fff4e5;
11
+ --warn-border: #f0b35b;
12
+ --warn-text: #7a4300;
13
+ --add-bg: #e6f6ea;
14
+ --add-mark: #1a7f37;
15
+ --del-bg: #fcebec;
16
+ --del-mark: #c62a2f;
17
+ --meta-bg: #eef1f8;
18
+ --meta-text: #4a5a85;
19
+
20
+ --n-required: #1f8a4c;
21
+ --n-supporting: #0f7f8f;
22
+ --n-defensive: #3b5bdb;
23
+ --n-incidental: #b7791f;
24
+ --n-speculative: #9c36b5;
25
+ --n-mechanical: #868e96;
26
+
27
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
28
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
29
+ color-scheme: light;
30
+ }
31
+
32
+ @media (prefers-color-scheme: dark) {
33
+ :root {
34
+ --bg: #151514;
35
+ --surface: #1e1e1c;
36
+ --surface-2: #262624;
37
+ --border: #34342f;
38
+ --ok: #57c47a;
39
+ --text: #e8e8e3;
40
+ --muted: #9a9a92;
41
+ --accent: #8ea2ff;
42
+ --warn-bg: #33270f;
43
+ --warn-border: #a2701e;
44
+ --warn-text: #f3c77a;
45
+ --add-bg: #15301e;
46
+ --add-mark: #5ccf7f;
47
+ --del-bg: #3a1a1c;
48
+ --del-mark: #ff7b80;
49
+ --meta-bg: #20263a;
50
+ --meta-text: #a3b1dc;
51
+
52
+ --n-required: #4cc27a;
53
+ --n-supporting: #33b5c4;
54
+ --n-defensive: #7d93ff;
55
+ --n-incidental: #e0a43e;
56
+ --n-speculative: #cf7ee4;
57
+ --n-mechanical: #8d949b;
58
+ color-scheme: dark;
59
+ }
60
+ }
61
+
62
+ * { box-sizing: border-box; }
63
+ html { -webkit-text-size-adjust: 100%; }
64
+ body {
65
+ margin: 0;
66
+ background: var(--bg);
67
+ color: var(--text);
68
+ font: 15px/1.55 var(--sans);
69
+ }
70
+ main { min-width: 0; }
71
+ a { color: var(--accent); text-decoration: none; }
72
+ a:hover { text-decoration: underline; }
73
+ code, .mono { font-family: var(--mono); font-size: 0.86em; }
74
+
75
+ .layout {
76
+ display: grid;
77
+ /* minmax(0, …) so a wide diff scrolls inside its box instead of widening the page */
78
+ grid-template-columns: minmax(0, 1fr);
79
+ max-width: 1280px;
80
+ margin: 0 auto;
81
+ padding: 24px 16px 64px;
82
+ gap: 32px;
83
+ }
84
+ @media (min-width: 1100px) {
85
+ .layout { grid-template-columns: 240px minmax(0, 1fr); padding: 32px 32px 80px; }
86
+ }
87
+
88
+ /* Sidebar */
89
+ .toc { display: none; }
90
+ @media (min-width: 1100px) {
91
+ .toc { display: block; position: sticky; top: 24px; align-self: start; max-height: calc(100vh - 48px); overflow: auto; }
92
+ }
93
+ .toc h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 8px; }
94
+ .toc ol { list-style: none; margin: 0 0 24px; padding: 0; }
95
+ .toc li a {
96
+ display: flex; align-items: center; gap: 8px;
97
+ padding: 4px 8px; border-radius: 6px; color: var(--text);
98
+ font-family: var(--mono); font-size: 12.5px;
99
+ }
100
+ .toc li a:hover { background: var(--surface-2); text-decoration: none; }
101
+ .toc li.hidden a { opacity: 0.35; }
102
+ .toc li.active a { background: var(--surface-2); }
103
+
104
+ .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--c); }
105
+
106
+ /* Header */
107
+ .commit { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; color: var(--muted); font-size: 13px; }
108
+ .sha { font-family: var(--mono); background: var(--surface-2); border: 1px solid var(--border); padding: 1px 6px; border-radius: 5px; color: var(--text); }
109
+ h1 { font-size: 24px; line-height: 1.3; margin: 8px 0 16px; letter-spacing: -0.01em; }
110
+ .statement { margin: 0 0 8px; max-width: 75ch; }
111
+ .statement .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; margin-right: 6px; }
112
+
113
+ .warning {
114
+ margin: 16px 0; padding: 12px 14px; border-radius: 8px;
115
+ background: var(--warn-bg); border: 1px solid var(--warn-border); color: var(--warn-text);
116
+ }
117
+
118
+ /* Necessity overview */
119
+ .overview { margin: 24px 0 8px; }
120
+ .bar { display: flex; height: 10px; border-radius: 5px; overflow: hidden; background: var(--surface-2); }
121
+ .bar span { display: block; height: 100%; background: var(--c); min-width: 2px; }
122
+ .bar span + span { border-left: 2px solid var(--bg); }
123
+ .chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
124
+ .chip {
125
+ display: inline-flex; align-items: center; gap: 6px;
126
+ border: 1px solid var(--border); background: var(--surface); color: var(--text);
127
+ border-radius: 999px; padding: 4px 10px; font: inherit; font-size: 13px; cursor: pointer;
128
+ }
129
+ .chip .count { color: var(--muted); font-variant-numeric: tabular-nums; }
130
+ .chip[aria-pressed="false"] { opacity: 0.45; }
131
+ .chip[aria-pressed="false"] .dot { background: transparent; border: 1.5px solid var(--c); }
132
+ .overview .note { color: var(--muted); font-size: 12.5px; margin-top: 8px; }
133
+
134
+ .toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 24px 0 12px; }
135
+ .toolbar .shown { color: var(--muted); font-size: 13px; }
136
+ .btn {
137
+ border: 1px solid var(--border); background: var(--surface); color: var(--text);
138
+ border-radius: 6px; padding: 4px 10px; font: inherit; font-size: 13px; cursor: pointer;
139
+ }
140
+ .btn:hover { background: var(--surface-2); }
141
+
142
+ /* Group cards */
143
+ .group {
144
+ background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--c);
145
+ border-radius: 8px; margin: 0 0 12px; scroll-margin-top: 16px;
146
+ }
147
+ .group:focus-visible, .group.focused { outline: 2px solid var(--accent); outline-offset: 2px; }
148
+ .group.hidden { display: none; }
149
+ .group-head { padding: 14px 16px 4px; }
150
+ .group-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
151
+ .badge {
152
+ font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;
153
+ color: var(--c); border: 1px solid var(--c); border-radius: 4px; padding: 0 5px;
154
+ }
155
+ .intent { font-weight: 600; font-size: 16px; overflow-wrap: anywhere; }
156
+ .gid { font-family: var(--mono); font-size: 12.5px; color: var(--muted); }
157
+ .stats { margin-left: auto; font-family: var(--mono); font-size: 12.5px; color: var(--muted); white-space: nowrap; }
158
+ .plus { color: var(--add-mark); }
159
+ .minus { color: var(--del-mark); }
160
+ .stale { font-size: 12px; color: var(--warn-text); background: var(--warn-bg); border-radius: 4px; padding: 0 6px; }
161
+
162
+ .facts { margin: 8px 0 0; padding: 0 16px 12px; display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; font-size: 14px; }
163
+ .facts dt { color: var(--muted); font-size: 12.5px; padding-top: 1px; }
164
+ .facts dd { margin: 0; max-width: 80ch; overflow-wrap: anywhere; }
165
+ @media (max-width: 600px) {
166
+ .facts { grid-template-columns: 1fr; }
167
+ .facts dd { margin-bottom: 6px; }
168
+ .stats { margin-left: 0; }
169
+ }
170
+
171
+ .code-toggle {
172
+ display: block; width: 100%; text-align: left; border: 0; border-top: 1px solid var(--border);
173
+ background: transparent; color: var(--muted); font: inherit; font-size: 13px; padding: 8px 16px; cursor: pointer;
174
+ border-radius: 0 0 8px 0;
175
+ }
176
+ .code-toggle:hover { background: var(--surface-2); color: var(--text); }
177
+ .code { border-top: 1px solid var(--border); padding: 12px 16px 16px; }
178
+ .code[hidden] { display: none; }
179
+
180
+ /* Hunks */
181
+ .hunk { border: 1px solid var(--border); border-radius: 6px; overflow: hidden; margin: 0 0 12px; }
182
+ .hunk:last-child { margin-bottom: 0; }
183
+ .hunk-head {
184
+ display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px;
185
+ padding: 6px 10px; background: var(--surface-2); border-bottom: 1px solid var(--border); font-size: 12.5px;
186
+ }
187
+ .hunk-head .file { font-family: var(--mono); font-weight: 600; overflow-wrap: anywhere; }
188
+ .hunk-head .range { font-family: var(--mono); color: var(--muted); }
189
+ .also { color: var(--muted); }
190
+ .also a { font-family: var(--mono); }
191
+ .scroll { overflow-x: auto; }
192
+ table.diff { border-collapse: collapse; width: 100%; font-family: var(--mono); font-size: 12.5px; line-height: 1.5; }
193
+ table.diff td { padding: 0 8px; vertical-align: top; }
194
+ td.ln { width: 1%; min-width: 3.2em; text-align: right; color: var(--muted); user-select: none; opacity: 0.8; }
195
+ td.mark { width: 1%; padding: 0 2px 0 4px; user-select: none; }
196
+ td.text { white-space: pre; }
197
+ tr.add { background: var(--add-bg); }
198
+ tr.add td.mark { color: var(--add-mark); }
199
+ tr.del { background: var(--del-bg); }
200
+ tr.del td.mark { color: var(--del-mark); }
201
+ tr.meta td { background: var(--meta-bg); color: var(--meta-text); }
202
+ tr.gap td { background: var(--surface-2); color: var(--muted); font-style: italic; font-family: var(--sans); padding: 2px 8px; }
203
+
204
+ /* Unannotated, alternatives, risks */
205
+ .section { margin-top: 40px; }
206
+ .section h2 { font-size: 17px; margin: 0 0 12px; }
207
+ .unannotated { border: 1px dashed var(--del-mark); border-radius: 8px; padding: 12px 16px; background: var(--surface); }
208
+ .list { margin: 0; padding-left: 20px; max-width: 80ch; }
209
+ .list li { margin-bottom: 6px; }
210
+ .list .why { color: var(--muted); }
211
+
212
+ footer { margin-top: 48px; color: var(--muted); font-size: 12.5px; }
213
+ kbd { font-family: var(--mono); font-size: 11.5px; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; padding: 0 4px; background: var(--surface); }
214
+
215
+ /* Range pages */
216
+ .commit-list { list-style: none; margin: 0 0 8px; padding: 0; font-size: 14px; }
217
+ .commit-list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; padding: 3px 0; }
218
+ .commit-list .sha { font-size: 12px; }
219
+ .commit-section { margin-top: 48px; padding-top: 24px; border-top: 1px solid var(--border); scroll-margin-top: 16px; }
220
+ .commit-section h2 { font-size: 20px; margin: 8px 0 12px; }
221
+ .commit-section .section h3 { font-size: 16px; margin: 0 0 12px; }
222
+ .toc h2.commit-head { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-transform: none; letter-spacing: 0; font-family: var(--mono); }
223
+
224
+ /* Reviews recorded for a commit */
225
+ .reviews { margin: 8px 0 12px; display: flex; flex-direction: column; gap: 4px; }
226
+ .review summary { cursor: pointer; }
227
+ .review-ok { color: var(--ok); font-weight: 600; }
228
+ .review-none { color: var(--muted); font-size: 0.9em; }
229
+ .findings { white-space: pre-wrap; font-size: 0.85em; max-height: 32em; overflow: auto; padding: 8px; border-left: 3px solid var(--border); }
package/dist/loader.js ADDED
@@ -0,0 +1,62 @@
1
+ import { commitDiff, commitSubject, readNote } from './git.js';
2
+ import { parseDiff } from './diff.js';
3
+ import { changedCount, selections, uncovered } from './selection.js';
4
+ const locationKey = (h) => [h.file, h.oldFile ?? '', h.oldStart, h.oldLines, h.newStart, h.newLines].join('\0');
5
+ // Pair each stored hunk with a current one: first by the hash of its changes
6
+ // (it may have moved in a rebase), then by location (same place, different
7
+ // changes). Hash matches all go first so a location fallback can't take a
8
+ // hunk that a later stored hunk matches exactly.
9
+ export function matchHunks(stored, current) {
10
+ const used = new Set();
11
+ const resolved = new Map();
12
+ for (const s of stored) {
13
+ const match = s.hash ? current.find((h) => h.hash === s.hash && !used.has(h)) : undefined;
14
+ if (match) {
15
+ used.add(match);
16
+ resolved.set(s.id, { id: s.id, stored: s, current: match, status: 'ok' });
17
+ }
18
+ }
19
+ const byLocation = new Map(current.map((h) => [locationKey(h), h]));
20
+ for (const s of stored) {
21
+ if (resolved.has(s.id))
22
+ continue;
23
+ const match = byLocation.get(locationKey(s));
24
+ if (match && !used.has(match)) {
25
+ used.add(match);
26
+ // Glosses written before hashes existed can only be matched by location.
27
+ resolved.set(s.id, { id: s.id, stored: s, current: match, status: s.hash ? 'changed' : 'ok' });
28
+ }
29
+ else {
30
+ resolved.set(s.id, { id: s.id, stored: s, current: null, status: 'missing' });
31
+ }
32
+ }
33
+ return resolved;
34
+ }
35
+ // Hunks are matched by their stored location and content hash, never by id
36
+ // alone: ids are positional, so any change in diff output would otherwise put
37
+ // the wrong code under a group without anyone noticing.
38
+ export function loadGloss(sha) {
39
+ const note = readNote(sha);
40
+ if (!note)
41
+ return null;
42
+ const current = parseDiff(commitDiff(sha));
43
+ const resolved = matchHunks(note.hunks, current);
44
+ const groups = note.annotation.groups.map((group) => ({
45
+ group,
46
+ hunks: [...selections(group.hunks, (id) => { const c = resolved.get(id)?.current; return c ? changedCount(c) : undefined; })].map(([id, lines]) => ({
47
+ ...(resolved.get(id) ?? { id, stored: null, current: null, status: 'missing' }),
48
+ lines,
49
+ })),
50
+ }));
51
+ const claims = groups.flatMap((g) => g.hunks.flatMap((h) => (h.current ? [{ hunk: h.current, lines: h.lines }] : [])));
52
+ const unannotated = uncovered(current, claims);
53
+ const bad = [...resolved.values()].filter((h) => h.status !== 'ok').length;
54
+ const matched = new Set([...resolved.values()].map((h) => h.current));
55
+ const unknown = current.filter((h) => !matched.has(h)).length;
56
+ const warnings = [];
57
+ if (bad || unknown) {
58
+ warnings.push(`this gloss no longer matches the commit (${bad} glossed hunk(s) changed or gone, ${unknown} new); ` +
59
+ 'the commit was probably amended or rebased after glossing. Re-gloss it with `git gloss add --force`.');
60
+ }
61
+ return { sha, subject: commitSubject(sha), note, groups, unannotated, stale: warnings.length > 0, warnings };
62
+ }
package/dist/merge.js ADDED
@@ -0,0 +1,191 @@
1
+ import { matchHunks } from './loader.js';
2
+ import { servesOf } from './schema.js';
3
+ import { bodyLines, changedCount, formatRanges, selections, uncovered } from './selection.js';
4
+ import { alsoServed } from './validate.js';
5
+ // A changed line is identified by its file and its text with the +/- sign.
6
+ const lineKey = (file, raw) => `${file}\0${raw}`;
7
+ // A header-only hunk (binary, rename, mode change) by its file and content hash.
8
+ const metaKey = (h, file = h.file) => `meta\0${file}\0${h.hash}`;
9
+ // Build one gloss for `target` (the diff of a commit) from the glosses of the
10
+ // commits its changes came from: a squash of several commits, a cherry-pick,
11
+ // a rebase whose diff changed. Each changed line in the target is matched to
12
+ // a source line with the same file and text, and takes that line's group.
13
+ // For an added line the latest source wins (the last commit to write that
14
+ // text); for a removed line, the earliest. Each source line is used once, and
15
+ // a line several groups wrote (such as "}") goes to the group of its nearest
16
+ // neighbour, so repeated lines are shared out sensibly. Groups keep their intent, grade and notes; lines no source explains
17
+ // are left as gaps, never assigned to a guess.
18
+ export function mergeGlosses(sources, target) {
19
+ const pools = new Map();
20
+ const add = (key, owner) => {
21
+ const pool = pools.get(key);
22
+ if (pool)
23
+ pool.push(owner);
24
+ else
25
+ pools.set(key, [owner]);
26
+ };
27
+ // A file renamed by a later source is known in the target by its last name,
28
+ // so lines written under an earlier name follow the renames.
29
+ const renames = sources.map((s) => new Map(s.hunks.filter((h) => h.oldFile && h.oldFile !== h.file).map((h) => [h.oldFile, h.file])));
30
+ const finalName = (file, src) => {
31
+ for (let i = src + 1; i < sources.length; i++)
32
+ file = renames[i].get(file) ?? file;
33
+ return file;
34
+ };
35
+ const stale = [];
36
+ sources.forEach((s, src) => {
37
+ // Hunk ids are positional, so a stored id is resolved to the source's
38
+ // current hunk by content; a hunk whose changes differ from what was
39
+ // glossed is left out rather than read under the wrong group.
40
+ const byId = new Map();
41
+ for (const [id, r] of matchHunks(s.note.hunks, s.hunks)) {
42
+ if (r.status === 'ok' && r.current)
43
+ byId.set(id, r.current);
44
+ else
45
+ stale.push(`${s.sha.slice(0, 7)}:${id}`);
46
+ }
47
+ for (const g of s.note.annotation.groups) {
48
+ for (const [id, sel] of selections(g.hunks, (hid) => { const h = byId.get(hid); return h && changedCount(h); })) {
49
+ const h = byId.get(id);
50
+ if (!h)
51
+ continue;
52
+ if (changedCount(h) === 0) {
53
+ add(metaKey(h, finalName(h.file, src)), { src, group: g.id });
54
+ continue;
55
+ }
56
+ for (const l of bodyLines(h)) {
57
+ if (l.idx !== null && (sel === null || sel.has(l.idx)))
58
+ add(lineKey(finalName(h.file, src), l.raw), { src, group: g.id });
59
+ }
60
+ }
61
+ }
62
+ });
63
+ // Takes a source line for one of `keys`: from `prefer`'s group when it has
64
+ // one, else the latest source (added lines) or the earliest (removed lines).
65
+ const take = (keys, latest, prefer) => {
66
+ for (const key of keys) {
67
+ const pool = pools.get(key);
68
+ if (!pool?.length)
69
+ continue;
70
+ // Pools are filled in source order, so the last entry is the latest.
71
+ let best = prefer ? pool.findIndex((o) => o.src === prefer.src && o.group === prefer.group) : -1;
72
+ if (best < 0)
73
+ best = latest ? pool.length - 1 : 0;
74
+ return pool.splice(best, 1)[0];
75
+ }
76
+ return null;
77
+ };
78
+ const owners = (keys) => new Set(keys.flatMap((k) => (pools.get(k) ?? []).map((o) => `${o.src}\0${o.group}`)));
79
+ // Claimed lines per source group, per target hunk.
80
+ const claimed = new Map();
81
+ const claim = (o, h, idx) => {
82
+ const key = `${o.src}\0${o.group}`;
83
+ let byHunk = claimed.get(key);
84
+ if (!byHunk)
85
+ claimed.set(key, (byHunk = new Map()));
86
+ if (idx === null) {
87
+ byHunk.set(h, null);
88
+ return;
89
+ }
90
+ const set = byHunk.get(h);
91
+ if (set === null)
92
+ return;
93
+ byHunk.set(h, (set ?? new Set()).add(idx));
94
+ };
95
+ for (const h of target) {
96
+ if (changedCount(h) === 0) {
97
+ const o = take([metaKey(h)], true);
98
+ if (o)
99
+ claim(o, h, null);
100
+ continue;
101
+ }
102
+ // A line may have been written under the file's earlier name.
103
+ const files = h.oldFile && h.oldFile !== h.file ? [h.file, h.oldFile] : [h.file];
104
+ const lines = bodyLines(h).filter((l) => l.idx !== null).map((l) => ({ l, keys: files.map((f) => lineKey(f, l.raw)) }));
105
+ // First the lines only one group could own; then lines several groups
106
+ // wrote (such as "}"), which go to the group of the nearest line above,
107
+ // or else below, that one of them owns.
108
+ const got = new Map();
109
+ for (const { l, keys } of lines) {
110
+ if (owners(keys).size !== 1)
111
+ continue;
112
+ const o = take(keys, l.kind === 'add');
113
+ if (o)
114
+ got.set(l.idx, o);
115
+ }
116
+ lines.forEach(({ l, keys }, i) => {
117
+ if (got.has(l.idx) || !owners(keys).size)
118
+ return;
119
+ const candidates = owners(keys);
120
+ const near = [...lines.slice(0, i).reverse(), ...lines.slice(i + 1)]
121
+ .map((x) => got.get(x.l.idx)).find((o) => o && candidates.has(`${o.src}\0${o.group}`));
122
+ const o = take(keys, l.kind === 'add', near);
123
+ if (o)
124
+ got.set(l.idx, o);
125
+ });
126
+ for (const [idx, o] of got)
127
+ claim(o, h, idx);
128
+ }
129
+ // The groups that kept a change, in source order then group order. Ids that
130
+ // clash between sources get the source's position appended.
131
+ const kept = [];
132
+ const dropped = [];
133
+ sources.forEach((s, src) => s.note.annotation.groups.forEach((g) => {
134
+ const byHunk = claimed.get(`${src}\0${g.id}`);
135
+ if (byHunk)
136
+ kept.push({ src, g, byHunk });
137
+ else
138
+ dropped.push(`${s.sha.slice(0, 7)}:${g.id}`);
139
+ }));
140
+ if (!kept.length)
141
+ return { annotation: null, used: [], dropped, gaps: [], stale };
142
+ const used = [...new Set(kept.map((k) => k.src))];
143
+ const several = used.length > 1;
144
+ const clash = new Set(kept.map((k) => k.g.id).filter((id, i, all) => all.indexOf(id) !== i));
145
+ const taken = new Set(kept.map((k) => k.g.id));
146
+ const newId = new Map(kept.map((k) => {
147
+ if (!clash.has(k.g.id))
148
+ return [`${k.src}\0${k.g.id}`, k.g.id];
149
+ let id = `${k.g.id}-${k.src + 1}`;
150
+ for (let n = 2; taken.has(id); n++)
151
+ id = `${k.g.id}-${k.src + 1}-${n}`;
152
+ taken.add(id);
153
+ return [`${k.src}\0${k.g.id}`, id];
154
+ }));
155
+ const groups = kept.map(({ src, g, byHunk }) => {
156
+ const hunks = [...byHunk].sort(([a], [b]) => target.indexOf(a) - target.indexOf(b)).map(([h, set]) => set === null || set.size === changedCount(h) ? h.id : `${h.id}:${formatRanges(set)}`);
157
+ // A group may serve one that kept no change; point it at the task and say
158
+ // so, rather than leave a reference validation would reject.
159
+ const targets = servesOf(g);
160
+ const mapped = targets.map((t) => (t === 'task' ? t : newId.get(`${src}\0${t}`))).filter((t) => !!t);
161
+ const lost = targets.filter((t) => t !== 'task' && !newId.has(`${src}\0${t}`));
162
+ const extra = [
163
+ ...(lost.length ? [alsoServed(lost, 'which has no change left here')] : []),
164
+ ...(several ? [`(from ${sources[src].sha.slice(0, 7)})`] : []),
165
+ ].join(' ');
166
+ return {
167
+ ...g,
168
+ id: newId.get(`${src}\0${g.id}`),
169
+ hunks,
170
+ serves: mapped.length > 1 ? mapped : mapped[0] ?? 'task',
171
+ ...(extra ? { notes: g.notes ? `${g.notes} ${extra}` : extra } : {}),
172
+ };
173
+ });
174
+ const notes = used.map((i) => sources[i].note.annotation);
175
+ const unique = (xs) => [...new Map(xs.map((x) => [JSON.stringify(x), x])).values()];
176
+ const tasks = unique(notes.map((a) => a.task));
177
+ const summaries = unique(notes.flatMap((a) => (a.summary ? [a.summary] : [])));
178
+ const alternatives = unique(notes.flatMap((a) => a.alternatives ?? []));
179
+ const risks = unique(notes.flatMap((a) => a.risks ?? []));
180
+ const annotation = {
181
+ version: 1,
182
+ task: tasks.join('; '),
183
+ ...(summaries.length ? { summary: summaries.join(' ') } : {}),
184
+ groups,
185
+ ...(alternatives.length ? { alternatives } : {}),
186
+ ...(risks.length ? { risks } : {}),
187
+ ...(notes[0].author ? { author: notes[0].author } : {}),
188
+ };
189
+ const claims = kept.flatMap(({ byHunk }) => [...byHunk].map(([hunk, lines]) => ({ hunk, lines })));
190
+ return { annotation, used: used.map((i) => sources[i].sha), dropped, gaps: uncovered(target, claims), stale };
191
+ }
@@ -0,0 +1,156 @@
1
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
2
+ import { commitDiff, diffBetween, EMPTY_TREE, git, gitTry, NOTES_REF } from './git.js';
3
+ import { parseDiff } from './diff.js';
4
+ import { bodyLines } from './selection.js';
5
+ // Commits that have a gloss and are in this repository (a teammate's gloss
6
+ // can arrive before, or without, the commit it describes).
7
+ export function glossedCommits() {
8
+ const shas = (gitTry(['notes', '--ref', NOTES_REF, 'list']) ?? '').split('\n').filter(Boolean).map((l) => l.split(' ')[1]);
9
+ if (!shas.length)
10
+ return [];
11
+ return git(['cat-file', '--batch-check=%(objectname) %(objecttype)'], shas.join('\n') + '\n')
12
+ .split('\n').filter((l) => l.endsWith(' commit')).map((l) => l.split(' ')[0]);
13
+ }
14
+ const diffs = new Map();
15
+ const diffOf = (sha) => {
16
+ let d = diffs.get(sha);
17
+ if (!d)
18
+ diffs.set(sha, (d = parseDiff(commitDiff(sha))));
19
+ return d;
20
+ };
21
+ // git's patch-id: the same for two commits that make the same changes,
22
+ // wherever they sit in history.
23
+ function patchId(sha) {
24
+ const diff = gitTry(['show', '--format=', '--no-color', '--no-ext-diff', sha]);
25
+ return diff ? git(['patch-id', '--stable'], diff).split(' ')[0] || null : null;
26
+ }
27
+ // Where a commit's changes came from, among commits with a gloss:
28
+ // 1. its message: "(cherry picked from commit X)" (cherry-pick -x), or the
29
+ // "Squashed commit of the following:" list git merge --squash writes;
30
+ // 2. a commit with the same author, author date and patch-id: a cherry-pick
31
+ // or a rebase (both keep the author date), including GitHub's
32
+ // "Rebase and merge";
33
+ // 3. with `squash`, a branch not merged into the commit whose combined diff is
34
+ // at least 80% the same as the commit's: a squash merge, including
35
+ // GitHub's "Squash and merge", whatever its message says.
36
+ // Returns the source commits, oldest first, or null.
37
+ export function findOrigins(target, opts = {}) {
38
+ const glossed = new Set(opts.glossed ?? glossedCommits());
39
+ glossed.delete(target);
40
+ if (!glossed.size)
41
+ return null;
42
+ const message = git(['log', '-1', '--format=%B', target]);
43
+ const named = [
44
+ ...[...message.matchAll(/\(cherry picked from commit ([0-9a-f]{7,64})\)/g)].map((m) => m[1]),
45
+ ...(/^Squashed commit of the following:/m.test(message) ? [...message.matchAll(/^commit ([0-9a-f]{40,64})$/gm)].map((m) => m[1]) : []),
46
+ ].map((s) => gitTry(['rev-parse', '-q', '--verify', `${s}^{commit}`])?.trim()).filter((s) => !!s && glossed.has(s));
47
+ // git lists squashed commits newest first.
48
+ if (named.length)
49
+ return [...new Set(named)].reverse();
50
+ const ident = (sha) => git(['log', '-1', '--format=%an%x00%ae%x00%ad', '--date=raw', sha]).trim();
51
+ const mine = ident(target);
52
+ const same = git(['log', '--no-walk=unsorted', '--stdin', '--format=%H%x00%an%x00%ae%x00%ad', '--date=raw'], [...glossed].join('\n'))
53
+ .split('\n').filter(Boolean).filter((l) => l.slice(l.indexOf('\0') + 1) === mine).map((l) => l.slice(0, l.indexOf('\0')));
54
+ if (same.length) {
55
+ const id = patchId(target);
56
+ const match = id && same.find((s) => patchId(s) === id);
57
+ if (match)
58
+ return [match];
59
+ }
60
+ if (!opts.squash)
61
+ return null;
62
+ return squashSources(target, [...glossed]);
63
+ }
64
+ // Changed lines as a multiset of "file, +/- text".
65
+ function changedLines(hunks) {
66
+ const out = new Map();
67
+ for (const h of hunks)
68
+ for (const l of bodyLines(h)) {
69
+ if (l.idx === null)
70
+ continue;
71
+ const key = `${h.file}\0${l.raw}`;
72
+ out.set(key, (out.get(key) ?? 0) + 1);
73
+ }
74
+ return out;
75
+ }
76
+ // How alike two diffs are: the changed lines they share, as a share of the
77
+ // larger one. A squash's diff is (nearly) its branch's combined diff, so this
78
+ // is near 1; an unrelated commit that happens to repeat a few of a branch's
79
+ // lines scores low, however small it is.
80
+ function similarity(a, b) {
81
+ const [x, y] = [changedLines(a), changedLines(b)];
82
+ let shared = 0, nx = 0, ny = 0;
83
+ for (const n of x.values())
84
+ nx += n;
85
+ for (const n of y.values())
86
+ ny += n;
87
+ for (const [k, n] of x)
88
+ shared += Math.min(n, y.get(k) ?? 0);
89
+ return Math.max(nx, ny) ? shared / Math.max(nx, ny) : 0;
90
+ }
91
+ // The unmerged branch the target is a squash of: the one whose combined
92
+ // diff (from where it left the target's parent to its tip) is at least 80%
93
+ // the same as the target's, best first.
94
+ function squashSources(target, glossed) {
95
+ const parent = gitTry(['rev-parse', '-q', '--verify', `${target}^1`])?.trim();
96
+ if (!parent)
97
+ return null;
98
+ // Branch history the target doesn't contain, from glossed commits and
99
+ // branch heads; its tips are the candidate squashed branches.
100
+ const heads = (gitTry(['for-each-ref', '--format=%(objectname)', 'refs/heads/', 'refs/remotes/']) ?? '').split('\n').filter(Boolean);
101
+ const when = Number(git(['log', '-1', '--format=%ct', target]).trim());
102
+ const rows = git(['rev-list', '--parents', ...new Set([...glossed, ...heads]), '--not', target, '--'])
103
+ .split('\n').filter(Boolean).map((l) => l.split(' '));
104
+ const hasChild = new Set(rows.flatMap(([, ...parents]) => parents));
105
+ const tips = rows.map(([c]) => c).filter((c) => !hasChild.has(c))
106
+ .filter((c) => Number(git(['log', '-1', '--format=%ct', c]).trim()) <= when);
107
+ const glossedSet = new Set(glossed);
108
+ const mine = diffOf(target);
109
+ let best = null;
110
+ for (const tip of tips) {
111
+ const base = gitTry(['merge-base', tip, parent])?.trim();
112
+ const shas = git(['rev-list', '--reverse', '--no-merges', base ? `${base}..${tip}` : tip, '--']).split('\n').filter((s) => glossedSet.has(s));
113
+ if (!shas.length)
114
+ continue;
115
+ const score = similarity(mine, parseDiff(diffBetween(base || EMPTY_TREE, tip)));
116
+ if (score >= 0.8 && (!best || score > best.score))
117
+ best = { sources: shas, score };
118
+ }
119
+ return best?.sources ?? null;
120
+ }
121
+ // Unglossed commits on `starts` (a remote's branches, commits being pushed)
122
+ // that may have been made from glossed ones by a squash or rebase merge, a
123
+ // cherry-pick: each is glossed from its origins when they are found. Checks
124
+ // are remembered in .git/gloss-checked with how many glossed commits there
125
+ // were, so a commit is checked again only once more glosses have arrived (its
126
+ // origins' glosses may come later, from a teammate). Returns what was carried.
127
+ export function carryNew(starts, carry) {
128
+ if (!starts.length)
129
+ return [];
130
+ const glossed = glossedCommits();
131
+ if (!glossed.length)
132
+ return [];
133
+ const memo = git(['rev-parse', '--git-path', 'gloss-checked']).trim();
134
+ const checked = new Map((existsSync(memo) ? readFileSync(memo, 'utf8').split('\n').filter(Boolean) : [])
135
+ .map((l) => { const [sha, n] = l.split(' '); return [sha, Number(n) || 0]; }));
136
+ // A commit made from glossed ones is newer than the oldest of them.
137
+ const oldest = Math.min(...git(['log', '--no-walk=unsorted', '--stdin', '--format=%ct'], glossed.join('\n'))
138
+ .split('\n').filter(Boolean).map(Number));
139
+ const glossedSet = new Set(glossed);
140
+ const targets = git(['rev-list', '--no-merges', `--since=${oldest}`, ...starts, '--']).split('\n')
141
+ .filter((s) => s && !glossedSet.has(s) && (checked.get(s) ?? -1) < glossed.length);
142
+ const out = [];
143
+ for (const target of targets.reverse()) {
144
+ const origins = findOrigins(target, { squash: true, glossed });
145
+ if (origins) {
146
+ const res = carry(origins, target);
147
+ if (res.written) {
148
+ out.push(res.message);
149
+ glossed.push(target);
150
+ glossedSet.add(target);
151
+ }
152
+ }
153
+ appendFileSync(memo, `${target} ${glossed.length}\n`);
154
+ }
155
+ return out;
156
+ }