@davmash/bygone 0.8.14 → 0.9.2

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,432 @@
1
+ const { execFileSync } = require('child_process');
2
+ const { realpathSync } = require('fs');
3
+ const { TextDecoder } = require('util');
4
+ const path = require('path');
5
+ const { parseNameStatusZ } = require('../out/gitComparison.js');
6
+
7
+ const DEFAULT_HISTORY_MAX_FILE_BYTES = 2 * 1024 * 1024;
8
+ const DEFAULT_HISTORY_MAX_LINE_BYTES = 64 * 1024;
9
+ const DEFAULT_GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
10
+
11
+ function git(root, args, options = {}) {
12
+ const encoding = options.encoding || 'utf8';
13
+ const output = execFileSync('git', args, {
14
+ cwd: root,
15
+ encoding,
16
+ maxBuffer: options.maxBuffer || DEFAULT_GIT_MAX_BUFFER_BYTES,
17
+ stdio: ['ignore', 'pipe', 'pipe']
18
+ });
19
+ return encoding === 'utf8' ? output.trimEnd() : output;
20
+ }
21
+
22
+ function validatePath(value) {
23
+ if (typeof value !== 'string' || !value || value.includes('\0') || value.includes('\\') ||
24
+ path.posix.isAbsolute(value) || value.split('/').some(part => part === '..' || part === '.' || !part)) {
25
+ throw new Error('History requires a repository-relative file path.');
26
+ }
27
+ return value;
28
+ }
29
+
30
+ function validateCommit(root, value) {
31
+ if (typeof value !== 'string' || !/^[a-f0-9]{40,64}$/i.test(value)) {
32
+ throw new Error('History requires a full Git commit ID.');
33
+ }
34
+ let resolved;
35
+ try {
36
+ resolved = git(root, ['rev-parse', '--verify', `${value}^{commit}`]).trim();
37
+ } catch {
38
+ throw new Error('History commit could not be resolved.');
39
+ }
40
+ if (resolved !== value.toLowerCase()) throw new Error('History commit could not be resolved.');
41
+ return resolved;
42
+ }
43
+
44
+ function requireInput(input) {
45
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
46
+ throw new Error('History requires a request object.');
47
+ }
48
+ return input;
49
+ }
50
+
51
+ function listCommitHistory(root, commit) {
52
+ const output = git(root, ['log', '--topo-order', '--format=%x1e%H%x00%P%x00%s%x00%cI%x00', commit, '--']);
53
+ const entries = [];
54
+ for (const record of output.split('\x1e').slice(1)) {
55
+ const fields = record.split('\0');
56
+ const [oid, parents, summary, timestamp] = fields;
57
+ if (!oid) continue;
58
+ entries.push({
59
+ commit: oid,
60
+ oid,
61
+ parentCommit: parents.split(' ')[0] || null,
62
+ parents: parents.split(' ').filter(Boolean),
63
+ shortCommit: oid.slice(0, 7),
64
+ summary,
65
+ timestamp
66
+ });
67
+ }
68
+ return entries;
69
+ }
70
+
71
+ function listChangedPaths(root, from, to) {
72
+ return parseNameStatusZ(git(root, [
73
+ '--literal-pathspecs',
74
+ 'diff',
75
+ '--no-ext-diff',
76
+ '--no-textconv',
77
+ '--name-status',
78
+ '-z',
79
+ '--find-renames',
80
+ '--find-copies',
81
+ from,
82
+ to,
83
+ '--'
84
+ ]));
85
+ }
86
+
87
+ function pathPair(changedPath) {
88
+ const followsPath = (changedPath.kind === 'renamed' || changedPath.kind === 'copied')
89
+ && Boolean(changedPath.previousPath);
90
+ return {
91
+ leftPath: changedPath.kind === 'added'
92
+ ? null
93
+ : followsPath ? changedPath.previousPath : changedPath.path,
94
+ rightPath: changedPath.kind === 'deleted' ? null : changedPath.path
95
+ };
96
+ }
97
+
98
+ function parseNumstatValue(value) {
99
+ if (value === '-') return 0;
100
+ const parsed = Number.parseInt(value, 10);
101
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
102
+ }
103
+
104
+ function readNumstats(root, from, to) {
105
+ const fields = git(root, [
106
+ '--literal-pathspecs',
107
+ 'diff',
108
+ '--no-ext-diff',
109
+ '--no-textconv',
110
+ '--numstat',
111
+ '-z',
112
+ '--find-renames',
113
+ '--find-copies',
114
+ from,
115
+ to,
116
+ '--'
117
+ ]).split('\0');
118
+ const stats = [];
119
+ for (let index = 0; index < fields.length;) {
120
+ const header = fields[index++];
121
+ if (!header) continue;
122
+ const match = /^(\d+|-)\t(\d+|-)\t(.*)$/.exec(header);
123
+ if (!match) continue;
124
+ const stat = {
125
+ additions: parseNumstatValue(match[1]),
126
+ deletions: parseNumstatValue(match[2])
127
+ };
128
+ if (match[3]) {
129
+ stat.path = match[3];
130
+ } else {
131
+ // With -z, rename and copy records put their two paths in the
132
+ // following NUL-delimited fields.
133
+ stat.previousPath = fields[index++] || undefined;
134
+ stat.path = fields[index++] || undefined;
135
+ }
136
+ if (stat.path) stats.push(stat);
137
+ }
138
+ return stats;
139
+ }
140
+
141
+ function buildStatsMap(stats) {
142
+ const byPath = new Map();
143
+ for (const stat of stats) {
144
+ byPath.set(stat.path, stat);
145
+ if (stat.previousPath) byPath.set(stat.previousPath, stat);
146
+ }
147
+ return byPath;
148
+ }
149
+
150
+ function hasLineLongerThan(content, maxLineBytes) {
151
+ let lineStart = 0;
152
+ for (let index = 0; index < content.length; index += 1) {
153
+ if (content[index] !== 10) continue;
154
+ if (index - lineStart > maxLineBytes) return true;
155
+ lineStart = index + 1;
156
+ }
157
+ return content.length - lineStart > maxLineBytes;
158
+ }
159
+
160
+ function readGitText(root, oid, relativePath) {
161
+ if (!oid || !relativePath) return { kind: 'text', content: '' };
162
+ let content;
163
+ try {
164
+ content = git(root, ['cat-file', 'blob', `${oid}:${relativePath}`], {
165
+ encoding: 'buffer',
166
+ maxBuffer: DEFAULT_HISTORY_MAX_FILE_BYTES + 1
167
+ });
168
+ } catch (error) {
169
+ if (error && (error.code === 'ENOBUFS' || /maxBuffer|buffer length/i.test(String(error.message)))) {
170
+ return { kind: 'omitted', reason: 'History file is too large to display.' };
171
+ }
172
+ return { kind: 'omitted', reason: 'History file could not be read.' };
173
+ }
174
+ if (content.length > DEFAULT_HISTORY_MAX_FILE_BYTES) {
175
+ return { kind: 'omitted', reason: 'History file is too large to display.' };
176
+ }
177
+ if (content.includes(0)) {
178
+ return { kind: 'omitted', reason: 'Binary history files cannot be displayed.' };
179
+ }
180
+ try {
181
+ // Validate UTF-8 before converting so arbitrary binary data that does
182
+ // not contain a NUL byte is still omitted instead of being replaced.
183
+ new TextDecoder('utf-8', { fatal: true }).decode(content);
184
+ } catch {
185
+ return { kind: 'omitted', reason: 'Binary history files cannot be displayed.' };
186
+ }
187
+ if (hasLineLongerThan(content, DEFAULT_HISTORY_MAX_LINE_BYTES)) {
188
+ return { kind: 'omitted', reason: 'History file contains an oversized line.' };
189
+ }
190
+ return { kind: 'text', content: content.toString('utf8') };
191
+ }
192
+
193
+ function buildComparisonFile(root, from, to, changedPath, statsByPath) {
194
+ const pair = pathPair(changedPath);
195
+ const left = readGitText(root, from, pair.leftPath);
196
+ const right = readGitText(root, to, pair.rightPath);
197
+ const stats = statsByPath.get(changedPath.path) ||
198
+ (changedPath.previousPath ? statsByPath.get(changedPath.previousPath) : undefined);
199
+ const additions = stats?.additions || 0;
200
+ const deletions = stats?.deletions || 0;
201
+ const leftLabel = pair.leftPath
202
+ ? `${pair.leftPath} @ ${from.slice(0, 7)}`
203
+ : `${changedPath.path} (absent)`;
204
+ const rightLabel = pair.rightPath
205
+ ? `${pair.rightPath} @ ${to.slice(0, 7)}`
206
+ : `${changedPath.path} (absent)`;
207
+ const reason = left.kind === 'omitted' ? left.reason : right.kind === 'omitted' ? right.reason : undefined;
208
+ const result = {
209
+ kind: reason ? 'omitted' : 'text-diff',
210
+ path: changedPath.path,
211
+ ...(changedPath.previousPath ? { previousPath: changedPath.previousPath } : {}),
212
+ changeKind: changedPath.kind,
213
+ leftPath: pair.leftPath,
214
+ rightPath: pair.rightPath,
215
+ leftContent: reason ? '' : left.content,
216
+ rightContent: reason ? '' : right.content,
217
+ leftLabel,
218
+ rightLabel,
219
+ additions,
220
+ deletions
221
+ };
222
+ if (reason) result.reason = reason;
223
+ return result;
224
+ }
225
+
226
+ function buildUnchangedPath(root, from, to, filePath) {
227
+ const left = readGitText(root, from, filePath);
228
+ const right = readGitText(root, to, filePath);
229
+ const reason = left.kind === 'omitted' ? left.reason : right.kind === 'omitted' ? right.reason : undefined;
230
+ const result = {
231
+ kind: reason ? 'omitted' : 'text-diff',
232
+ path: filePath,
233
+ changeKind: 'unchanged',
234
+ leftPath: filePath,
235
+ rightPath: filePath,
236
+ leftContent: reason ? '' : left.content,
237
+ rightContent: reason ? '' : right.content,
238
+ leftLabel: `${filePath} @ ${from.slice(0, 7)}`,
239
+ rightLabel: `${filePath} @ ${to.slice(0, 7)}`,
240
+ additions: 0,
241
+ deletions: 0
242
+ };
243
+ if (reason) result.reason = reason;
244
+ return result;
245
+ }
246
+
247
+ function pathExistsAt(root, oid, filePath) {
248
+ try {
249
+ return git(root, ['cat-file', '-e', `${oid}:${filePath}`]) === '';
250
+ } catch {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ function aggregateComparisonFiles(commits, comparisons) {
256
+ const groups = [];
257
+ for (const [pairIndex, comparison] of comparisons.entries()) {
258
+ for (const file of comparison.files) {
259
+ const aliases = [file.path, file.previousPath, file.leftPath, file.rightPath].filter(Boolean);
260
+ const matches = groups.filter(group => aliases.some(alias => group.aliases.has(alias)));
261
+ const group = matches.shift() || { aliases: new Set(), files: [], panels: Array(commits.length).fill(null) };
262
+ if (!groups.includes(group)) groups.push(group);
263
+ for (const duplicate of matches) {
264
+ for (const alias of duplicate.aliases) group.aliases.add(alias);
265
+ group.files.push(...duplicate.files);
266
+ for (let index = 0; index < group.panels.length; index += 1) group.panels[index] ||= duplicate.panels[index];
267
+ groups.splice(groups.indexOf(duplicate), 1);
268
+ }
269
+ for (const alias of aliases) group.aliases.add(alias);
270
+ group.files.push(file);
271
+ if (file.kind === 'text-diff') {
272
+ group.panels[pairIndex] = { content: file.leftContent, path: file.leftPath || file.previousPath || file.path };
273
+ group.panels[pairIndex + 1] = { content: file.rightContent, path: file.rightPath || file.path };
274
+ }
275
+ }
276
+ }
277
+ return groups.map(group => {
278
+ for (let index = 1; index < group.panels.length; index += 1) group.panels[index] ||= group.panels[index - 1];
279
+ for (let index = group.panels.length - 2; index >= 0; index -= 1) group.panels[index] ||= group.panels[index + 1];
280
+ const source = group.files[group.files.length - 1];
281
+ const filePath = [...group.panels].reverse().find(panel => panel?.path)?.path || source.path;
282
+ const omitted = group.files.find(file => file.kind === 'omitted');
283
+ return {
284
+ kind: omitted ? 'omitted' : 'text-diff',
285
+ path: filePath,
286
+ changeKind: source.changeKind,
287
+ additions: group.files.reduce((sum, file) => sum + (file.additions || 0), 0),
288
+ deletions: group.files.reduce((sum, file) => sum + (file.deletions || 0), 0),
289
+ ...(omitted ? { reason: omitted.reason } : {}),
290
+ comparisonPanels: group.panels.map((panel, index) => ({
291
+ id: `compare-${commits[index]}`,
292
+ label: `${panel?.path || filePath} @ ${commits[index].slice(0, 7)}`,
293
+ commit: commits[index],
294
+ path: panel?.path || filePath,
295
+ content: panel?.content || '',
296
+ editable: false
297
+ }))
298
+ };
299
+ });
300
+ }
301
+
302
+ function createTourHistory(manifest) {
303
+ if (manifest.version < 2) return null;
304
+ let root;
305
+ try {
306
+ root = realpathSync(manifest.repository.root);
307
+ if (realpathSync(git(root, ['rev-parse', '--show-toplevel']).trim()) !== root) throw new Error('Wrong repository root');
308
+ validateCommit(root, manifest.range.headOid);
309
+ validateCommit(root, manifest.range.mergeBaseOid);
310
+ } catch {
311
+ throw new Error('This v2 tour requires its originating Git repository. Restore the repository before presenting it.');
312
+ }
313
+ function compare(input) {
314
+ input = requireInput(input);
315
+ const from = validateCommit(root, input.from);
316
+ const to = validateCommit(root, input.to);
317
+ const changedPaths = listChangedPaths(root, from, to);
318
+ const statsByPath = buildStatsMap(readNumstats(root, from, to));
319
+ if (input.path !== undefined) {
320
+ const filePath = validatePath(input.path);
321
+ const changedPath = changedPaths.find(item => item.path === filePath || item.previousPath === filePath);
322
+ if (changedPath) return { from, to, files: [buildComparisonFile(root, from, to, changedPath, statsByPath)] };
323
+ if (!pathExistsAt(root, from, filePath) && !pathExistsAt(root, to, filePath)) return { from, to, files: [] };
324
+ return { from, to, files: [buildUnchangedPath(root, from, to, filePath)] };
325
+ }
326
+ return { from, to, files: changedPaths.map(changedPath => buildComparisonFile(root, from, to, changedPath, statsByPath)) };
327
+ }
328
+ return {
329
+ list(input) {
330
+ input = requireInput(input);
331
+ if (input.path === undefined) {
332
+ const commit = validateCommit(root, input.commit || manifest.range.headOid);
333
+ const entries = listCommitHistory(root, commit);
334
+ return {
335
+ entries,
336
+ selectedIndex: entries.length ? 0 : -1,
337
+ selectedCommit: entries[0]?.commit || null
338
+ };
339
+ }
340
+ const filePath = validatePath(input.path);
341
+ const commit = validateCommit(root, input.commit || manifest.range.headOid);
342
+ // Follow the file backwards from the requested revision, including deleted
343
+ // files and rename records; no working-tree file needs to exist.
344
+ const output = git(root, ['--literal-pathspecs', 'log', '--topo-order', '--follow', '-M',
345
+ '--format=%x1e%H%x00%P%x00%s%x00%cI%x00', '--name-status', '-z', commit, '--', filePath]);
346
+ const entries = [];
347
+ let historicalPath = filePath;
348
+ for (const record of output.split('\x1e').slice(1)) {
349
+ const fields = record.split('\0');
350
+ const [oid, parents, summary, timestamp] = fields;
351
+ const changes = parseNameStatusZ(fields.slice(4).join('\0').replace(/^\0?\n/, ''));
352
+ const change = changes.find(item => item.path === historicalPath || item.previousPath === historicalPath) || changes[0];
353
+ if (!change) continue;
354
+ entries.push({ commit: oid, oid, parentCommit: parents.split(' ')[0] || null,
355
+ shortCommit: oid.slice(0, 7), summary, timestamp, path: change.path,
356
+ ...(change.previousPath ? { previousPath: change.previousPath } : {}) });
357
+ historicalPath = change.previousPath || change.path;
358
+ }
359
+ return { entries, selectedIndex: entries.length ? 0 : -1, selectedCommit: entries[0]?.commit || null,
360
+ ...(entries[0]?.commit !== commit ? { fallback: entries.length
361
+ ? 'Showing the closest preceding commit that changed this file.'
362
+ : 'No file history exists at this revision.' } : {}) };
363
+ },
364
+ revisions(input) {
365
+ input = requireInput(input);
366
+ const commit = validateCommit(root, input.commit || manifest.range.headOid);
367
+ const entries = listCommitHistory(root, commit);
368
+ return {
369
+ revisions: entries,
370
+ entries,
371
+ selectedIndex: entries.length ? 0 : -1,
372
+ selectedCommit: entries[0]?.commit || null
373
+ };
374
+ },
375
+ diff(input) {
376
+ input = requireInput(input);
377
+ let filePath = validatePath(input.path);
378
+ const commit = validateCommit(root, input.commit);
379
+ // Resolve the file's historical name without changing the commit axis.
380
+ // Only cross a rename when this revision is an ancestor of that rename.
381
+ if (input.head && !pathExistsAt(root, commit, filePath)) {
382
+ const head = validateCommit(root, input.head);
383
+ const lineage = this.list({ path: filePath, commit: head }).entries;
384
+ for (const entry of lineage) {
385
+ if (!entry.previousPath) continue;
386
+ try {
387
+ git(root, ['merge-base', '--is-ancestor', commit, entry.parentCommit]);
388
+ filePath = entry.previousPath;
389
+ } catch { /* This rename is not ahead of the requested revision. */ }
390
+ if (pathExistsAt(root, commit, filePath)) break;
391
+ }
392
+ }
393
+ const parents = git(root, ['show', '-s', '--format=%P', commit]).trim();
394
+ const parentCommit = parents.split(' ')[0] || null;
395
+ const changes = parseNameStatusZ(git(root, ['--literal-pathspecs', 'diff-tree', '--root', '--no-commit-id', '-r', '-M', '--name-status', '-z',
396
+ ...(parentCommit ? [parentCommit, commit] : [commit]), '--']));
397
+ const change = changes.find(item => item.path === filePath || item.previousPath === filePath);
398
+ const rightPath = change?.path || filePath;
399
+ const leftPath = change?.previousPath || filePath;
400
+ function read(oid, name, absent) {
401
+ if (!oid || absent || !pathExistsAt(root, oid, name)) return '';
402
+ const content = readGitText(root, oid, name);
403
+ if (content.kind === 'omitted') throw new Error(content.reason);
404
+ return content.content;
405
+ }
406
+ return { commit, parentCommit, path: rightPath,
407
+ ...(change?.previousPath ? { previousPath: change.previousPath } : {}),
408
+ leftContent: read(parentCommit, leftPath, change?.kind === 'added'),
409
+ rightContent: read(commit, rightPath, change?.kind === 'deleted'),
410
+ leftLabel: `${leftPath} @ ${parentCommit ? parentCommit.slice(0, 7) : 'empty'}`,
411
+ rightLabel: `${rightPath} @ ${commit.slice(0, 7)}` };
412
+ },
413
+ compare,
414
+ compareMany(input) {
415
+ input = requireInput(input);
416
+ if (!Array.isArray(input.commits) || input.commits.length < 2) throw new Error('Comparison requires at least two commits.');
417
+ const commits = input.commits.map(commit => validateCommit(root, commit));
418
+ if (new Set(commits).size !== commits.length) throw new Error('Comparison commits must be unique.');
419
+ const comparisons = commits.slice(0, -1).map((from, index) => compare({
420
+ from,
421
+ to: commits[index + 1],
422
+ ...(input.path !== undefined ? { path: input.path } : {})
423
+ }));
424
+ return {
425
+ commits,
426
+ files: aggregateComparisonFiles(commits, comparisons)
427
+ };
428
+ }
429
+ };
430
+ }
431
+
432
+ module.exports = { createTourHistory, validatePath };
@@ -22,6 +22,7 @@ _bygone() {
22
22
  '--git-diff:Compare two or more Git sources'
23
23
  '--branch-diff:Legacy alias for branch review'
24
24
  '--test:Open the built-in test comparison'
25
+ '--read-only:Open live Explore sources without allowing edits'
25
26
  '-C:Run as if Bygone started in this directory'
26
27
  '-h:Show command help'
27
28
  '--help:Show command help'
@@ -59,7 +60,7 @@ _bygone() {
59
60
  '--help:Show command help'
60
61
  )
61
62
  shells=('zsh:Z shell' 'bash:Bash' 'fish:Fish shell')
62
- tour_actions=('context:Build structured change evidence' 'coverage:Measure authored tour coverage' 'validate:Validate source and anchors' 'compile:Compile a portable manifest' 'schema:Print the source schema')
63
+ tour_actions=('context:Build structured change evidence' 'coverage:Measure authored tour coverage' 'validate:Validate source and anchors' 'compile:Compile a repository-bound manifest' 'schema:Print the source schema')
63
64
 
64
65
  if (( CURRENT == 2 )); then
65
66
  _describe 'bygone command' root_items
@@ -12,7 +12,7 @@ _bygone() {
12
12
  first="${COMP_WORDS[1]}"
13
13
 
14
14
  if (( COMP_CWORD == 1 )); then
15
- COMPREPLY=( $(compgen -W 'review completion present tour --diff --history --git-diff --branch-diff --test -C -h --help -v --version' -- "$cur") $(compgen -f -- "$cur") )
15
+ COMPREPLY=( $(compgen -W 'review completion present tour --diff --history --git-diff --branch-diff --test --read-only -C -h --help -v --version' -- "$cur") $(compgen -f -- "$cur") )
16
16
  compopt -o filenames 2>/dev/null || true
17
17
  return
18
18
  fi
@@ -13,6 +13,7 @@ complete -c bygone -n '__fish_use_subcommand' -l history -r -d 'Open Git history
13
13
  complete -c bygone -n '__fish_use_subcommand' -l git-diff -d 'Compare two or more Git sources'
14
14
  complete -c bygone -n '__fish_use_subcommand' -l branch-diff -d 'Legacy alias for branch review'
15
15
  complete -c bygone -n '__fish_use_subcommand' -l test -d 'Open the built-in test comparison'
16
+ complete -c bygone -n '__fish_use_subcommand' -l read-only -d 'Open live Explore sources without allowing edits'
16
17
  complete -c bygone -n '__fish_use_subcommand' -s C -r -d 'Run as if Bygone started in this directory'
17
18
  complete -c bygone -n '__fish_use_subcommand' -s h -l help -d 'Show command help'
18
19
  complete -c bygone -n '__fish_use_subcommand' -s v -l version -d 'Show the installed version'