@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,209 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { blameRange, commitSubject, lineHistory } from '../git.js';
3
+ import { loadGloss } from '../loader.js';
4
+ import { servesOf } from '../schema.js';
5
+ import { field, groupHeader, style } from '../render.js';
6
+ import { bodyLines, formatRanges } from '../selection.js';
7
+ export const usage = 'why <file>:<line>[-<end>] [--history] [--rev <commit>] [--json] explain why code exists';
8
+ // Glosses are loaded at most once per commit, however many lines or steps
9
+ // refer to it; each load diffs the commit.
10
+ const glossCache = new Map();
11
+ function glossFor(commit) {
12
+ if (!glossCache.has(commit))
13
+ glossCache.set(commit, loadGloss(commit));
14
+ return glossCache.get(commit);
15
+ }
16
+ // Groups in a gloss that own `line` of `file` on the new side of the commit's
17
+ // diff: they claim the whole hunk, or that line's changed-line number.
18
+ function groupsForLine(gloss, file, line) {
19
+ return (gloss?.groups ?? [])
20
+ .filter((g) => g.hunks.some(({ current: h, lines }) => {
21
+ if (!h || h.file !== file || line < h.newStart || line >= h.newStart + h.newLines)
22
+ return false;
23
+ if (!lines)
24
+ return true;
25
+ const idx = bodyLines(h).find((l) => l.kind === 'add' && l.newNo === line)?.idx;
26
+ return idx !== undefined && idx !== null && lines.has(idx);
27
+ }))
28
+ .map((g) => g.group);
29
+ }
30
+ function step(commit, file, lines) {
31
+ const gloss = glossFor(commit);
32
+ const groups = [...new Map(lines.flatMap((l) => groupsForLine(gloss, file, l)).map((g) => [g.id, g])).values()];
33
+ return { commit, subject: gloss?.subject ?? commitSubject(commit), file, lines, gloss, groups };
34
+ }
35
+ // A change whose only explanation is mechanical (rename, reformat) says
36
+ // nothing about why the code exists, so it is looked through.
37
+ const mechanicalOnly = (s) => s.groups.length > 0 && s.groups.every((g) => g.necessity === 'mechanical');
38
+ // Every group a group serves, directly or through others, nearest first.
39
+ // `add` rejects cycles; the seen set guards glosses written before it did.
40
+ function chain(gloss, g) {
41
+ const byId = new Map(gloss?.note.annotation.groups.map((x) => [x.id, x]) ?? []);
42
+ const seen = new Set([g.id]);
43
+ const out = [];
44
+ const queue = servesOf(g);
45
+ while (queue.length) {
46
+ const next = byId.get(queue.shift());
47
+ if (!next || seen.has(next.id))
48
+ continue;
49
+ seen.add(next.id);
50
+ out.push(next);
51
+ queue.push(...servesOf(next));
52
+ }
53
+ return out;
54
+ }
55
+ function explain(run, history) {
56
+ const { commit, origFile } = run[0];
57
+ const orig = run.map((b) => b.origLine);
58
+ const latest = step(commit, origFile, orig);
59
+ const lines = run.map((b) => b.line);
60
+ // The run is contiguous in the blamed commit, so git log -L tracks exactly
61
+ // these lines and not others between them.
62
+ const start = orig[0], end = orig[orig.length - 1];
63
+ if (!history && !mechanicalOnly(latest)) {
64
+ // Only whether older history exists, without walking it.
65
+ const more = (lineHistory(origFile, start, end, commit, 2)?.length ?? 0) > 1;
66
+ return { lines, latest, before: [], moreHistory: more, note: null };
67
+ }
68
+ const trail = lineHistory(origFile, start, end, commit) ?? [];
69
+ // Normally the trail starts at the blamed commit. If they disagree (blame
70
+ // can skip commits, e.g. with blame.ignoreRevsFile), keep the trail's older
71
+ // commits anyway and say so rather than drop them.
72
+ const at = trail.findIndex((t) => t.commit === commit);
73
+ const older = at >= 0 ? trail.slice(at + 1) : trail;
74
+ const note = trail.length && at !== 0 ? 'git log -L traced these lines from a different commit than blame named; the history may be incomplete' : null;
75
+ const before = [];
76
+ for (const t of older) {
77
+ if (!history && !mechanicalOnly(before.at(-1) ?? latest))
78
+ break;
79
+ before.push(step(t.commit, t.file, t.lines));
80
+ }
81
+ return { lines, latest, before, moreHistory: older.length > before.length, note };
82
+ }
83
+ const stepJson = (s) => ({
84
+ commit: s.commit,
85
+ subject: s.subject,
86
+ file: s.file,
87
+ lines_in_commit: formatRanges(s.lines),
88
+ glossed: s.gloss !== null,
89
+ stale: s.gloss?.stale ?? false,
90
+ task: s.gloss?.note.annotation.task ?? null,
91
+ // serves is always a list here, whichever form the gloss used.
92
+ groups: s.groups.map((g) => ({ ...g, serves: servesOf(g), serves_chain: chain(s.gloss, g).map((c) => c.id) })),
93
+ });
94
+ export function run(argv) {
95
+ const { values, positionals } = parseArgs({
96
+ args: argv,
97
+ allowPositionals: true,
98
+ options: { rev: { type: 'string' }, json: { type: 'boolean' }, history: { type: 'boolean' } },
99
+ });
100
+ const m = /^(.+):(\d+)(?:-(\d+))?$/.exec(positionals[0] ?? '');
101
+ if (!m || Number(m[2]) < 1 || (m[3] && Number(m[3]) < Number(m[2]))) {
102
+ console.error('usage: git gloss ' + usage);
103
+ return 2;
104
+ }
105
+ const [, file, startStr, endStr] = m;
106
+ const start = Number(startStr), end = Number(endStr ?? startStr);
107
+ // One blame for the whole range, then one explanation per contiguous run of
108
+ // lines (in the blamed commit) that a commit last changed, in the order the
109
+ // runs appear. A commit's lines can be split by others' changes, and each
110
+ // run's history has to be traced separately.
111
+ const blamed = blameRange(file, start, end, values.rev);
112
+ const uncommitted = [];
113
+ const byCommit = new Map();
114
+ for (const b of blamed) {
115
+ if (/^0+$/.test(b.commit)) {
116
+ uncommitted.push(b.line);
117
+ continue;
118
+ }
119
+ if (!byCommit.has(b.commit))
120
+ byCommit.set(b.commit, []);
121
+ byCommit.get(b.commit).push(b);
122
+ }
123
+ const runs = [];
124
+ for (const group of byCommit.values()) {
125
+ group.sort((x, y) => x.origLine - y.origLine);
126
+ for (const b of group) {
127
+ const last = runs.at(-1);
128
+ if (last && last[0].commit === b.commit && b.origLine === last[last.length - 1].origLine + 1)
129
+ last.push(b);
130
+ else
131
+ runs.push([b]);
132
+ }
133
+ }
134
+ runs.sort((x, y) => Math.min(...x.map((b) => b.line)) - Math.min(...y.map((b) => b.line)));
135
+ const explanations = runs.map((run) => explain(run, !!values.history));
136
+ const short = blamed.length < end - start + 1 ? `the file ends at line ${start + blamed.length - 1}` : null;
137
+ if (values.json) {
138
+ console.log(JSON.stringify({
139
+ file, start, end,
140
+ explanations: explanations.map((e) => ({
141
+ lines: formatRanges(e.lines),
142
+ ...stepJson(e.latest),
143
+ risks: e.latest.gloss?.note.annotation.risks ?? [],
144
+ alternatives: e.latest.gloss?.note.annotation.alternatives ?? [],
145
+ // Older commits: mechanical changes looked through by default, every
146
+ // change with --history.
147
+ before: e.before.map(stepJson),
148
+ more_history: e.moreHistory,
149
+ note: e.note,
150
+ })),
151
+ uncommitted: uncommitted.length ? formatRanges(uncommitted) : null,
152
+ note: short,
153
+ }, null, 2));
154
+ return 0;
155
+ }
156
+ const out = [];
157
+ const span = start === end ? `${start}` : `${start}-${end}`;
158
+ const renderStep = (s, label, older) => {
159
+ // Where the lines were in an older commit: after edits or a file rename
160
+ // that differs from the query.
161
+ const then = older || s.file !== file ? style('dim', ` (${s.file}:${formatRanges(s.lines)} then)`) : '';
162
+ out.push(`${label}${style('yellow', s.commit.slice(0, 10))} ${s.subject}${then}`);
163
+ if (!s.gloss) {
164
+ out.push(style('dim', ' no gloss for this commit'));
165
+ return;
166
+ }
167
+ out.push(...field('task:', s.gloss.note.annotation.task));
168
+ for (const w of s.gloss.warnings)
169
+ out.push(style(['bold', 'red'], ` ⚠ ${w}`));
170
+ if (!s.groups.length)
171
+ out.push(style('dim', ' these lines are not in an annotated hunk'));
172
+ for (const g of s.groups) {
173
+ out.push(...groupHeader(g).map((l) => ' ' + l));
174
+ for (const parent of chain(s.gloss, g))
175
+ out.push(` ${style('dim', '↳ which serves:')} ${parent.intent} ${style('dim', `(${parent.id})`)}`);
176
+ }
177
+ };
178
+ explanations.forEach((e, i) => {
179
+ if (i > 0)
180
+ out.push('');
181
+ const label = runs.length > 1 || start !== end
182
+ ? style('bold', `line${e.lines.length === 1 ? '' : 's'} ${formatRanges(e.lines)} `)
183
+ : '';
184
+ renderStep(e.latest, label, false);
185
+ // Risks and rejected alternatives cover the whole commit, not just these
186
+ // lines, so text output points at them instead of printing them here.
187
+ const a = e.latest.gloss?.note.annotation;
188
+ const extras = [a?.risks?.length && `${a.risks.length} risk${a.risks.length === 1 ? '' : 's'}`,
189
+ a?.alternatives?.length && `${a.alternatives.length} rejected alternative${a.alternatives.length === 1 ? '' : 's'}`].filter(Boolean);
190
+ if (extras.length)
191
+ out.push(style('dim', ` the commit also lists ${extras.join(' and ')}: git gloss show ${e.latest.commit.slice(0, 7)} --full`));
192
+ e.before.forEach((s, j) => {
193
+ const prev = j === 0 ? e.latest : e.before[j - 1];
194
+ out.push(style('dim', mechanicalOnly(prev) ? ' ↓ that change was mechanical; before it:' : ' ↓ before that:'));
195
+ renderStep(s, ' ', true);
196
+ });
197
+ if (e.note)
198
+ out.push(style('dim', ` ${e.note}`));
199
+ });
200
+ if (!values.history && explanations.some((e) => e.moreHistory)) {
201
+ out.push('', style('dim', `Earlier changes to these lines: git gloss why ${file}:${span}${values.rev ? ` --rev ${values.rev}` : ''} --history`));
202
+ }
203
+ if (short)
204
+ out.push('', style('dim', short));
205
+ if (uncommitted.length)
206
+ out.push('', style('dim', `lines ${formatRanges(uncommitted)} are not committed yet`));
207
+ console.log(out.join('\n'));
208
+ return 0;
209
+ }
package/dist/diff.js ADDED
@@ -0,0 +1,144 @@
1
+ import { createHash } from 'node:crypto';
2
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
3
+ const ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };
4
+ // git C-quotes paths with unusual characters: "q\"x.js", "a\tb", and octal
5
+ // bytes such as \303\251 for non-ASCII when core.quotePath is on.
6
+ export function unquote(quoted) {
7
+ const body = quoted.slice(1, -1);
8
+ const parts = [];
9
+ let last = 0;
10
+ for (const m of body.matchAll(/\\([0-7]{3}|.)/g)) {
11
+ parts.push(Buffer.from(body.slice(last, m.index), 'utf8'));
12
+ const e = m[1];
13
+ parts.push(Buffer.from([e.length === 3 ? parseInt(e, 8) : (ESCAPES[e] ?? e.charCodeAt(0))]));
14
+ last = m.index + m[0].length;
15
+ }
16
+ parts.push(Buffer.from(body.slice(last), 'utf8'));
17
+ return Buffer.concat(parts).toString('utf8');
18
+ }
19
+ // A path from a "--- a/x" or "+++ b/x" line: null for /dev/null, without the
20
+ // a/ b/ prefix, unquoted, and without the tab git appends to paths that
21
+ // contain spaces.
22
+ export function parsePath(raw) {
23
+ let p = raw.replace(/\t$/, '');
24
+ if (p === '/dev/null')
25
+ return null;
26
+ if (p.startsWith('"') && p.endsWith('"'))
27
+ p = unquote(p);
28
+ return p.replace(/^[ab]\//, '');
29
+ }
30
+ export function parseDiff(diff) {
31
+ const lines = diff.split('\n');
32
+ const hunks = [];
33
+ let file = null;
34
+ let i = 0;
35
+ const flush = () => {
36
+ if (!file)
37
+ return;
38
+ if (file.hunks.length === 0) {
39
+ // Still give header-only changes an id so they can be annotated.
40
+ hunks.push({
41
+ id: '',
42
+ file: file.newFile ?? file.oldFile ?? '?',
43
+ oldFile: file.oldFile,
44
+ oldStart: 0, oldLines: 0, newStart: 0, newLines: 0,
45
+ text: file.meta.join('\n'),
46
+ hash: '',
47
+ added: 0, removed: 0,
48
+ });
49
+ }
50
+ hunks.push(...file.hunks);
51
+ file = null;
52
+ };
53
+ while (i < lines.length) {
54
+ const line = lines[i];
55
+ if (line.startsWith('diff --git ')) {
56
+ flush();
57
+ const m = /^diff --git a\/(.*) b\/(.*)$/.exec(line);
58
+ file = { oldFile: m?.[1] ?? null, newFile: m?.[2] ?? null, meta: [line], hunks: [] };
59
+ i++;
60
+ continue;
61
+ }
62
+ if (!file) {
63
+ i++;
64
+ continue;
65
+ }
66
+ if (line.startsWith('--- ')) {
67
+ file.oldFile = parsePath(line.slice(4));
68
+ file.meta.push(line);
69
+ i++;
70
+ continue;
71
+ }
72
+ if (line.startsWith('+++ ')) {
73
+ file.newFile = parsePath(line.slice(4));
74
+ file.meta.push(line);
75
+ i++;
76
+ continue;
77
+ }
78
+ if (line.startsWith('new file mode')) {
79
+ file.oldFile = null;
80
+ }
81
+ if (line.startsWith('deleted file mode')) {
82
+ file.newFile = null;
83
+ }
84
+ if (line.startsWith('rename from '))
85
+ file.oldFile = line.slice(12);
86
+ if (line.startsWith('rename to '))
87
+ file.newFile = line.slice(10);
88
+ const h = HUNK_HEADER.exec(line);
89
+ if (!h) {
90
+ file.meta.push(line);
91
+ i++;
92
+ continue;
93
+ }
94
+ const oldStart = Number(h[1]), oldLines = h[2] === undefined ? 1 : Number(h[2]);
95
+ const newStart = Number(h[3]), newLines = h[4] === undefined ? 1 : Number(h[4]);
96
+ const body = [line];
97
+ let oldLeft = oldLines, newLeft = newLines, added = 0, removed = 0;
98
+ i++;
99
+ // Consume by line counts, not by prefix: a removed line "-- x" looks like a
100
+ // "--- " file header and would otherwise end the hunk early.
101
+ while (i < lines.length && (oldLeft > 0 || newLeft > 0 || lines[i].startsWith('\\'))) {
102
+ const l = lines[i];
103
+ if (l.startsWith('+')) {
104
+ newLeft--;
105
+ added++;
106
+ }
107
+ else if (l.startsWith('-')) {
108
+ oldLeft--;
109
+ removed++;
110
+ }
111
+ else if (l.startsWith('\\')) { /* "" */ }
112
+ else {
113
+ oldLeft--;
114
+ newLeft--;
115
+ }
116
+ body.push(l);
117
+ i++;
118
+ }
119
+ file.hunks.push({
120
+ id: '',
121
+ file: file.newFile ?? file.oldFile ?? '?',
122
+ oldFile: file.oldFile,
123
+ oldStart, oldLines, newStart, newLines,
124
+ text: body.join('\n'),
125
+ hash: '',
126
+ added, removed,
127
+ });
128
+ }
129
+ flush();
130
+ hunks.forEach((h, n) => {
131
+ h.id = `h${n + 1}`;
132
+ // Hash only what the hunk changes, so a rebase that shifts line numbers or
133
+ // context lines doesn't make an otherwise identical hunk look different.
134
+ const body = h.text.startsWith('@@')
135
+ ? h.text.split('\n').slice(1).filter((l) => l[0] === '+' || l[0] === '-' || l[0] === '\\').join('\n')
136
+ : h.text;
137
+ h.hash = createHash('sha256').update(h.file + '\0' + body).digest('hex').slice(0, 16);
138
+ });
139
+ return hunks;
140
+ }
141
+ export function location(h) {
142
+ const { id, file, oldFile, oldStart, oldLines, newStart, newLines, hash } = h;
143
+ return { id, file, oldFile, oldStart, oldLines, newStart, newLines, hash };
144
+ }
package/dist/git.js ADDED
@@ -0,0 +1,315 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync, utimesSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { Annotation, NOTE_FORMAT } from './schema.js';
6
+ import { parsePath, unquote } from './diff.js';
7
+ export const NOTES_REF = 'refs/notes/gloss';
8
+ // Glosses of uncommitted work (wip snapshots) live in their own notes ref,
9
+ // which sync never pushes, and the latest snapshot in a private ref that
10
+ // keeps it from being garbage-collected. refs/worktree/ refs are per
11
+ // worktree, so each linked worktree has its own snapshot.
12
+ export const WIP_NOTES_REF = 'refs/notes/gloss-wip';
13
+ export const WIP_REF = 'refs/worktree/gloss/wip';
14
+ const WIP_MESSAGE = 'git-gloss wip snapshot';
15
+ export const REMOTE_NOTES_REF = 'refs/notes/remotes/origin/gloss';
16
+ export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
17
+ // Errors whose message is fit to show the user without a stack trace.
18
+ export class GlossError extends Error {
19
+ }
20
+ export class GitError extends GlossError {
21
+ }
22
+ export function git(args, input, env = {}) {
23
+ try {
24
+ return execFileSync('git', args, {
25
+ encoding: 'utf8',
26
+ input,
27
+ // Untranslated messages: readNote recognises "no note found" by its text.
28
+ env: { ...process.env, ...env, LC_ALL: 'C' },
29
+ maxBuffer: 256 * 1024 * 1024,
30
+ stdio: ['pipe', 'pipe', 'pipe'],
31
+ });
32
+ }
33
+ catch (err) {
34
+ const stderr = err.stderr?.trim();
35
+ throw new GitError(stderr || `git ${args.join(' ')} failed`);
36
+ }
37
+ }
38
+ export function gitTry(args, input) {
39
+ try {
40
+ return git(args, input);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ // Untracked files that look like secrets. A snapshot is shown and sent to
47
+ // reviewers, so these are left out unless the user stages them.
48
+ const SENSITIVE = [
49
+ /^\.env(\..*)?$/, /\.(pem|key|p12|pfx|jks|keystore|kdbx)$/, /^id_(rsa|dsa|ecdsa|ed25519)$/,
50
+ /(^|[-_.])(secrets?|credentials?|tokens?|passwords?)([-_.]|$)/i, /^\.(netrc|npmrc|pypirc)$/,
51
+ ];
52
+ const looksSensitive = (path) => SENSITIVE.some((re) => re.test(path.split('/').pop()));
53
+ // The working tree as a tree object: tracked files, staged files, and
54
+ // untracked files except ignored ones and ones that look like secrets. Built
55
+ // in a temporary index so the real index is untouched. `excluded` lists the
56
+ // untracked files left out as possible secrets.
57
+ function workingTree() {
58
+ const dir = mkdtempSync(join(tmpdir(), 'git-gloss-index-'));
59
+ try {
60
+ const index = join(dir, 'index');
61
+ const real = git(['rev-parse', '--path-format=absolute', '--git-path', 'index']).trim();
62
+ if (existsSync(real)) {
63
+ copyFileSync(real, index);
64
+ // Keep the index's timestamps: git compares them with file mtimes to
65
+ // spot files edited in the same second as the index was written
66
+ // ("racily clean"), and a fresh mtime would hide such edits.
67
+ const { atime, mtime } = statSync(real);
68
+ utimesSync(index, atime, mtime);
69
+ }
70
+ const env = { GIT_INDEX_FILE: index };
71
+ const untracked = git(['ls-files', '--others', '--exclude-standard', '-z', '--', ':/']).split('\0').filter(Boolean);
72
+ const excluded = untracked.filter(looksSensitive);
73
+ git(['add', '-A', '--', ':/'], undefined, env);
74
+ if (excluded.length)
75
+ git(['rm', '-q', '--cached', '--', ...excluded.map((p) => `:/${p}`)], undefined, env);
76
+ return { tree: git(['write-tree'], undefined, env).trim(), excluded };
77
+ }
78
+ finally {
79
+ rmSync(dir, { recursive: true, force: true });
80
+ }
81
+ }
82
+ const headSha = () => gitTry(['rev-parse', '--verify', '--quiet', 'HEAD'])?.trim() ?? null;
83
+ const treeAndParent = (sha) => {
84
+ const [tree, parent] = git(['log', '-1', '--format=%T %P', sha]).trim().split(' ');
85
+ return { tree, parent: parent || null };
86
+ };
87
+ // Record uncommitted changes as a commit on top of HEAD without touching the
88
+ // branch, index or files, and point WIP_REF at it. Reuses the current
89
+ // snapshot if nothing has changed, so its gloss stays attached.
90
+ // `replaced` is the previous snapshot when this one replaces it, so the
91
+ // caller can carry its gloss over.
92
+ export function snapshotWorkingTree() {
93
+ const { tree, excluded } = workingTree();
94
+ const head = headSha();
95
+ if (head && git(['rev-parse', `${head}^{tree}`]).trim() === tree) {
96
+ throw new GlossError('there are no uncommitted changes to snapshot');
97
+ }
98
+ const current = gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim();
99
+ if (current) {
100
+ const c = treeAndParent(current);
101
+ if (c.tree === tree && c.parent === head)
102
+ return { sha: current, excluded, replaced: null };
103
+ }
104
+ const sha = git(['commit-tree', tree, ...(head ? ['-p', head] : []), '-m', WIP_MESSAGE], undefined, commitIdentity()).trim();
105
+ if (current)
106
+ archiveSnapshot(current);
107
+ git(['update-ref', WIP_REF, sha]);
108
+ return { sha, excluded, replaced: current ?? null };
109
+ }
110
+ // Snapshots and archive entries are commit objects, which need an identity;
111
+ // fall back to a placeholder where git has none configured rather than fail.
112
+ function commitIdentity() {
113
+ const ident = (v) => gitTry(['var', v]) !== null;
114
+ const env = {};
115
+ if (!ident('GIT_AUTHOR_IDENT'))
116
+ Object.assign(env, { GIT_AUTHOR_NAME: 'git-gloss', GIT_AUTHOR_EMAIL: 'git-gloss@localhost' });
117
+ if (!ident('GIT_COMMITTER_IDENT'))
118
+ Object.assign(env, { GIT_COMMITTER_NAME: 'git-gloss', GIT_COMMITTER_EMAIL: 'git-gloss@localhost' });
119
+ return env;
120
+ }
121
+ // A glossed snapshot that is being replaced or cleared stays reachable from
122
+ // this ref, so git gc keeps it: its old gloss, which the notes history keeps,
123
+ // still has the changes it describes (`versions wip` reads and restores them).
124
+ // Each entry is an empty commit whose parents are the previous entry and the
125
+ // snapshot. Per worktree, like WIP_REF.
126
+ export const ARCHIVE_REF = 'refs/worktree/gloss/archive';
127
+ export function archiveSnapshot(sha) {
128
+ if (rawNote(WIP_NOTES_REF, sha) === null)
129
+ return;
130
+ const prev = gitTry(['rev-parse', '-q', '--verify', ARCHIVE_REF])?.trim();
131
+ const entry = git(['commit-tree', EMPTY_TREE, ...(prev ? ['-p', prev] : []), '-p', sha, '-m', 'git-gloss: earlier glossed wip snapshot'], undefined, commitIdentity()).trim();
132
+ git(['update-ref', ARCHIVE_REF, entry]);
133
+ }
134
+ export const dropWipNote = (sha) => gitTry(['notes', '--ref', WIP_NOTES_REF, 'remove', '--ignore-missing', sha]);
135
+ export const isWipSnapshot = (sha) => gitTry(['log', '-1', '--format=%s', sha])?.trim() === WIP_MESSAGE;
136
+ // Why the latest snapshot no longer matches the working tree, or null.
137
+ export function wipStaleness(sha) {
138
+ const s = treeAndParent(sha);
139
+ if (s.parent !== headSha())
140
+ return 'HEAD has moved since this snapshot was taken';
141
+ if (workingTree().tree !== s.tree)
142
+ return 'the working tree has changed since this snapshot was taken';
143
+ return null;
144
+ }
145
+ export function resolveCommit(rev) {
146
+ // "wip": the latest snapshot of uncommitted work (made by `hunks wip`). A
147
+ // branch or tag named wip still works when there is no snapshot, and can be
148
+ // reached as heads/wip or tags/wip when there is.
149
+ const named = gitTry(['rev-parse', '--verify', '--quiet', `${rev}^{commit}`])?.trim();
150
+ if (rev === 'wip') {
151
+ const sha = gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim();
152
+ if (sha) {
153
+ if (named)
154
+ console.error('note: "wip" means the snapshot of uncommitted work; for the branch or tag, use heads/wip or tags/wip');
155
+ const stale = wipStaleness(sha);
156
+ if (stale)
157
+ console.error(`warning: ${stale}; run \`git gloss hunks wip\` to snapshot again, then re-gloss`);
158
+ return sha;
159
+ }
160
+ if (!named)
161
+ throw new GlossError('no snapshot of uncommitted work yet; run `git gloss hunks wip` first');
162
+ }
163
+ const sha = named;
164
+ if (!sha)
165
+ throw new GitError(`not a commit: ${rev}`);
166
+ return sha.trim();
167
+ }
168
+ // A commit, or the commits in a range such as "main..feature", oldest first.
169
+ // Merges are not reviewed (their first-parent diff repeats the merged
170
+ // commits) but are returned so views can say they were skipped: a merge can
171
+ // still carry conflict resolutions or edits of its own.
172
+ export function resolveCommits(arg) {
173
+ if (!arg.includes('..'))
174
+ return { shas: [resolveCommit(arg)], merges: [], range: null };
175
+ if (arg.includes('...')) {
176
+ throw new GitError(`${arg}: "..." includes commits from both sides; use A..B for the commits on B since A`);
177
+ }
178
+ // --end-of-options: the argument is a revision range, never an option.
179
+ const list = (...flags) => git(['rev-list', '--reverse', ...flags, '--end-of-options', arg, '--']).split('\n').filter(Boolean);
180
+ const shas = list('--no-merges');
181
+ const merges = list('--merges');
182
+ if (!shas.length && !merges.length)
183
+ throw new GitError(`no commits in ${arg}`);
184
+ return { shas, merges, range: arg };
185
+ }
186
+ export function commitSubject(sha) {
187
+ return git(['log', '-1', '--format=%s', sha]).trim();
188
+ }
189
+ // Diff of a commit against its first parent (or the empty tree for a root
190
+ // commit). Every option that changes which hunks git produces, or their order,
191
+ // is pinned so hunk ids are the same regardless of the user's diff config.
192
+ export function commitDiff(sha) {
193
+ return diffBetween(gitTry(['rev-parse', '--verify', '--quiet', `${sha}^1`])?.trim() || EMPTY_TREE, sha);
194
+ }
195
+ // The diff between two commits (or trees), with the same pinned options.
196
+ export function diffBetween(from, to) {
197
+ return git([
198
+ '-c', 'core.quotePath=false', '-c', 'diff.suppressBlankEmpty=false',
199
+ 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--find-renames',
200
+ '--src-prefix=a/', '--dst-prefix=b/', '-U3',
201
+ '-O/dev/null', '--diff-algorithm=myers', '--indent-heuristic',
202
+ '--inter-hunk-context=0', '--no-relative', '--submodule=short', '-l1000',
203
+ from, to,
204
+ ]);
205
+ }
206
+ function rawNote(ref, sha) {
207
+ try {
208
+ return git(['notes', '--ref', ref, 'show', sha]);
209
+ }
210
+ catch (err) {
211
+ if (/no note found/i.test(err.message))
212
+ return null;
213
+ throw err;
214
+ }
215
+ }
216
+ export function readNote(sha) {
217
+ // A commit's gloss, or a wip snapshot's (they are never the same object).
218
+ const raw = rawNote(NOTES_REF, sha) ?? rawNote(WIP_NOTES_REF, sha);
219
+ if (raw === null)
220
+ return null;
221
+ const at = `the gloss on ${sha.slice(0, 10)}`;
222
+ let data;
223
+ try {
224
+ data = JSON.parse(raw);
225
+ }
226
+ catch {
227
+ // notes.rewriteMode=concatenate joins two notes when a rebase squashes
228
+ // glossed commits together.
229
+ if (/}\s*\n\s*{/.test(raw)) {
230
+ throw new GlossError(`${at} holds several glosses joined together (from a squash or fixup); re-gloss it with \`git gloss add --force\``);
231
+ }
232
+ throw new GlossError(`${at} is not valid JSON; replace it with \`git gloss add --force\``);
233
+ }
234
+ if (typeof data?.gloss === 'number' && data.gloss > NOTE_FORMAT) {
235
+ throw new GlossError(`${at} was written by a newer git-gloss (format ${data.gloss}); upgrade to read it`);
236
+ }
237
+ const annotation = Annotation.safeParse(data?.annotation);
238
+ if (![1, 2, 3].includes(data?.gloss) || !Array.isArray(data.hunks) || !annotation.success) {
239
+ throw new GlossError(`${at} is malformed; replace it with \`git gloss add --force\``);
240
+ }
241
+ return { ...data, annotation: annotation.data };
242
+ }
243
+ export function writeNote(sha, note, ref = NOTES_REF) {
244
+ git(['notes', '--ref', ref, 'add', '-f', '-F', '-', sha], JSON.stringify(note, null, 2) + '\n');
245
+ }
246
+ // The commits that changed lines start..end of `file`, newest first, starting
247
+ // from `commit`, following them through edits, moves within the file and
248
+ // renames. Each step gives the file's path in that commit and the lines' new
249
+ // numbers in its diff. `max` stops after that many commits (to check whether
250
+ // any older history exists without walking it). Uses git log -L, so it is
251
+ // approximate when lines are heavily rewritten. Null if git can't trace them.
252
+ export function lineHistory(file, start, end, commit, max) {
253
+ const out = gitTry([
254
+ // showSignature would print signature checks between the commits.
255
+ '-c', 'core.quotePath=false', '-c', 'log.showSignature=false', 'log', '--no-color', '--no-ext-diff',
256
+ '--src-prefix=a/', '--dst-prefix=b/', `-L${start},${end}:${file}`, '--format=%x00%H',
257
+ ...(max ? [`--max-count=${max}`] : []), commit,
258
+ ]);
259
+ if (out === null)
260
+ return null;
261
+ return out.split('\0').filter((chunk) => chunk.trim()).map((chunk) => {
262
+ const [first, ...rest] = chunk.split('\n');
263
+ let path = file, newNo = 0, inHunk = false;
264
+ const lines = [];
265
+ for (const l of rest) {
266
+ if (l.startsWith('diff --git ')) {
267
+ inHunk = false;
268
+ continue;
269
+ }
270
+ // Inside a hunk, decide by position, not prefix: an added line whose
271
+ // text starts with "++ " would otherwise look like a "+++" header.
272
+ if (!inHunk && l.startsWith('+++ ')) {
273
+ path = parsePath(l.slice(4)) ?? path;
274
+ continue;
275
+ }
276
+ const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l);
277
+ if (h) {
278
+ newNo = Number(h[1]);
279
+ inHunk = true;
280
+ continue;
281
+ }
282
+ if (!inHunk)
283
+ continue;
284
+ if (l.startsWith('+'))
285
+ lines.push(newNo++);
286
+ else if (l.startsWith(' '))
287
+ newNo++;
288
+ }
289
+ return { commit: first.trim(), file: path, lines };
290
+ });
291
+ }
292
+ // Blame lines start..end in one call (one record per line).
293
+ export function blameRange(file, start, end, rev) {
294
+ const args = ['blame', '--line-porcelain', '-L', `${start},${end}`];
295
+ if (rev)
296
+ args.push(rev);
297
+ args.push('--', file);
298
+ const out = [];
299
+ let cur = null;
300
+ for (const l of git(args).split('\n')) {
301
+ // 40 hex digits for SHA-1 repositories, 64 for SHA-256.
302
+ const head = /^([0-9a-f]{40}(?:[0-9a-f]{24})?) (\d+) (\d+)/.exec(l);
303
+ if (head) {
304
+ cur = { commit: head[1], origLine: Number(head[2]), line: Number(head[3]), origFile: file };
305
+ out.push(cur);
306
+ continue;
307
+ }
308
+ // Porcelain C-quotes unusual file names, like diff headers do.
309
+ if (cur && l.startsWith('filename ')) {
310
+ const raw = l.slice(9);
311
+ cur.origFile = raw.startsWith('"') ? unquote(raw) : raw;
312
+ }
313
+ }
314
+ return out;
315
+ }