@davmash/bygone 0.8.16 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.4
6
+
7
+ - Updated transitive brace-expansion dependencies within their existing major versions to address high-severity denial-of-service advisories
8
+ - Unified non-editor tooltips with a consistent 500 ms hover delay, opaque backgrounds, and normal UI typography
9
+ - Restored tooltips for folder controls and fixed placement and duplicate tooltips on previous/next file buttons
10
+ - Stabilized tooltip behavior across icon children, keyboard focus, dynamic labels, and modal dialogs
11
+ - Fixed commit-detail hover transitions and Escape dismissal, and normalized commit action menu styling
12
+
13
+ macOS desktop downloads are unsigned and not notarized. If Gatekeeper reports
14
+ the app as damaged, run `xattr -cr /Applications/Bygone.app`, then reopen it.
15
+
16
+ ## 0.9.3
17
+
18
+ - Made commit rows toggle comparison selection, with jump and detail actions available from the context menu
19
+ - Added fuller commit details on hover, including the commit message, author, date, revision, and parents
20
+ - Added tooltip coverage across toolbar controls and tour coverage indicators
21
+
22
+ ## 0.9.2
23
+
24
+ - Documented the stable v3 tour contract, compatibility policy, and evidence-linked review notes
25
+ - Updated Electron and vulnerable packaging dependencies within their existing major versions
26
+ - Shared the expanded commit navigator across tours, History, and Compare, with explicit draft submission and loaded-panel badges
27
+ - Kept History's commit axis and expanded panels stable across files, with markers for commits that changed the current file
28
+ - Preserved navigator tabs and comparison drafts across mode switches
29
+ - Replaced tour mode and comparison endpoint drop-downs with direct mode buttons and multi-select revision comparison
30
+ - Added two-or-more-panel Compare views ordered from oldest to newest with adjacent diffs
31
+ - Unified Present navigation into one Tour, Files, or Commits navigator
32
+ - Made tours a continuous reading flow through title, chapters, scene overviews, and steps, with a collapsible outline and scene context above each step
33
+ - Added shared workspace tour discovery, editable skill-based v3 authoring prompts, and same-repository transitions into History
34
+ - Clarified tour mode restoration and directory overview scope while preserving first-visit History context
35
+
36
+ macOS desktop downloads are unsigned and not notarized. If Gatekeeper reports
37
+ the app as damaged, run `xattr -cr /Applications/Bygone.app`, then reopen it.
38
+
39
+ ## 0.9.1
40
+
41
+ - Restored authored Final walkthroughs and legacy Final deep links for version 2 deconstructed tours
42
+ - Reused the full revision rail and toolbar in tour History mode while preserving the selected file on first entry
43
+ - Expanded Files and Tour details by default and kept Scene context available alongside the active step
44
+
45
+ ## 0.9.0
46
+
47
+ - Added independently authored Historical and Deconstructed tours with stable per-mode reading positions
48
+ - Added optional scene-level directory overviews with safe repository-relative scoping and authored comparison endpoints
49
+ - Added evidence-linked review notes for concepts, boundaries, tradeoffs, and open questions
50
+ - Introduced source and manifest format v3 for the new tour metadata while preserving v1 and v2 compatibility
51
+
5
52
  ## 0.8.16
6
53
 
7
54
  - Clarified authored-tour errors when a document requires a newer format version
package/cli/present.js CHANGED
@@ -6,7 +6,7 @@ const { buildChangeTourManifest, parseChangeTourStory } = require('../out/change
6
6
  const { buildTourAuthoringCoverage, buildTourCoverageReport } = require('../out/tourCoverage.js');
7
7
  const { buildTourWindowTitle } = require('../out/windowTitle.js');
8
8
  const { tokenMatches } = require('./commandSpec.js');
9
- const { loadTourSource } = require('./tourFile.js');
9
+ const { loadTourSource, parseTourSourceText, buildManifestForTourSource } = require('./tourFile.js');
10
10
  const { createTourHistory } = require('./tourHistory.js');
11
11
 
12
12
  const MIME_TYPES = new Map([
@@ -21,15 +21,15 @@ const MIME_TYPES = new Map([
21
21
 
22
22
  async function startPresentation(args, cwd, packageRoot, options = {}) {
23
23
  const { headRef, baseRef, tourPath, explicitHeadRef } = parsePresentArgs(args);
24
- const story = process.env.BYGONE_TOUR_STORY
24
+ const story = !options.ignoreEnvironment && process.env.BYGONE_TOUR_STORY
25
25
  ? parseChangeTourStory(JSON.parse(readFileSync(path.resolve(cwd, process.env.BYGONE_TOUR_STORY), 'utf8')))
26
26
  : undefined;
27
27
  const source = tourPath ? loadTourSource(cwd, tourPath).source : undefined;
28
28
  const builtManifest = buildChangeTourManifest(cwd, {
29
29
  headRef: explicitHeadRef || source?.range?.head || headRef,
30
30
  baseRef: baseRef || source?.range?.base,
31
- title: process.env.BYGONE_TOUR_TITLE,
32
- sourceUrl: process.env.BYGONE_TOUR_SOURCE_URL,
31
+ title: options.ignoreEnvironment ? undefined : process.env.BYGONE_TOUR_TITLE,
32
+ sourceUrl: options.ignoreEnvironment ? undefined : process.env.BYGONE_TOUR_SOURCE_URL,
33
33
  story,
34
34
  source
35
35
  });
@@ -38,7 +38,7 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
38
38
  : builtManifest;
39
39
  const history = createTourHistory(manifest);
40
40
  const serializedManifest = `${JSON.stringify(manifest, null, 2)}\n`;
41
- const outputPath = process.env.BYGONE_TOUR_OUTPUT;
41
+ const outputPath = options.ignoreEnvironment ? undefined : process.env.BYGONE_TOUR_OUTPUT;
42
42
  if (outputPath) {
43
43
  const resolvedOutput = path.resolve(cwd, outputPath);
44
44
  mkdirSync(path.dirname(resolvedOutput), { recursive: true });
@@ -49,13 +49,46 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
49
49
  const tourWindowTitle = buildTourWindowTitle(manifest, 'Bygone');
50
50
  const presenterIndexPath = path.join(packageRoot, 'web', 'index.html');
51
51
  let presenterIndexTemplate;
52
+ // Uploaded documents remain immutable, scoped to this existing repository,
53
+ // and addressed independently so opening one cannot mutate another tab.
54
+ const uploadedTours = new Map();
52
55
 
53
56
  const server = createServer((request, response) => {
54
57
  const requestUrl = new URL(request.url || '/', 'http://127.0.0.1');
55
- if (requestUrl.pathname === '/history/list' || requestUrl.pathname === '/history/diff') {
58
+ if (requestUrl.pathname === '/tour/open') {
56
59
  if (request.method !== 'POST') return respondJson(response, 405, { error: 'Method not allowed' });
57
60
  if (!isSameOriginLoopbackRequest(request)) return respondJson(response, 403, { error: 'Forbidden' });
58
- if (!history) return respondJson(response, 404, { error: 'History requires a version 2 tour.' });
61
+ if (uploadedTours.size >= 16) return respondJson(response, 409, { error: 'This presentation has reached its open-document limit. Start a new presentation to open more documents.' });
62
+ let body = '';
63
+ request.setEncoding('utf8');
64
+ request.on('data', (chunk) => {
65
+ body += chunk;
66
+ if (Buffer.byteLength(body, 'utf8') > 2 * 1024 * 1024) { respondJson(response, 413, { error: 'Request too large' }); request.destroy(); }
67
+ });
68
+ request.on('end', () => {
69
+ if (response.writableEnded) return;
70
+ try {
71
+ const uploadedSource = parseTourSourceText(JSON.parse(body).source);
72
+ const loaded = buildManifestForTourSource(cwd, uploadedSource);
73
+ const id = String(uploadedTours.size + 1);
74
+ uploadedTours.set(id, { manifest: loaded, history: createTourHistory(loaded) });
75
+ respondJson(response, 200, { manifestUrl: `/loaded/${id}/tour.json`, repository: loaded.repository?.root || cwd, range: loaded.range });
76
+ } catch (error) { respondJson(response, 400, { error: `Invalid tour for this repository: ${error.message}` }); }
77
+ });
78
+ return;
79
+ }
80
+ const loadedMatch = requestUrl.pathname.match(/^\/loaded\/(\d+)\/(tour\.json|history\/(?:list|diff|compare|compare-many|revisions))$/);
81
+ const loadedTour = loadedMatch ? uploadedTours.get(loadedMatch[1]) : null;
82
+ if (loadedMatch && !loadedTour) return respondJson(response, 404, { error: 'Loaded document not found' });
83
+ if (loadedTour && loadedMatch[2] === 'tour.json') return respondJson(response, 200, loadedTour.manifest);
84
+ const requestHistory = loadedTour ? loadedTour.history : history;
85
+ if (loadedMatch) requestUrl.pathname = `/${loadedMatch[2]}`;
86
+ if (requestUrl.pathname === '/history/list' || requestUrl.pathname === '/history/diff'
87
+ || requestUrl.pathname === '/history/compare' || requestUrl.pathname === '/history/compare-many'
88
+ || requestUrl.pathname === '/history/revisions') {
89
+ if (request.method !== 'POST') return respondJson(response, 405, { error: 'Method not allowed' });
90
+ if (!isSameOriginLoopbackRequest(request)) return respondJson(response, 403, { error: 'Forbidden' });
91
+ if (!requestHistory) return respondJson(response, 404, { error: 'History requires a version 2 tour.' });
59
92
  let body = '';
60
93
  request.setEncoding('utf8');
61
94
  request.on('data', chunk => {
@@ -69,7 +102,15 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
69
102
  if (response.writableEnded) return;
70
103
  try {
71
104
  const input = JSON.parse(body);
72
- const result = requestUrl.pathname === '/history/list' ? history.list(input) : history.diff(input);
105
+ const result = requestUrl.pathname === '/history/list'
106
+ ? requestHistory.list(input)
107
+ : requestUrl.pathname === '/history/diff'
108
+ ? requestHistory.diff(input)
109
+ : requestUrl.pathname === '/history/compare'
110
+ ? requestHistory.compare(input)
111
+ : requestUrl.pathname === '/history/compare-many'
112
+ ? requestHistory.compareMany(input)
113
+ : requestHistory.revisions(input);
73
114
  respondJson(response, 200, result);
74
115
  } catch {
75
116
  respondJson(response, 400, { error: 'Could not load history for this file and revision.' });
@@ -125,7 +166,7 @@ async function startPresentation(args, cwd, packageRoot, options = {}) {
125
166
  });
126
167
  createReadStream(targetPath).on('error', () => respond(response, 500, 'Read failed')).pipe(response);
127
168
  });
128
- const requestedPort = readPort(process.env.BYGONE_TOUR_PORT);
169
+ const requestedPort = readPort(options.ignoreEnvironment ? undefined : process.env.BYGONE_TOUR_PORT);
129
170
  await new Promise((resolve, reject) => {
130
171
  server.once('error', reject);
131
172
  server.listen(requestedPort, '127.0.0.1', resolve);
package/cli/tourFile.js CHANGED
@@ -48,6 +48,14 @@ function readTourSourceDocument(sourcePath, options = {}) {
48
48
  throw new Error(`Could not decode Bygone source ${sourceLabel} as UTF-8: ${errorMessage(error)}`, { cause: error });
49
49
  }
50
50
 
51
+ return parseTourSourceText(text, sourceLabel, { maxBytes });
52
+ }
53
+
54
+ function parseTourSourceText(text, sourceLabel = 'uploaded tour', options = {}) {
55
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_TOUR_SOURCE_BYTES;
56
+ if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > maxBytes || text.includes('\0')) {
57
+ throw new Error('Invalid tour text or source size limit exceeded.');
58
+ }
51
59
  let documents;
52
60
  try {
53
61
  documents = loadYamlDocuments(text);
@@ -84,5 +92,6 @@ module.exports = {
84
92
  DEFAULT_MAX_TOUR_SOURCE_BYTES,
85
93
  buildManifestForTourSource,
86
94
  loadTourSource,
95
+ parseTourSourceText,
87
96
  readTourSourceDocument
88
97
  };
@@ -1,10 +1,22 @@
1
1
  const { execFileSync } = require('child_process');
2
2
  const { realpathSync } = require('fs');
3
+ const { TextDecoder } = require('util');
3
4
  const path = require('path');
4
5
  const { parseNameStatusZ } = require('../out/gitComparison.js');
5
6
 
6
- function git(root, args) {
7
- return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
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;
8
20
  }
9
21
 
10
22
  function validatePath(value) {
@@ -19,13 +31,276 @@ function validateCommit(root, value) {
19
31
  if (typeof value !== 'string' || !/^[a-f0-9]{40,64}$/i.test(value)) {
20
32
  throw new Error('History requires a full Git commit ID.');
21
33
  }
22
- const resolved = git(root, ['rev-parse', '--verify', `${value}^{commit}`]).trim();
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
+ }
23
40
  if (resolved !== value.toLowerCase()) throw new Error('History commit could not be resolved.');
24
41
  return resolved;
25
42
  }
26
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%an%x00%ae%x00%B%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, author, authorEmail, message] = 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, author, authorEmail, message: message.trimEnd()
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
+
27
302
  function createTourHistory(manifest) {
28
- if (manifest.version !== 2) return null;
303
+ if (manifest.version < 2) return null;
29
304
  let root;
30
305
  try {
31
306
  root = realpathSync(manifest.repository.root);
@@ -35,24 +310,50 @@ function createTourHistory(manifest) {
35
310
  } catch {
36
311
  throw new Error('This v2 tour requires its originating Git repository. Restore the repository before presenting it.');
37
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
+ }
38
328
  return {
39
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
+ }
40
340
  const filePath = validatePath(input.path);
41
341
  const commit = validateCommit(root, input.commit || manifest.range.headOid);
42
342
  // Follow the file backwards from the requested revision, including deleted
43
343
  // files and rename records; no working-tree file needs to exist.
44
- const output = git(root, ['--literal-pathspecs', 'log', '--follow', '-M', '--max-count=250',
45
- '--format=%x1e%H%x00%P%x00%s%x00%cI%x00', '--name-status', '-z', commit, '--', filePath]);
344
+ const output = git(root, ['--literal-pathspecs', 'log', '--topo-order', '--follow', '-M',
345
+ '--format=%x1e%H%x00%P%x00%s%x00%cI%x00%an%x00%ae%x00%B%x00', '--name-status', '-z', commit, '--', filePath]);
46
346
  const entries = [];
47
347
  let historicalPath = filePath;
48
348
  for (const record of output.split('\x1e').slice(1)) {
49
349
  const fields = record.split('\0');
50
- const [oid, parents, summary, timestamp] = fields;
51
- const changes = parseNameStatusZ(fields.slice(4).join('\0').replace(/^\0?\n/, ''));
350
+ const [oid, parents, summary, timestamp, author, authorEmail, message] = fields;
351
+ const changes = parseNameStatusZ(fields.slice(7).join('\0').replace(/^\0?\n/, ''));
52
352
  const change = changes.find(item => item.path === historicalPath || item.previousPath === historicalPath) || changes[0];
53
353
  if (!change) continue;
54
354
  entries.push({ commit: oid, oid, parentCommit: parents.split(' ')[0] || null,
55
- shortCommit: oid.slice(0, 7), summary, timestamp, path: change.path,
355
+ shortCommit: oid.slice(0, 7), summary, timestamp, author, authorEmail, message: message.trimEnd(),
356
+ parents: parents.split(' ').filter(Boolean), path: change.path,
56
357
  ...(change.previousPath ? { previousPath: change.previousPath } : {}) });
57
358
  historicalPath = change.previousPath || change.path;
58
359
  }
@@ -61,21 +362,47 @@ function createTourHistory(manifest) {
61
362
  ? 'Showing the closest preceding commit that changed this file.'
62
363
  : 'No file history exists at this revision.' } : {}) };
63
364
  },
365
+ revisions(input) {
366
+ input = requireInput(input);
367
+ const commit = validateCommit(root, input.commit || manifest.range.headOid);
368
+ const entries = listCommitHistory(root, commit);
369
+ return {
370
+ revisions: entries,
371
+ entries,
372
+ selectedIndex: entries.length ? 0 : -1,
373
+ selectedCommit: entries[0]?.commit || null
374
+ };
375
+ },
64
376
  diff(input) {
65
- const filePath = validatePath(input.path);
377
+ input = requireInput(input);
378
+ let filePath = validatePath(input.path);
66
379
  const commit = validateCommit(root, input.commit);
380
+ // Resolve the file's historical name without changing the commit axis.
381
+ // Only cross a rename when this revision is an ancestor of that rename.
382
+ if (input.head && !pathExistsAt(root, commit, filePath)) {
383
+ const head = validateCommit(root, input.head);
384
+ const lineage = this.list({ path: filePath, commit: head }).entries;
385
+ for (const entry of lineage) {
386
+ if (!entry.previousPath) continue;
387
+ try {
388
+ git(root, ['merge-base', '--is-ancestor', commit, entry.parentCommit]);
389
+ filePath = entry.previousPath;
390
+ } catch { /* This rename is not ahead of the requested revision. */ }
391
+ if (pathExistsAt(root, commit, filePath)) break;
392
+ }
393
+ }
67
394
  const parents = git(root, ['show', '-s', '--format=%P', commit]).trim();
68
395
  const parentCommit = parents.split(' ')[0] || null;
69
- const changes = parseNameStatusZ(git(root, ['diff-tree', '--root', '--no-commit-id', '-r', '-M', '--name-status', '-z',
70
- ...(parentCommit ? [parentCommit, commit] : [commit])]));
396
+ const changes = parseNameStatusZ(git(root, ['--literal-pathspecs', 'diff-tree', '--root', '--no-commit-id', '-r', '-M', '--name-status', '-z',
397
+ ...(parentCommit ? [parentCommit, commit] : [commit]), '--']));
71
398
  const change = changes.find(item => item.path === filePath || item.previousPath === filePath);
72
399
  const rightPath = change?.path || filePath;
73
400
  const leftPath = change?.previousPath || filePath;
74
401
  function read(oid, name, absent) {
75
- if (!oid || absent) return '';
76
- const content = git(root, ['show', `${oid}:${name}`]);
77
- if (content.includes('\0') || content.length > 2 * 1024 * 1024) throw new Error('History file is binary or too large to display.');
78
- return content;
402
+ if (!oid || absent || !pathExistsAt(root, oid, name)) return '';
403
+ const content = readGitText(root, oid, name);
404
+ if (content.kind === 'omitted') throw new Error(content.reason);
405
+ return content.content;
79
406
  }
80
407
  return { commit, parentCommit, path: rightPath,
81
408
  ...(change?.previousPath ? { previousPath: change.previousPath } : {}),
@@ -83,6 +410,22 @@ function createTourHistory(manifest) {
83
410
  rightContent: read(commit, rightPath, change?.kind === 'deleted'),
84
411
  leftLabel: `${leftPath} @ ${parentCommit ? parentCommit.slice(0, 7) : 'empty'}`,
85
412
  rightLabel: `${rightPath} @ ${commit.slice(0, 7)}` };
413
+ },
414
+ compare,
415
+ compareMany(input) {
416
+ input = requireInput(input);
417
+ if (!Array.isArray(input.commits) || input.commits.length < 2) throw new Error('Comparison requires at least two commits.');
418
+ const commits = input.commits.map(commit => validateCommit(root, commit));
419
+ if (new Set(commits).size !== commits.length) throw new Error('Comparison commits must be unique.');
420
+ const comparisons = commits.slice(0, -1).map((from, index) => compare({
421
+ from,
422
+ to: commits[index + 1],
423
+ ...(input.path !== undefined ? { path: input.path } : {})
424
+ }));
425
+ return {
426
+ commits,
427
+ files: aggregateComparisonFiles(commits, comparisons)
428
+ };
86
429
  }
87
430
  };
88
431
  }