@feng0531/dsh-vcs 0.3.12

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/vcs.mjs ADDED
@@ -0,0 +1,327 @@
1
+ import { spawn } from 'node:child_process';
2
+ import * as fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { XMLParser, XMLValidator } from 'fast-xml-parser';
6
+
7
+ const MAX_TEXT = 2 * 1024 * 1024;
8
+ const MAX_OUTPUT = 16 * 1024 * 1024;
9
+ const TIMEOUT = 15000;
10
+ const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, parseAttributeValue: false, trimValues: false, processEntities: true, isArray: name => ['entry', 'target', 'property'].includes(name) });
11
+
12
+ function run(command, args, cwd, max = MAX_OUTPUT, { signal, timeoutMs = TIMEOUT } = {}) {
13
+ return new Promise((resolve, reject) => {
14
+ if (signal?.aborted) { reject(Object.assign(new Error('Discovery cancelled'), { code: 'ABORT_ERR' })); return; }
15
+ // Inherited Git overrides must not redirect reads to a different repository.
16
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('GIT_')));
17
+ Object.assign(env, { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', LC_ALL: process.platform === 'linux' ? 'C.UTF-8' : 'en_US.UTF-8' });
18
+ const child = spawn(command, args, { cwd, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env });
19
+ const out = [], err = []; let size = 0, error, done = false;
20
+ const stop = e => { error ??= e; child.kill(); };
21
+ const abort = () => stop(Object.assign(new Error('Discovery cancelled'), { code: 'ABORT_ERR' }));
22
+ signal?.addEventListener('abort', abort, { once: true });
23
+ const timer = setTimeout(() => stop(Object.assign(new Error(command + ' timed out'), { code: 'TIMEOUT' })), timeoutMs);
24
+ child.stdout.on('data', b => { size += b.length; if (size > max) stop(Object.assign(new Error('Output exceeds size limit'), { code: 'TOO_LARGE' })); else out.push(b); });
25
+ child.stderr.on('data', b => { size += b.length; if (size > max) stop(Object.assign(new Error('Output exceeds size limit'), { code: 'TOO_LARGE' })); else err.push(b); });
26
+ const finish = e => { if (done) return; done = true; clearTimeout(timer); signal?.removeEventListener('abort', abort); if (e) reject(e); else resolve(Buffer.concat(out)); };
27
+ child.on('error', finish);
28
+ child.on('close', code => finish(error || (code !== 0 ? Object.assign(new Error(command + ': ' + Buffer.concat(err).toString('utf8').trim()), { code: 'VCS_COMMAND', exitCode: code }) : null)));
29
+ });
30
+ }
31
+ const git = (root, args, max, options) => run('git', ['--no-pager', '--literal-pathspecs', '-c', 'core.fsmonitor=false', ...args], root, max, options);
32
+ const svn = (root, args, max, options) => run('svn', ['--non-interactive', ...args], root, max, options);
33
+ function parseXML(buffer) {
34
+ const text = buffer.toString('utf8');
35
+ if (/<!DOCTYPE|<!ENTITY/i.test(text) || XMLValidator.validate(text) !== true) throw new Error('Invalid VCS XML');
36
+ return xml.parse(text);
37
+ }
38
+ function inside(root, candidate) { const rel = path.relative(root, candidate); return rel === '' || (!path.isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + path.sep)); }
39
+ async function confined(root, relative) {
40
+ if (typeof relative !== 'string' || relative.includes('\0') || path.isAbsolute(relative) || /^[a-z]:/i.test(relative)) throw new Error('Invalid repository path');
41
+ const target = path.resolve(root, relative);
42
+ if (!inside(root, target)) throw new Error('Path escapes repository');
43
+ // Inspect each existing component, including dangling links whose realpath fails.
44
+ let component = root;
45
+ for (const part of path.relative(root, target).split(path.sep).filter(Boolean)) {
46
+ component = path.join(component, part);
47
+ try {
48
+ if ((await fs.lstat(component)).isSymbolicLink()) {
49
+ const destination = path.resolve(path.dirname(component), await fs.readlink(component));
50
+ if (!inside(root, destination)) throw new Error('Symlink escapes repository');
51
+ }
52
+ } catch (e) { if (!['ENOENT', 'ENOTDIR'].includes(e.code)) throw e; }
53
+ }
54
+ let current = target;
55
+ for (;;) {
56
+ try { const real = await fs.realpath(current); if (!inside(root, real)) throw new Error('Symlink escapes repository'); break; }
57
+ catch (e) { if (!['ENOENT', 'ENOTDIR'].includes(e.code)) throw e; const parent = path.dirname(current); if (parent === current) throw e; current = parent; }
58
+ }
59
+ return target;
60
+ }
61
+
62
+ /** Detect the nearest enclosing Git or SVN working copy; never initializes one. */
63
+ export async function detectRepository(cwd) {
64
+ let current;
65
+ try { current = await fs.realpath(cwd); if (!(await fs.stat(current)).isDirectory()) current = path.dirname(current); } catch { return null; }
66
+ for (;;) {
67
+ for (const type of ['git', 'svn']) {
68
+ try { await fs.lstat(path.join(current, '.' + type)); } catch { continue; }
69
+ try {
70
+ if (type === 'git') {
71
+ const root = await fs.realpath((await git(current, ['rev-parse', '--show-toplevel'])).toString('utf8').trim());
72
+ let branch; try { branch = (await git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).toString('utf8').trim(); } catch { branch = (await git(root, ['rev-parse', '--short', 'HEAD'])).toString('utf8').trim(); }
73
+ return { root, type, branch };
74
+ }
75
+ const info = parseXML(await svn(current, ['info', '--xml', '--', '.'])).info.entry[0];
76
+ return { root: await fs.realpath(info['wc-info']['wcroot-abspath']), type };
77
+ } catch (e) { if (e.code === 'ENOENT') continue; throw e; }
78
+ }
79
+ const parent = path.dirname(current); if (parent === current) return null; current = parent;
80
+ }
81
+ }
82
+
83
+ const DISCOVERY_SKIP = new Set(['.git', '.svn', 'node_modules', 'bower_components', 'vendor', '.pnpm', '.yarn', '.npm', '.cache', '__pycache__', '.venv', 'venv', '.tox', '.mypy_cache', '.pytest_cache', '.gradle', '.nuget']);
84
+
85
+ async function repositoryAt(directory, type, options = () => undefined) {
86
+ if (type === 'svn') {
87
+ const info = parseXML(await svn(directory, ['info', '--xml', '--', '.'], undefined, options())).info.entry[0];
88
+ return { root: await fs.realpath(info['wc-info']['wcroot-abspath']), type };
89
+ }
90
+ const root = await fs.realpath((await git(directory, ['rev-parse', '--show-toplevel'], undefined, options())).toString('utf8').trim());
91
+ let branch;
92
+ try { branch = (await git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], undefined, options())).toString('utf8').trim(); }
93
+ catch (e) {
94
+ if (e.code !== 'VCS_COMMAND') throw e;
95
+ branch = (await git(root, ['rev-parse', '--short', 'HEAD'], undefined, options())).toString('utf8').trim();
96
+ }
97
+ return { root, type, branch };
98
+ }
99
+
100
+ /**
101
+ * Discover enclosing Git AND SVN working copies independently, then nested copies.
102
+ * Depth zero scans only the selected directory. Directory limits apply to the
103
+ * downward walk (not the short enclosing-ancestor walk). relativePath is relative
104
+ * to the original cwd, including '..' for enclosing copies and '.' for cwd itself.
105
+ * Cancellation/deadlines return partial results with truncated=true and warnings.
106
+ * Invalid options/targets reject. Targeted scans never follow symlinks/junctions.
107
+ */
108
+ export async function discoverRepositories(cwd, { signal, maxDepth = 6, maxDirectories = 2000, maxRepositories = 100, timeoutMs = 10000, subdirectory, shallow = false } = {}) {
109
+ for (const [name, value] of Object.entries({ maxDepth, maxDirectories, maxRepositories, timeoutMs })) {
110
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error('Invalid discovery option: ' + name);
111
+ }
112
+ const deadline = performance.now() + timeoutMs;
113
+ const result = { repositories: [], truncated: false, warnings: [] };
114
+ const warn = message => { result.truncated = true; if (!result.warnings.includes(message)) result.warnings.push(message); };
115
+ const check = () => {
116
+ if (signal?.aborted) throw Object.assign(new Error('Discovery cancelled'), { code: 'ABORT_ERR' });
117
+ if (performance.now() >= deadline) throw Object.assign(new Error('Discovery timed out'), { code: 'TIMEOUT' });
118
+ };
119
+ const options = () => { check(); return { signal, timeoutMs: Math.max(1, Math.min(TIMEOUT, Math.ceil(deadline - performance.now()))) }; };
120
+ if (subdirectory !== undefined && (typeof subdirectory !== 'string' || !subdirectory || subdirectory.includes('\0') || path.isAbsolute(subdirectory) || path.win32.isAbsolute(subdirectory) || /^[a-z]:/i.test(subdirectory) || subdirectory.split(/[\\/]/).some(p => p === '..' || p.includes(':')))) throw new Error('Invalid discovery subdirectory');
121
+ // Validate the explicit target even when the request has already been cancelled.
122
+ const base = await fs.realpath(cwd);
123
+ if (!(await fs.stat(base)).isDirectory()) throw new Error('Discovery cwd must be a directory');
124
+ let start = base;
125
+ if (subdirectory !== undefined) {
126
+ for (const part of subdirectory.split(/[\\/]/).filter(p => p && p !== '.')) {
127
+ start = path.join(start, part);
128
+ const stat = await fs.lstat(start);
129
+ if (stat.isSymbolicLink()) throw new Error('Discovery subdirectory must not traverse a symlink or junction');
130
+ if (!stat.isDirectory()) throw new Error('Discovery subdirectory must be a directory');
131
+ }
132
+ start = await fs.realpath(start);
133
+ if (!inside(base, start)) throw new Error('Discovery subdirectory escapes cwd');
134
+ }
135
+ const unavailable = new Set(), seen = new Set(), inspected = new Map();
136
+ let halted = false, directories = 0;
137
+ const failure = (type, directory, e) => {
138
+ if (['ABORT_ERR', 'TIMEOUT'].includes(e.code)) throw e;
139
+ if (e.code === 'ENOENT' && e.syscall?.startsWith('spawn')) {
140
+ unavailable.add(type); warn(type + ' executable is unavailable; repository discovery is incomplete.');
141
+ } else warn('Cannot inspect ' + type + ' repository at ' + directory + ': ' + e.message);
142
+ };
143
+ const inspect = async (directory, type) => {
144
+ check();
145
+ const key = type + '\0' + directory;
146
+ if (inspected.has(key)) return inspected.get(key);
147
+ if (unavailable.has(type)) return false;
148
+ let marker;
149
+ try { marker = await fs.lstat(path.join(directory, '.' + type)); }
150
+ catch (e) { if (e.code !== 'ENOENT' && e.code !== 'ENOTDIR') failure(type, directory, e); return false; }
151
+ if (marker.isSymbolicLink() || !(marker.isDirectory() || (type === 'git' && marker.isFile()))) return false;
152
+ try {
153
+ const repo = await repositoryAt(directory, type, options);
154
+ check();
155
+ // Git metadata may name a different work tree. It is not a repository here.
156
+ if (path.relative(directory, repo.root) !== '') { warn('Repository root differs from marker directory: ' + directory); return false; }
157
+ const canonical = process.platform === 'win32' ? repo.root.toLowerCase() : repo.root;
158
+ const identity = type + '\0' + canonical;
159
+ if (!seen.has(identity)) {
160
+ if (result.repositories.length >= maxRepositories) { warn('Repository limit reached (' + maxRepositories + ').'); halted = true; return true; }
161
+ seen.add(identity);
162
+ result.repositories.push({ id: createHash('sha256').update(identity).digest('hex'), ...repo, relativePath: path.relative(base, repo.root).split(path.sep).join('/') || '.' });
163
+ }
164
+ inspected.set(key, true);
165
+ return true;
166
+ } catch (e) { failure(type, directory, e); inspected.set(key, false); return false; }
167
+ };
168
+ // Streaming DFS bounds memory even for directories containing millions of files.
169
+ const walk = async (directory, depth) => {
170
+ check();
171
+ if (halted) return;
172
+ if (directories >= maxDirectories) { warn('Directory limit reached (' + maxDirectories + ').'); halted = true; return; }
173
+ const stat = await fs.lstat(directory);
174
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return;
175
+ const real = await fs.realpath(directory);
176
+ if (!inside(start, real)) { warn('Directory escaped scan root: ' + directory); return; }
177
+ directories++;
178
+ for (const type of ['git', 'svn']) { await inspect(directory, type); if (halted) return; }
179
+ if (shallow && depth >= 1) return; // Do not even enumerate grandchildren.
180
+ const handle = await fs.opendir(directory);
181
+ for await (const entry of handle) {
182
+ check();
183
+ if (halted) break;
184
+ if (DISCOVERY_SKIP.has(entry.name.toLowerCase()) || entry.isSymbolicLink() || !entry.isDirectory()) continue;
185
+ const child = path.join(directory, entry.name);
186
+ if ((await fs.lstat(child)).isSymbolicLink()) continue;
187
+ if (depth >= maxDepth) { warn('Depth limit reached (' + maxDepth + ').'); continue; }
188
+ try { await walk(child, depth + 1); }
189
+ catch (e) { if (['ABORT_ERR', 'TIMEOUT'].includes(e.code)) throw e; warn('Cannot scan directory ' + child + ': ' + e.message); }
190
+ }
191
+ };
192
+ try {
193
+ check();
194
+ // Probe tools even for empty folders: absent tools must not look like a clean scan.
195
+ for (const type of ['git', 'svn']) {
196
+ try { await (type === 'git' ? git : svn)(start, ['--version'], undefined, options()); }
197
+ catch (e) { failure(type, start, e); unavailable.add(type); }
198
+ }
199
+ const found = new Set(unavailable);
200
+ for (let current = start; found.size < 2 && !halted; current = path.dirname(current)) {
201
+ check();
202
+ for (const type of ['git', 'svn']) {
203
+ if (!found.has(type) && await inspect(current, type)) found.add(type);
204
+ if (halted) break;
205
+ }
206
+ if (path.dirname(current) === current) break;
207
+ }
208
+ if (!halted) await walk(start, 0);
209
+ check();
210
+ } catch (e) { warn(['ABORT_ERR', 'TIMEOUT'].includes(e.code) ? e.message : 'Discovery incomplete: ' + e.message); }
211
+ return result;
212
+ }
213
+
214
+ async function checkedRepo(repo) {
215
+ if (!repo || !['git', 'svn'].includes(repo.type) || typeof repo.root !== 'string') throw new Error('Invalid repository');
216
+ const found = await repositoryAt(repo.root, repo.type);
217
+ if (!found || found.type !== repo.type || path.resolve(found.root) !== path.resolve(repo.root)) throw new Error('Repository root changed or is invalid');
218
+ return found;
219
+ }
220
+ function modeFor(repo, mode) {
221
+ if (!['all', 'staged', 'unstaged'].includes(mode)) throw new Error('Invalid comparison mode');
222
+ if (repo.type === 'svn' && mode !== 'all') throw new Error('SVN supports only all mode');
223
+ }
224
+ function identity(entry, mode) { return createHash('sha256').update(JSON.stringify([mode, entry.path, entry.oldPath, entry.status, entry.indexStatus, entry.worktreeStatus, entry.propertyStatus])).digest('hex'); }
225
+ const statuses = { M: 'modified', A: 'added', D: 'deleted', R: 'renamed', C: 'copied', U: 'conflicted', T: 'typechanged', '?': 'untracked' };
226
+ async function changes(repo, mode) {
227
+ let entries;
228
+ if (repo.type === 'git') {
229
+ const fields = (await git(repo.root, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none'])).toString('utf8').split('\0');
230
+ entries = [];
231
+ for (let i = 0; i < fields.length && fields[i]; i++) {
232
+ const raw = fields[i], x = raw[0], y = raw[1], file = raw.slice(3);
233
+ const oldPath = /[RC]/.test(x + y) ? fields[++i] : undefined;
234
+ const conflict = x === 'U' || y === 'U' || ['AA', 'DD'].includes(x + y);
235
+ if (mode === 'staged' && (x === ' ' || x === '?')) continue;
236
+ if (mode === 'unstaged' && (y === ' ' || y === '!')) continue;
237
+ const code = mode === 'staged' ? x : mode === 'unstaged' ? y : (y !== ' ' && y !== '?' ? y : x);
238
+ entries.push({ path: file, ...(oldPath ? { oldPath } : {}), status: conflict ? 'conflicted' : statuses[code] || 'modified', indexStatus: x, worktreeStatus: y });
239
+ }
240
+ } else {
241
+ const document = parseXML(await svn(repo.root, ['status', '--xml', '--ignore-externals', '--', '.']));
242
+ entries = [];
243
+ for (const target of document.status?.target || []) for (const item of target.entry || []) {
244
+ const state = item['wc-status'];
245
+ if (['ignored', 'external', 'none'].includes(state.item) && !['modified', 'conflicted'].includes(state.props)) continue;
246
+ if (state.item === 'normal' && !['modified', 'conflicted'].includes(state.props) && state['tree-conflicted'] !== 'true') continue;
247
+ const file = (path.isAbsolute(item.path) ? path.relative(repo.root, item.path) : item.path).split(path.sep).join('/');
248
+ entries.push({ path: file, status: state['tree-conflicted'] === 'true' || state.props === 'conflicted' ? 'conflicted' : state.item === 'normal' ? 'modified' : state.item, propertyStatus: state.props, treeConflict: state['tree-conflicted'] === 'true' });
249
+ }
250
+ }
251
+ return entries.map(entry => ({ ...entry, id: identity(entry, mode) }));
252
+ }
253
+ /** Changes have opaque IDs, status words, and repo-relative paths. */
254
+ export async function listChanges(repo, mode = 'all') { repo = await checkedRepo(repo); modeFor(repo, mode); return changes(repo, mode); }
255
+ export function decode(buffer) {
256
+ if (buffer.length > MAX_TEXT) return { text: '', notice: 'File exceeds the 2 MiB preview limit.' };
257
+ let text, encoding = 'UTF-8';
258
+ if (buffer[0] === 0xff && buffer[1] === 0xfe) { if (buffer.length % 2) return { text: '', binary: true, notice: 'Invalid UTF-16 file.' }; text = buffer.subarray(2).toString('utf16le'); encoding = 'UTF-16LE'; }
259
+ else if (buffer[0] === 0xfe && buffer[1] === 0xff) { const b = Buffer.from(buffer.subarray(2)); if (b.length % 2) return { text: '', binary: true, notice: 'Invalid UTF-16 file.' }; b.swap16(); text = b.toString('utf16le'); encoding = 'UTF-16BE'; }
260
+ else {
261
+ if (buffer.includes(0)) return { text: '', binary: true, notice: 'Binary file; text preview unavailable.' };
262
+ if (buffer.some(byte => byte < 32 && ![9,10,12,13].includes(byte))) return { text: '', binary: true, notice: 'Binary control bytes; text preview unavailable.' };
263
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(buffer); }
264
+ catch {
265
+ // A UTF-8 BOM is authoritative; do not reinterpret malformed UTF-8 as GBK.
266
+ if (buffer.subarray(0,3).equals(Buffer.from([0xef,0xbb,0xbf]))) return {text:'',binary:true,notice:'Invalid UTF-8 file.'};
267
+ try { text = new TextDecoder('gbk', {fatal:true}).decode(buffer); encoding = 'GBK'; }
268
+ catch { return {text:'',binary:true,notice:'Unsupported encoding or binary file; preview unavailable.'}; }
269
+ }
270
+ }
271
+ return { text, encoding };
272
+ }
273
+ async function content(fn) { try { return decode(await fn()); } catch (e) { if (e.code === 'TOO_LARGE') return { text: '', notice: 'File exceeds the 2 MiB preview limit.' }; throw e; } }
274
+ async function working(root, relative, svnLink = false) {
275
+ const target = await confined(root, relative);
276
+ let stat; try { stat = await fs.lstat(target); } catch (e) { if (e.code === 'ENOENT') return { text: '' }; throw e; }
277
+ if (stat.isSymbolicLink()) return { text: (svnLink ? 'link ' : '') + await fs.readlink(target) };
278
+ if (!stat.isFile()) return { text: '', notice: 'Directory or special file; no file-content preview.' };
279
+ if (stat.size > MAX_TEXT) return { text: '', notice: 'File exceeds the 2 MiB preview limit.' };
280
+ const handle = await fs.open(target, 'r');
281
+ try { const info = await handle.stat(); if (!info.isFile()) throw new Error('Not a regular file'); const buffer = Buffer.alloc(MAX_TEXT + 1); const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); await confined(root, relative); return decode(buffer.subarray(0, bytesRead)); } finally { await handle.close(); }
282
+ }
283
+ async function gitBlob(root, revision, file) { return content(() => git(root, ['show', '--no-ext-diff', '--no-textconv', revision + ':' + file], MAX_TEXT)); }
284
+ async function properties(root, file, base) {
285
+ const result = parseXML(await svn(root, ['proplist', '--xml', '--verbose', ...(base ? ['--revision', 'BASE'] : []), '--', file + '@']));
286
+ const props = Object.create(null);
287
+ for (const target of result.properties?.target || []) for (const prop of target.property || []) props[prop.name] = prop.encoding === 'base64' ? '[base64] ' + (prop['#text'] || '') : String(prop['#text'] ?? '');
288
+ return props;
289
+ }
290
+ /** Revalidates the opaque ID against fresh status before reading any selected path. */
291
+ export async function getComparison(repo, { mode = 'all', id } = {}) {
292
+ repo = await checkedRepo(repo); modeFor(repo, mode);
293
+ const entry = (await changes(repo, mode)).find(item => item.id === id);
294
+ if (!entry) throw new Error('Change no longer exists; refresh repository status.');
295
+ await confined(repo.root, entry.path); if (entry.oldPath) await confined(repo.root, entry.oldPath);
296
+ let left = { text: '' }, right = { text: '' }, leftLabel, rightLabel, props, notice;
297
+ if (repo.type === 'git') {
298
+ leftLabel = mode === 'unstaged' ? 'Index' : 'HEAD'; rightLabel = mode === 'staged' ? 'Index' : 'Working tree';
299
+ if (entry.status === 'conflicted') {
300
+ notice = 'Unmerged conflict: showing HEAD versus working tree (index has conflict stages).'; leftLabel = 'HEAD'; rightLabel = 'Working tree';
301
+ try { left = await gitBlob(repo.root, 'HEAD', entry.oldPath || entry.path); } catch (e) { if (e.code !== 'VCS_COMMAND') throw e; }
302
+ right = await working(repo.root, entry.path);
303
+ } else {
304
+ let hasHead = true; try { await git(repo.root, ['rev-parse', '--verify', 'HEAD']); } catch (e) { if (e.code !== 'VCS_COMMAND') throw e; hasHead = false; }
305
+ const untracked = entry.indexStatus === '?';
306
+ const addedToHead = entry.indexStatus === 'A' || entry.indexStatus === 'C';
307
+ if (!untracked && (mode === 'unstaged' ? entry.indexStatus !== 'D' : (hasHead && !addedToHead))) left = await gitBlob(repo.root, mode === 'unstaged' ? '' : 'HEAD', mode === 'unstaged' ? entry.path : entry.oldPath || entry.path);
308
+ if (mode === 'staged') { if (entry.indexStatus !== 'D') right = await gitBlob(repo.root, '', entry.path); }
309
+ else right = await working(repo.root, entry.path);
310
+ }
311
+ } else {
312
+ leftLabel = 'BASE'; rightLabel = 'Working copy';
313
+ const added = ['added', 'unversioned', 'replaced'].includes(entry.status);
314
+ if (entry.status === 'replaced') notice = 'Replaced node: local SVN BASE is unavailable; showing empty versus working content without contacting the server.';
315
+ let directory = false;
316
+ try { directory = (await fs.lstat(await confined(repo.root, entry.path))).isDirectory(); } catch (e) { if (e.code !== 'ENOENT') throw e; }
317
+ if (!added && !directory) {
318
+ const info = parseXML(await svn(repo.root, ['info', '--xml', '--', entry.path + '@'])).info.entry[0]; directory = info.kind === 'dir';
319
+ if (!directory) left = await content(() => svn(repo.root, ['cat', '--revision', 'BASE', '--', entry.path + '@'], MAX_TEXT));
320
+ }
321
+ if (directory) notice = 'Directory change; property differences are shown separately.';
322
+ else right = await working(repo.root, entry.path, true);
323
+ if (entry.status !== 'unversioned') props = { left: added ? {} : await properties(repo.root, entry.path, true), right: ['deleted', 'missing'].includes(entry.status) ? {} : await properties(repo.root, entry.path, false) };
324
+ if (entry.status === 'conflicted') notice = 'Conflicted working copy: showing BASE versus working content.';
325
+ }
326
+ return { ...entry, left: { label: leftLabel, text: left.text, encoding: left.encoding }, right: { label: rightLabel, text: right.text, encoding: right.encoding }, ...(left.binary || right.binary ? { binary: true } : {}), ...([notice, left.notice, right.notice].filter(Boolean).length ? { notice: [...new Set([notice, left.notice, right.notice].filter(Boolean))].join(' ') } : {}), ...(props ? { properties: props } : {}) };
327
+ }