@davmash/bygone 0.8.16 → 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.
- package/CHANGELOG.md +30 -0
- package/cli/present.js +50 -9
- package/cli/tourFile.js +9 -0
- package/cli/tourHistory.js +354 -12
- package/docs/change-tour-format.md +168 -16
- package/docs/generating-change-tours.md +70 -17
- package/docs/present.md +126 -5
- package/docs/product-surface.md +56 -4
- package/examples/bygone-history.bygone +52 -10
- package/examples/navigation-lab.bygone.yaml +420 -0
- package/media/webview.css +1 -1
- package/media/webview.js +548 -180
- package/out/changeTour.js +423 -44
- package/out/standalone-main.js +4984 -1141
- package/out/tourCoverage.js +432 -49
- package/package.json +2 -2
- package/schemas/change-tour-source.schema.json +166 -2
- package/skills/pr-tour-guide/SKILL.md +104 -13
- package/standalone/index.html +1 -1
- package/web/index.html +107 -80
- package/web/presenter.css +558 -231
- package/web/web-host.js +374 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.9.2
|
|
6
|
+
|
|
7
|
+
- Documented the stable v3 tour contract, compatibility policy, and evidence-linked review notes
|
|
8
|
+
- Updated Electron and vulnerable packaging dependencies within their existing major versions
|
|
9
|
+
- Shared the expanded commit navigator across tours, History, and Compare, with explicit draft submission and loaded-panel badges
|
|
10
|
+
- Kept History's commit axis and expanded panels stable across files, with markers for commits that changed the current file
|
|
11
|
+
- Preserved navigator tabs and comparison drafts across mode switches
|
|
12
|
+
- Replaced tour mode and comparison endpoint drop-downs with direct mode buttons and multi-select revision comparison
|
|
13
|
+
- Added two-or-more-panel Compare views ordered from oldest to newest with adjacent diffs
|
|
14
|
+
- Unified Present navigation into one Tour, Files, or Commits navigator
|
|
15
|
+
- Made tours a continuous reading flow through title, chapters, scene overviews, and steps, with a collapsible outline and scene context above each step
|
|
16
|
+
- Added shared workspace tour discovery, editable skill-based v3 authoring prompts, and same-repository transitions into History
|
|
17
|
+
- Clarified tour mode restoration and directory overview scope while preserving first-visit History context
|
|
18
|
+
|
|
19
|
+
macOS desktop downloads are unsigned and not notarized. If Gatekeeper reports
|
|
20
|
+
the app as damaged, run `xattr -cr /Applications/Bygone.app`, then reopen it.
|
|
21
|
+
|
|
22
|
+
## 0.9.1
|
|
23
|
+
|
|
24
|
+
- Restored authored Final walkthroughs and legacy Final deep links for version 2 deconstructed tours
|
|
25
|
+
- Reused the full revision rail and toolbar in tour History mode while preserving the selected file on first entry
|
|
26
|
+
- Expanded Files and Tour details by default and kept Scene context available alongside the active step
|
|
27
|
+
|
|
28
|
+
## 0.9.0
|
|
29
|
+
|
|
30
|
+
- Added independently authored Historical and Deconstructed tours with stable per-mode reading positions
|
|
31
|
+
- Added optional scene-level directory overviews with safe repository-relative scoping and authored comparison endpoints
|
|
32
|
+
- Added evidence-linked review notes for concepts, boundaries, tradeoffs, and open questions
|
|
33
|
+
- Introduced source and manifest format v3 for the new tour metadata while preserving v1 and v2 compatibility
|
|
34
|
+
|
|
5
35
|
## 0.8.16
|
|
6
36
|
|
|
7
37
|
- 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 === '/
|
|
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 (
|
|
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'
|
|
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
|
};
|
package/cli/tourHistory.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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', 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
|
+
|
|
27
302
|
function createTourHistory(manifest) {
|
|
28
|
-
if (manifest.version
|
|
303
|
+
if (manifest.version < 2) return null;
|
|
29
304
|
let root;
|
|
30
305
|
try {
|
|
31
306
|
root = realpathSync(manifest.repository.root);
|
|
@@ -35,13 +310,38 @@ 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', '--
|
|
344
|
+
const output = git(root, ['--literal-pathspecs', 'log', '--topo-order', '--follow', '-M',
|
|
45
345
|
'--format=%x1e%H%x00%P%x00%s%x00%cI%x00', '--name-status', '-z', commit, '--', filePath]);
|
|
46
346
|
const entries = [];
|
|
47
347
|
let historicalPath = filePath;
|
|
@@ -61,21 +361,47 @@ function createTourHistory(manifest) {
|
|
|
61
361
|
? 'Showing the closest preceding commit that changed this file.'
|
|
62
362
|
: 'No file history exists at this revision.' } : {}) };
|
|
63
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
|
+
},
|
|
64
375
|
diff(input) {
|
|
65
|
-
|
|
376
|
+
input = requireInput(input);
|
|
377
|
+
let filePath = validatePath(input.path);
|
|
66
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
|
+
}
|
|
67
393
|
const parents = git(root, ['show', '-s', '--format=%P', commit]).trim();
|
|
68
394
|
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])]));
|
|
395
|
+
const changes = parseNameStatusZ(git(root, ['--literal-pathspecs', 'diff-tree', '--root', '--no-commit-id', '-r', '-M', '--name-status', '-z',
|
|
396
|
+
...(parentCommit ? [parentCommit, commit] : [commit]), '--']));
|
|
71
397
|
const change = changes.find(item => item.path === filePath || item.previousPath === filePath);
|
|
72
398
|
const rightPath = change?.path || filePath;
|
|
73
399
|
const leftPath = change?.previousPath || filePath;
|
|
74
400
|
function read(oid, name, absent) {
|
|
75
|
-
if (!oid || absent) return '';
|
|
76
|
-
const content =
|
|
77
|
-
if (content.
|
|
78
|
-
return content;
|
|
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;
|
|
79
405
|
}
|
|
80
406
|
return { commit, parentCommit, path: rightPath,
|
|
81
407
|
...(change?.previousPath ? { previousPath: change.previousPath } : {}),
|
|
@@ -83,6 +409,22 @@ function createTourHistory(manifest) {
|
|
|
83
409
|
rightContent: read(commit, rightPath, change?.kind === 'deleted'),
|
|
84
410
|
leftLabel: `${leftPath} @ ${parentCommit ? parentCommit.slice(0, 7) : 'empty'}`,
|
|
85
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
|
+
};
|
|
86
428
|
}
|
|
87
429
|
};
|
|
88
430
|
}
|