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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ import { commitDiff, commitSubject, git } from '../git.js';
2
+ import { parseDiff } from '../diff.js';
3
+ import { loadGloss } from '../loader.js';
4
+ import { bodyLines, changedCount, gapText, isGap, refLabel, slice, sliceStats } from '../selection.js';
5
+ import { NECESSITY, NECESSITY_HELP, servesOf } from '../schema.js';
6
+ // The lines to show for a selection: the @@ header, then the slice, with
7
+ // changed lines that belong to other groups collapsed into gap rows.
8
+ export function pageLines(h, selected) {
9
+ const body = bodyLines(h);
10
+ if (body.every((l) => l.kind === 'meta'))
11
+ return body.map((l) => ({ kind: 'meta', old: null, new: null, text: l.raw }));
12
+ const header = { kind: 'meta', old: null, new: null, text: h.text.split('\n')[0] };
13
+ return [header, ...slice(body, selected).map((l) => isGap(l)
14
+ ? { kind: 'gap', old: null, new: null, text: gapText(l.gap) }
15
+ : { kind: l.kind, old: l.oldNo, new: l.newNo, text: l.kind === 'meta' ? l.raw : l.raw.slice(1) })];
16
+ }
17
+ // A glossed hunk that is no longer in the commit: shown as a placeholder so
18
+ // the reader can see what the gloss refers to, rather than it vanishing.
19
+ function missingHunk(h) {
20
+ const s = h.stored;
21
+ return {
22
+ id: h.id,
23
+ ref: refLabel(h.id, h.lines),
24
+ file: s?.file ?? '?',
25
+ oldFile: s?.oldFile && s.oldFile !== s.file ? s.oldFile : null,
26
+ range: s && (s.newLines || s.oldLines) ? `-${s.oldStart},${s.oldLines} +${s.newStart},${s.newLines}` : '',
27
+ status: 'missing',
28
+ added: 0,
29
+ removed: 0,
30
+ alsoIn: [],
31
+ lines: [],
32
+ };
33
+ }
34
+ function pageHunk(h, lines, status, alsoIn) {
35
+ const counts = sliceStats(h, lines);
36
+ return {
37
+ id: h.id,
38
+ ref: refLabel(h.id, lines),
39
+ file: h.file,
40
+ oldFile: h.oldFile && h.oldFile !== h.file ? h.oldFile : null,
41
+ range: h.newLines || h.oldLines ? `-${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines}` : 'file header',
42
+ status,
43
+ added: counts.added,
44
+ removed: counts.removed,
45
+ alsoIn,
46
+ lines: pageLines(h, lines),
47
+ };
48
+ }
49
+ const allLines = (h) => Array.from({ length: changedCount(h) }, (_, i) => i + 1);
50
+ export function commitData(gloss) {
51
+ const [author, date] = git(['log', '-1', '--format=%an%x00%aI', gloss.sha]).trim().split('\0');
52
+ const a = gloss.note.annotation;
53
+ // Which groups claim each changed line, so shared lines are split between
54
+ // their groups in the bar and each hunk can link to the groups it shares
55
+ // lines with. A header-only hunk counts as one shared unit.
56
+ const owners = new Map();
57
+ const claimed = (h, lines) => (changedCount(h) ? [...(lines ?? allLines(h))] : [0]);
58
+ for (const g of gloss.groups) {
59
+ for (const h of g.hunks) {
60
+ if (!h.current)
61
+ continue;
62
+ const byLine = owners.get(h.current) ?? new Map();
63
+ for (const i of claimed(h.current, h.lines))
64
+ byLine.set(i, [...(byLine.get(i) ?? []), g.group.id]);
65
+ owners.set(h.current, byLine);
66
+ }
67
+ }
68
+ const groups = gloss.groups
69
+ .map(({ group: g, hunks }) => {
70
+ const live = hunks.filter((h) => h.current !== null);
71
+ const counts = live.map((h) => sliceStats(h.current, h.lines));
72
+ const shares = (h) => claimed(h.current, h.lines).map((i) => owners.get(h.current).get(i));
73
+ return {
74
+ id: g.id,
75
+ intent: g.intent,
76
+ necessity: g.necessity,
77
+ serves: servesOf(g),
78
+ removal_impact: g.removal_impact,
79
+ evidence: g.evidence,
80
+ notes: g.notes,
81
+ stale: hunks.filter((h) => h.status !== 'ok').length,
82
+ total: hunks.length,
83
+ added: counts.reduce((n, c) => n + c.added, 0),
84
+ removed: counts.reduce((n, c) => n + c.removed, 0),
85
+ weight: live.reduce((n, h) => n + (changedCount(h.current) ? shares(h).reduce((w, o) => w + 1 / o.length, 0) : 0), 0),
86
+ hunks: hunks.map((h) => h.current
87
+ ? pageHunk(h.current, h.lines, h.status, [...new Set(shares(h).flat())].filter((id) => id !== g.id))
88
+ : missingHunk(h)),
89
+ };
90
+ })
91
+ .sort((x, y) => NECESSITY.indexOf(x.necessity) - NECESSITY.indexOf(y.necessity));
92
+ return {
93
+ sha: gloss.sha,
94
+ subject: gloss.subject,
95
+ author,
96
+ date,
97
+ glossed: true,
98
+ task: a.task,
99
+ summary: a.summary,
100
+ warnings: gloss.warnings,
101
+ groups,
102
+ unannotated: gloss.unannotated.map((u) => pageHunk(u.hunk, u.lines, 'unannotated', [])),
103
+ alternatives: a.alternatives ?? [],
104
+ risks: a.risks ?? [],
105
+ };
106
+ }
107
+ function unglossedData(sha) {
108
+ const [author, date] = git(['log', '-1', '--format=%an%x00%aI', sha]).trim().split('\0');
109
+ return {
110
+ sha,
111
+ subject: commitSubject(sha),
112
+ author,
113
+ date,
114
+ glossed: false,
115
+ task: '',
116
+ warnings: [],
117
+ groups: [],
118
+ unannotated: parseDiff(commitDiff(sha)).map((h) => pageHunk(h, null, 'unannotated', [])),
119
+ alternatives: [],
120
+ risks: [],
121
+ };
122
+ }
123
+ export function buildPageData(shas, range = null, merges = []) {
124
+ return {
125
+ range,
126
+ merges: merges.map((sha) => ({ sha, subject: commitSubject(sha) })),
127
+ necessity: NECESSITY.map((name) => ({ name, help: NECESSITY_HELP[name] })),
128
+ commits: shas.map((sha) => {
129
+ const gloss = loadGloss(sha);
130
+ return gloss ? commitData(gloss) : unglossedData(sha);
131
+ }),
132
+ generated: new Date().toISOString(),
133
+ };
134
+ }
@@ -0,0 +1,35 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ const asset = (name) => readFileSync(new URL(name, import.meta.url), 'utf8');
4
+ // JSON inside a <script> element: escape "<" so no string in the data, such as
5
+ // "</script>", can end the element early.
6
+ export function embedJson(data) {
7
+ return JSON.stringify(data).replace(/</g, '\\u003c');
8
+ }
9
+ const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
10
+ export function renderPage(data) {
11
+ // The page only runs our own inline script. If escaping ever failed, an
12
+ // injected script would lack the nonce and the CSP would block it.
13
+ const nonce = randomBytes(16).toString('base64');
14
+ const csp = `default-src 'none'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'`;
15
+ return `<!doctype html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <meta name="viewport" content="width=device-width, initial-scale=1">
20
+ <meta http-equiv="Content-Security-Policy" content="${csp}">
21
+ <title>${data.range ? `gloss ${escapeHtml(data.range)} (${data.commits.length} commit${data.commits.length === 1 ? '' : 's'})` : `gloss ${escapeHtml(data.commits[0].sha.slice(0, 7))}: ${escapeHtml(data.commits[0].subject)}`}</title>
22
+ <style nonce="${nonce}">
23
+ ${asset('./style.css')}
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <div id="app"><noscript>This page needs JavaScript to render the gloss.</noscript></div>
28
+ <script type="application/json" id="gloss-data">${embedJson(data)}</script>
29
+ <script nonce="${nonce}">
30
+ ${asset('./client.js')}
31
+ </script>
32
+ </body>
33
+ </html>
34
+ `;
35
+ }
@@ -0,0 +1,220 @@
1
+ :root {
2
+ --bg: #f7f7f5;
3
+ --surface: #ffffff;
4
+ --surface-2: #f1f1ee;
5
+ --border: #e2e2dc;
6
+ --text: #1d1d1b;
7
+ --muted: #6b6b66;
8
+ --accent: #3b5bdb;
9
+ --warn-bg: #fff4e5;
10
+ --warn-border: #f0b35b;
11
+ --warn-text: #7a4300;
12
+ --add-bg: #e6f6ea;
13
+ --add-mark: #1a7f37;
14
+ --del-bg: #fcebec;
15
+ --del-mark: #c62a2f;
16
+ --meta-bg: #eef1f8;
17
+ --meta-text: #4a5a85;
18
+
19
+ --n-required: #1f8a4c;
20
+ --n-supporting: #0f7f8f;
21
+ --n-defensive: #3b5bdb;
22
+ --n-incidental: #b7791f;
23
+ --n-speculative: #9c36b5;
24
+ --n-mechanical: #868e96;
25
+
26
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
27
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
28
+ color-scheme: light;
29
+ }
30
+
31
+ @media (prefers-color-scheme: dark) {
32
+ :root {
33
+ --bg: #151514;
34
+ --surface: #1e1e1c;
35
+ --surface-2: #262624;
36
+ --border: #34342f;
37
+ --text: #e8e8e3;
38
+ --muted: #9a9a92;
39
+ --accent: #8ea2ff;
40
+ --warn-bg: #33270f;
41
+ --warn-border: #a2701e;
42
+ --warn-text: #f3c77a;
43
+ --add-bg: #15301e;
44
+ --add-mark: #5ccf7f;
45
+ --del-bg: #3a1a1c;
46
+ --del-mark: #ff7b80;
47
+ --meta-bg: #20263a;
48
+ --meta-text: #a3b1dc;
49
+
50
+ --n-required: #4cc27a;
51
+ --n-supporting: #33b5c4;
52
+ --n-defensive: #7d93ff;
53
+ --n-incidental: #e0a43e;
54
+ --n-speculative: #cf7ee4;
55
+ --n-mechanical: #8d949b;
56
+ color-scheme: dark;
57
+ }
58
+ }
59
+
60
+ * { box-sizing: border-box; }
61
+ html { -webkit-text-size-adjust: 100%; }
62
+ body {
63
+ margin: 0;
64
+ background: var(--bg);
65
+ color: var(--text);
66
+ font: 15px/1.55 var(--sans);
67
+ }
68
+ main { min-width: 0; }
69
+ a { color: var(--accent); text-decoration: none; }
70
+ a:hover { text-decoration: underline; }
71
+ code, .mono { font-family: var(--mono); font-size: 0.86em; }
72
+
73
+ .layout {
74
+ display: grid;
75
+ /* minmax(0, …) so a wide diff scrolls inside its box instead of widening the page */
76
+ grid-template-columns: minmax(0, 1fr);
77
+ max-width: 1280px;
78
+ margin: 0 auto;
79
+ padding: 24px 16px 64px;
80
+ gap: 32px;
81
+ }
82
+ @media (min-width: 1100px) {
83
+ .layout { grid-template-columns: 240px minmax(0, 1fr); padding: 32px 32px 80px; }
84
+ }
85
+
86
+ /* Sidebar */
87
+ .toc { display: none; }
88
+ @media (min-width: 1100px) {
89
+ .toc { display: block; position: sticky; top: 24px; align-self: start; max-height: calc(100vh - 48px); overflow: auto; }
90
+ }
91
+ .toc h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 8px; }
92
+ .toc ol { list-style: none; margin: 0 0 24px; padding: 0; }
93
+ .toc li a {
94
+ display: flex; align-items: center; gap: 8px;
95
+ padding: 4px 8px; border-radius: 6px; color: var(--text);
96
+ font-family: var(--mono); font-size: 12.5px;
97
+ }
98
+ .toc li a:hover { background: var(--surface-2); text-decoration: none; }
99
+ .toc li.hidden a { opacity: 0.35; }
100
+ .toc li.active a { background: var(--surface-2); }
101
+
102
+ .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--c); }
103
+
104
+ /* Header */
105
+ .commit { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; color: var(--muted); font-size: 13px; }
106
+ .sha { font-family: var(--mono); background: var(--surface-2); border: 1px solid var(--border); padding: 1px 6px; border-radius: 5px; color: var(--text); }
107
+ h1 { font-size: 24px; line-height: 1.3; margin: 8px 0 16px; letter-spacing: -0.01em; }
108
+ .statement { margin: 0 0 8px; max-width: 75ch; }
109
+ .statement .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; margin-right: 6px; }
110
+
111
+ .warning {
112
+ margin: 16px 0; padding: 12px 14px; border-radius: 8px;
113
+ background: var(--warn-bg); border: 1px solid var(--warn-border); color: var(--warn-text);
114
+ }
115
+
116
+ /* Necessity overview */
117
+ .overview { margin: 24px 0 8px; }
118
+ .bar { display: flex; height: 10px; border-radius: 5px; overflow: hidden; background: var(--surface-2); }
119
+ .bar span { display: block; height: 100%; background: var(--c); min-width: 2px; }
120
+ .bar span + span { border-left: 2px solid var(--bg); }
121
+ .chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
122
+ .chip {
123
+ display: inline-flex; align-items: center; gap: 6px;
124
+ border: 1px solid var(--border); background: var(--surface); color: var(--text);
125
+ border-radius: 999px; padding: 4px 10px; font: inherit; font-size: 13px; cursor: pointer;
126
+ }
127
+ .chip .count { color: var(--muted); font-variant-numeric: tabular-nums; }
128
+ .chip[aria-pressed="false"] { opacity: 0.45; }
129
+ .chip[aria-pressed="false"] .dot { background: transparent; border: 1.5px solid var(--c); }
130
+ .overview .note { color: var(--muted); font-size: 12.5px; margin-top: 8px; }
131
+
132
+ .toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 24px 0 12px; }
133
+ .toolbar .shown { color: var(--muted); font-size: 13px; }
134
+ .btn {
135
+ border: 1px solid var(--border); background: var(--surface); color: var(--text);
136
+ border-radius: 6px; padding: 4px 10px; font: inherit; font-size: 13px; cursor: pointer;
137
+ }
138
+ .btn:hover { background: var(--surface-2); }
139
+
140
+ /* Group cards */
141
+ .group {
142
+ background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--c);
143
+ border-radius: 8px; margin: 0 0 12px; scroll-margin-top: 16px;
144
+ }
145
+ .group:focus-visible, .group.focused { outline: 2px solid var(--accent); outline-offset: 2px; }
146
+ .group.hidden { display: none; }
147
+ .group-head { padding: 14px 16px 4px; }
148
+ .group-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
149
+ .badge {
150
+ font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;
151
+ color: var(--c); border: 1px solid var(--c); border-radius: 4px; padding: 0 5px;
152
+ }
153
+ .intent { font-weight: 600; font-size: 16px; overflow-wrap: anywhere; }
154
+ .gid { font-family: var(--mono); font-size: 12.5px; color: var(--muted); }
155
+ .stats { margin-left: auto; font-family: var(--mono); font-size: 12.5px; color: var(--muted); white-space: nowrap; }
156
+ .plus { color: var(--add-mark); }
157
+ .minus { color: var(--del-mark); }
158
+ .stale { font-size: 12px; color: var(--warn-text); background: var(--warn-bg); border-radius: 4px; padding: 0 6px; }
159
+
160
+ .facts { margin: 8px 0 0; padding: 0 16px 12px; display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; font-size: 14px; }
161
+ .facts dt { color: var(--muted); font-size: 12.5px; padding-top: 1px; }
162
+ .facts dd { margin: 0; max-width: 80ch; overflow-wrap: anywhere; }
163
+ @media (max-width: 600px) {
164
+ .facts { grid-template-columns: 1fr; }
165
+ .facts dd { margin-bottom: 6px; }
166
+ .stats { margin-left: 0; }
167
+ }
168
+
169
+ .code-toggle {
170
+ display: block; width: 100%; text-align: left; border: 0; border-top: 1px solid var(--border);
171
+ background: transparent; color: var(--muted); font: inherit; font-size: 13px; padding: 8px 16px; cursor: pointer;
172
+ border-radius: 0 0 8px 0;
173
+ }
174
+ .code-toggle:hover { background: var(--surface-2); color: var(--text); }
175
+ .code { border-top: 1px solid var(--border); padding: 12px 16px 16px; }
176
+ .code[hidden] { display: none; }
177
+
178
+ /* Hunks */
179
+ .hunk { border: 1px solid var(--border); border-radius: 6px; overflow: hidden; margin: 0 0 12px; }
180
+ .hunk:last-child { margin-bottom: 0; }
181
+ .hunk-head {
182
+ display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px;
183
+ padding: 6px 10px; background: var(--surface-2); border-bottom: 1px solid var(--border); font-size: 12.5px;
184
+ }
185
+ .hunk-head .file { font-family: var(--mono); font-weight: 600; overflow-wrap: anywhere; }
186
+ .hunk-head .range { font-family: var(--mono); color: var(--muted); }
187
+ .also { color: var(--muted); }
188
+ .also a { font-family: var(--mono); }
189
+ .scroll { overflow-x: auto; }
190
+ table.diff { border-collapse: collapse; width: 100%; font-family: var(--mono); font-size: 12.5px; line-height: 1.5; }
191
+ table.diff td { padding: 0 8px; vertical-align: top; }
192
+ td.ln { width: 1%; min-width: 3.2em; text-align: right; color: var(--muted); user-select: none; opacity: 0.8; }
193
+ td.mark { width: 1%; padding: 0 2px 0 4px; user-select: none; }
194
+ td.text { white-space: pre; }
195
+ tr.add { background: var(--add-bg); }
196
+ tr.add td.mark { color: var(--add-mark); }
197
+ tr.del { background: var(--del-bg); }
198
+ tr.del td.mark { color: var(--del-mark); }
199
+ tr.meta td { background: var(--meta-bg); color: var(--meta-text); }
200
+ tr.gap td { background: var(--surface-2); color: var(--muted); font-style: italic; font-family: var(--sans); padding: 2px 8px; }
201
+
202
+ /* Unannotated, alternatives, risks */
203
+ .section { margin-top: 40px; }
204
+ .section h2 { font-size: 17px; margin: 0 0 12px; }
205
+ .unannotated { border: 1px dashed var(--del-mark); border-radius: 8px; padding: 12px 16px; background: var(--surface); }
206
+ .list { margin: 0; padding-left: 20px; max-width: 80ch; }
207
+ .list li { margin-bottom: 6px; }
208
+ .list .why { color: var(--muted); }
209
+
210
+ footer { margin-top: 48px; color: var(--muted); font-size: 12.5px; }
211
+ 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); }
212
+
213
+ /* Range pages */
214
+ .commit-list { list-style: none; margin: 0 0 8px; padding: 0; font-size: 14px; }
215
+ .commit-list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; padding: 3px 0; }
216
+ .commit-list .sha { font-size: 12px; }
217
+ .commit-section { margin-top: 48px; padding-top: 24px; border-top: 1px solid var(--border); scroll-margin-top: 16px; }
218
+ .commit-section h2 { font-size: 20px; margin: 8px 0 12px; }
219
+ .commit-section .section h3 { font-size: 16px; margin: 0 0 12px; }
220
+ .toc h2.commit-head { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-transform: none; letter-spacing: 0; font-family: var(--mono); }
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
+ }