@myagentroam/node 0.1.0 → 0.1.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/dist/workspace.js CHANGED
@@ -1,17 +1,270 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { watch } from 'node:fs';
2
3
  import { lstat, readdir, readFile, realpath, stat, unlink } from 'node:fs/promises';
3
4
  import { homedir, platform as hostPlatform } from 'node:os';
4
5
  import { dirname, isAbsolute, posix, relative, resolve, sep, win32 } from 'node:path';
5
6
  import { spawn } from 'node:child_process';
7
+ import { minimatch } from 'minimatch';
6
8
  const GIT_TIMEOUT_MS = 5_000;
7
9
  const GIT_OUTPUT_LIMIT = 512 * 1024;
8
10
  const FILE_READ_LIMIT = 512 * 1024;
9
11
  const DIRECTORY_PAGE_LIMIT = 200;
10
12
  const DIRECTORY_PICKER_LIMIT = 200;
11
13
  const SEARCH_RESULT_LIMIT = 200;
12
- const SEARCH_SCAN_LIMIT = 10_000;
13
- const FILE_SEARCH_TIMEOUT_MS = 2_000;
14
+ const FILE_INDEX_IDLE_TTL_MS = 60 * 60 * 1_000;
15
+ const FILE_INDEX_ENTRY_LIMIT = 100_000;
16
+ const FILE_INDEX_FALLBACK_POLL_MS = 30_000;
17
+ const DEFAULT_QUICK_OPEN_EXCLUDES = [
18
+ '**/.git/**',
19
+ '**/node_modules/**',
20
+ '**/bower_components/**',
21
+ '**/.pnpm/**',
22
+ '**/.yarn/**',
23
+ '**/.cache/**',
24
+ '**/.parcel-cache/**',
25
+ '**/.turbo/**',
26
+ '**/dist/**',
27
+ '**/build/**',
28
+ '**/out/**',
29
+ '**/coverage/**',
30
+ '**/.next/**',
31
+ '**/.nuxt/**',
32
+ '**/.svelte-kit/**',
33
+ '**/.angular/**',
34
+ '**/target/**',
35
+ '**/.gradle/**',
36
+ '**/bin/**',
37
+ '**/obj/**',
38
+ '**/vendor/**',
39
+ '**/__pycache__/**',
40
+ '**/.venv/**',
41
+ '**/venv/**'
42
+ ];
14
43
  const REF_PATTERN = /^(?!-)(?!.*\.\.)(?!.*@\{)[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/;
44
+ /** A per-workspace, process-local index for the Quick Open file picker. */
45
+ export class WorkspaceFileIndex {
46
+ workspaceRoot;
47
+ limits;
48
+ entries = [];
49
+ truncated = false;
50
+ root;
51
+ loading;
52
+ stale = true;
53
+ watcher;
54
+ expiryTimer;
55
+ fallbackTimer;
56
+ signature = '';
57
+ constructor(workspaceRoot, limits = {}) {
58
+ this.workspaceRoot = workspaceRoot;
59
+ this.limits = limits;
60
+ }
61
+ /** Starts/renews the lightweight index observer without exposing file data. */
62
+ async observe() {
63
+ await this.ensureLoaded();
64
+ this.touch();
65
+ }
66
+ dispose() {
67
+ if (this.expiryTimer !== undefined)
68
+ clearTimeout(this.expiryTimer);
69
+ this.expiryTimer = undefined;
70
+ this.watcher?.close();
71
+ this.watcher = undefined;
72
+ if (this.fallbackTimer !== undefined)
73
+ clearInterval(this.fallbackTimer);
74
+ this.fallbackTimer = undefined;
75
+ this.entries = [];
76
+ this.truncated = false;
77
+ this.stale = true;
78
+ this.signature = '';
79
+ }
80
+ async search(query, cursor, limit = SEARCH_RESULT_LIMIT) {
81
+ if (query.trim().length === 0 ||
82
+ query.length > 256 ||
83
+ !Number.isSafeInteger(limit) ||
84
+ limit < 1 ||
85
+ limit > SEARCH_RESULT_LIMIT)
86
+ throw new Error('FILE_SEARCH_LIMIT_EXCEEDED');
87
+ await this.ensureLoaded();
88
+ this.touch();
89
+ const after = decodeSearchCursor(cursor, query);
90
+ const ranked = this.entries
91
+ .map((entry) => ({ entry, score: fuzzyPathScore(entry.path, query) }))
92
+ .filter((candidate) => candidate.score !== -1)
93
+ .sort((left, right) => right.score - left.score || left.entry.path.localeCompare(right.entry.path));
94
+ const start = after === undefined ? 0 : ranked.findIndex((candidate) => candidate.entry.path === after) + 1;
95
+ const page = ranked
96
+ .slice(Math.max(0, start), Math.max(0, start) + limit)
97
+ .map((candidate) => candidate.entry);
98
+ const next = ranked[Math.max(0, start) + limit]?.entry.path;
99
+ return {
100
+ entries: page,
101
+ nextCursor: next === undefined ? null : encodeSearchCursor(query, page.at(-1)?.path ?? after ?? ''),
102
+ truncated: next !== undefined || this.truncated
103
+ };
104
+ }
105
+ async ensureLoaded() {
106
+ if (!this.stale && this.root !== undefined)
107
+ return;
108
+ if (this.loading === undefined) {
109
+ this.loading = this.rebuild().finally(() => {
110
+ this.loading = undefined;
111
+ });
112
+ }
113
+ await this.loading;
114
+ }
115
+ touch() {
116
+ if (this.expiryTimer !== undefined)
117
+ clearTimeout(this.expiryTimer);
118
+ this.expiryTimer = setTimeout(() => this.dispose(), this.limits.idleTtlMs ?? FILE_INDEX_IDLE_TTL_MS);
119
+ this.expiryTimer.unref();
120
+ }
121
+ async rebuild() {
122
+ const root = await realpath(this.workspaceRoot);
123
+ const excludes = [...DEFAULT_QUICK_OPEN_EXCLUDES, ...(await workspaceExcludePatterns(root))];
124
+ const indexed = [];
125
+ const entryLimit = this.limits.entryLimit ?? FILE_INDEX_ENTRY_LIMIT;
126
+ let truncated = false;
127
+ const directories = ['.'];
128
+ while (directories.length > 0 && !truncated) {
129
+ const relativeDirectory = directories.shift();
130
+ const directory = await resolveWorkspaceEntry(root, relativeDirectory, true).catch(() => undefined);
131
+ if (directory === undefined)
132
+ continue;
133
+ const children = await readdir(directory, { withFileTypes: true }).catch(() => undefined);
134
+ if (children === undefined)
135
+ continue;
136
+ for (const child of children) {
137
+ const candidatePath = relative(root, resolve(directory, child.name)).split(sep).join('/');
138
+ if (candidatePath === '.git' ||
139
+ candidatePath.startsWith('.git/') ||
140
+ isExcluded(candidatePath, excludes))
141
+ continue;
142
+ const resolved = await resolveWorkspaceEntry(root, candidatePath, false).catch(() => undefined);
143
+ if (resolved === undefined)
144
+ continue;
145
+ const metadata = await lstat(resolved).catch(() => undefined);
146
+ if (metadata === undefined || (!metadata.isFile() && !metadata.isDirectory()))
147
+ continue;
148
+ const safePath = relative(root, resolved).split(sep).join('/');
149
+ if (metadata.isDirectory()) {
150
+ directories.push(safePath);
151
+ continue;
152
+ }
153
+ indexed.push({
154
+ path: safePath,
155
+ name: child.name,
156
+ kind: 'FILE',
157
+ size: metadata.size,
158
+ modifiedAt: metadata.mtimeMs
159
+ });
160
+ if (indexed.length >= entryLimit) {
161
+ truncated = true;
162
+ break;
163
+ }
164
+ }
165
+ }
166
+ this.root = root;
167
+ this.entries = indexed;
168
+ this.truncated = truncated;
169
+ this.signature = fileIndexSignature(indexed, truncated);
170
+ this.stale = false;
171
+ this.watcher?.close();
172
+ // fs.watch is advisory. Any change safely invalidates the in-memory index;
173
+ // the next lookup rebuilds it and never trusts stale paths for file reads.
174
+ try {
175
+ this.watcher = watch(root, { persistent: false, recursive: true }, () => {
176
+ this.invalidate();
177
+ });
178
+ this.watcher.on('error', () => {
179
+ this.invalidate();
180
+ this.watcher?.close();
181
+ this.watcher = undefined;
182
+ this.startFallbackPolling();
183
+ });
184
+ }
185
+ catch {
186
+ // Recursive watching is unavailable on some filesystems. A low-frequency
187
+ // scan still rebuilds the index, but only emits after its metadata changed.
188
+ this.watcher = undefined;
189
+ this.startFallbackPolling();
190
+ }
191
+ }
192
+ startFallbackPolling() {
193
+ if (this.fallbackTimer !== undefined)
194
+ return;
195
+ this.fallbackTimer = setInterval(() => void this.pollFallback(), FILE_INDEX_FALLBACK_POLL_MS);
196
+ this.fallbackTimer.unref();
197
+ }
198
+ async pollFallback() {
199
+ if (this.loading !== undefined)
200
+ return;
201
+ const previous = this.signature;
202
+ this.stale = true;
203
+ try {
204
+ await this.ensureLoaded();
205
+ if (previous !== this.signature)
206
+ this.limits.onInvalidated?.();
207
+ }
208
+ catch {
209
+ // Keep the current last-known index. A later poll can recover when the
210
+ // Workspace becomes readable again.
211
+ this.stale = false;
212
+ }
213
+ }
214
+ invalidate() {
215
+ if (this.stale)
216
+ return;
217
+ this.stale = true;
218
+ this.limits.onInvalidated?.();
219
+ }
220
+ }
221
+ function fileIndexSignature(entries, truncated) {
222
+ return JSON.stringify({
223
+ truncated,
224
+ entries: entries.map((entry) => [entry.path, entry.size, entry.modifiedAt])
225
+ });
226
+ }
227
+ async function workspaceExcludePatterns(root) {
228
+ const settingsPath = resolve(root, '.vscode/settings.json');
229
+ const settings = (await readFile(settingsPath, 'utf8')
230
+ .then(JSON.parse)
231
+ .catch(() => undefined));
232
+ if (typeof settings !== 'object' || settings === null)
233
+ return [];
234
+ return ['files.exclude', 'search.exclude'].flatMap((key) => {
235
+ const value = settings[key];
236
+ return typeof value === 'object' && value !== null
237
+ ? Object.entries(value).flatMap(([pattern, enabled]) => (enabled === true ? [pattern] : []))
238
+ : [];
239
+ });
240
+ }
241
+ function isExcluded(path, patterns) {
242
+ return patterns.some((pattern) => minimatch(path, pattern, { dot: true, nocase: hostPlatform() !== 'linux' }) ||
243
+ minimatch(`${path}/`, pattern, { dot: true, nocase: hostPlatform() !== 'linux' }));
244
+ }
245
+ function fuzzyPathScore(path, query) {
246
+ const source = path.toLocaleLowerCase();
247
+ const needle = query.trim().toLocaleLowerCase();
248
+ let cursor = 0;
249
+ let score = 0;
250
+ let previous = -2;
251
+ for (const character of needle) {
252
+ const index = source.indexOf(character, cursor);
253
+ if (index === -1)
254
+ return -1;
255
+ score += index === previous + 1 ? 12 : 1;
256
+ if (index === 0 || '/._-'.includes(source[index - 1] ?? ''))
257
+ score += 8;
258
+ previous = index;
259
+ cursor = index + 1;
260
+ }
261
+ const name = path.slice(path.lastIndexOf('/') + 1).toLocaleLowerCase();
262
+ if (name.includes(needle))
263
+ score += 40;
264
+ else if (source.includes(needle))
265
+ score += 16;
266
+ return score - path.length / 1_000;
267
+ }
15
268
  /**
16
269
  * Lists directory names only for the Workspace picker. Browsing begins at the
17
270
  * first allowed root when configured, while the Node account home remains a
@@ -51,10 +304,10 @@ export async function listWorkspaceDirectories(requestedPath, allowedRoots, home
51
304
  truncated: entries.length > DIRECTORY_PICKER_LIMIT
52
305
  };
53
306
  }
54
- export async function listWorkspaceFiles(workspaceRoot, requestedPath = '.', cursor, limit = DIRECTORY_PAGE_LIMIT, options = {}) {
307
+ export async function listWorkspaceFiles(workspaceRoot, requestedPath = '.', cursor, limit = DIRECTORY_PAGE_LIMIT) {
55
308
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > DIRECTORY_PAGE_LIMIT)
56
309
  throw new Error('DIRECTORY_CURSOR_INVALID');
57
- const directory = await resolveWorkspaceEntry(workspaceRoot, requestedPath, true, options);
310
+ const directory = await resolveWorkspaceEntry(workspaceRoot, requestedPath, true);
58
311
  const entries = await readdir(directory, { withFileTypes: true });
59
312
  const root = await realpath(workspaceRoot);
60
313
  const cursorValue = decodeDirectoryCursor(cursor, requestedPath);
@@ -68,10 +321,18 @@ export async function listWorkspaceFiles(workspaceRoot, requestedPath = '.', cur
68
321
  const pageNames = names.slice(start, start + limit);
69
322
  const output = [];
70
323
  for (const name of pageNames) {
71
- const child = await resolveWorkspaceEntry(root, relative(root, resolve(directory, name)), false, options).catch(() => undefined);
324
+ const child = await resolveWorkspaceEntry(root, relative(root, resolve(directory, name)), false).catch((error) => {
325
+ if (isSkippableWorkspaceEntryError(error))
326
+ return undefined;
327
+ throw error;
328
+ });
72
329
  if (child === undefined)
73
330
  continue;
74
- const metadata = await lstat(child).catch(() => undefined);
331
+ const metadata = await lstat(child).catch((error) => {
332
+ if (error.code === 'ENOENT')
333
+ return undefined;
334
+ throw error;
335
+ });
75
336
  if (metadata === undefined || (!metadata.isFile() && !metadata.isDirectory()))
76
337
  continue;
77
338
  output.push({
@@ -91,8 +352,14 @@ export async function listWorkspaceFiles(workspaceRoot, requestedPath = '.', cur
91
352
  truncated: nextName !== undefined
92
353
  };
93
354
  }
355
+ function isSkippableWorkspaceEntryError(error) {
356
+ return (error instanceof Error &&
357
+ (error.message === 'FILE_PATH_ESCAPE' ||
358
+ error.message === 'FILE_NOT_FOUND' ||
359
+ error.message === 'FILE_NOT_REGULAR'));
360
+ }
94
361
  /** Validates an upload destination without following an existing target symlink. */
95
- export async function preflightWorkspaceUpload(workspaceRoot, requestedDirectory, name, options = {}) {
362
+ export async function preflightWorkspaceUpload(workspaceRoot, requestedDirectory, name) {
96
363
  if (name.length === 0 ||
97
364
  name === '.' ||
98
365
  name === '..' ||
@@ -103,11 +370,10 @@ export async function preflightWorkspaceUpload(workspaceRoot, requestedDirectory
103
370
  return { path: '', absolutePath: '', status: 'INVALID_NAME' };
104
371
  }
105
372
  const root = await realpath(workspaceRoot);
106
- const directory = await resolveWorkspaceEntry(root, requestedDirectory, true, options);
373
+ const directory = await resolveWorkspaceEntry(root, requestedDirectory, true);
107
374
  const absolutePath = resolve(directory, name);
108
375
  if (dirname(absolutePath) !== directory)
109
376
  throw new Error('FILE_PATH_ESCAPE');
110
- assertNotPrivate(absolutePath, options.privateRoots);
111
377
  const relativePath = relative(root, absolutePath).split(sep).join('/');
112
378
  const metadata = await lstat(absolutePath).catch((error) => {
113
379
  if (error.code === 'ENOENT')
@@ -128,7 +394,7 @@ export function workspaceUploadTemporaryName(uploadId) {
128
394
  function isWorkspaceUploadTemporaryName(name) {
129
395
  return /^\.mar-upload-[0-9a-f-]{36}\.part$/i.test(name);
130
396
  }
131
- export async function readWorkspaceTextFile(workspaceRoot, requestedPath, offset = 0, limit = FILE_READ_LIMIT, options = {}) {
397
+ export async function readWorkspaceTextFile(workspaceRoot, requestedPath, offset = 0, limit = FILE_READ_LIMIT) {
132
398
  if (!Number.isSafeInteger(offset) ||
133
399
  offset < 0 ||
134
400
  !Number.isSafeInteger(limit) ||
@@ -136,7 +402,7 @@ export async function readWorkspaceTextFile(workspaceRoot, requestedPath, offset
136
402
  limit > FILE_READ_LIMIT)
137
403
  throw new Error('FILE_RANGE_INVALID');
138
404
  const root = await realpath(workspaceRoot);
139
- const file = await resolveWorkspaceEntry(root, requestedPath, false, options);
405
+ const file = await resolveWorkspaceEntry(root, requestedPath, false);
140
406
  const metadata = await lstat(file);
141
407
  if (!metadata.isFile())
142
408
  throw new Error('FILE_NOT_REGULAR');
@@ -181,7 +447,7 @@ export async function readWorkspaceTextFile(workspaceRoot, requestedPath, offset
181
447
  };
182
448
  }
183
449
  /** Reads one absolute file only when the resolved target remains within a Node allowed root. */
184
- export async function readAllowedTextFile(requestedPath, allowedRoots, offset = 0, limit = FILE_READ_LIMIT, options = {}) {
450
+ export async function readAllowedTextFile(requestedPath, allowedRoots, offset = 0, limit = FILE_READ_LIMIT) {
185
451
  if (!isAbsolute(requestedPath))
186
452
  throw new Error('FILE_PATH_ESCAPE');
187
453
  const file = await realpath(requestedPath).catch((error) => {
@@ -192,7 +458,6 @@ export async function readAllowedTextFile(requestedPath, allowedRoots, offset =
192
458
  const roots = await Promise.all(allowedRoots.map((root) => realpath(root)));
193
459
  if (!isWithinAllowedRoot(file, roots))
194
460
  throw new Error('FILE_PATH_ESCAPE');
195
- assertNotPrivate(file, options.privateRoots);
196
461
  const metadata = await lstat(file);
197
462
  if (!metadata.isFile())
198
463
  throw new Error('FILE_NOT_REGULAR');
@@ -253,83 +518,16 @@ function isUtf8ContinuationByte(value) {
253
518
  return (value & 0b1100_0000) === 0b1000_0000;
254
519
  }
255
520
  /** Searches file names only, so an exploratory request cannot exfiltrate file contents. */
256
- export async function searchWorkspaceFiles(workspaceRoot, query, cursor, limit = SEARCH_RESULT_LIMIT, options = {}) {
257
- if (query.trim().length === 0 || query.length > 256)
258
- throw new Error('FILE_SEARCH_LIMIT_EXCEEDED');
259
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > SEARCH_RESULT_LIMIT)
260
- throw new Error('FILE_SEARCH_LIMIT_EXCEEDED');
261
- const root = await realpath(workspaceRoot);
262
- assertNotPrivate(root, options.privateRoots);
263
- const after = decodeSearchCursor(cursor, query);
264
- const deadline = Date.now() + FILE_SEARCH_TIMEOUT_MS;
265
- const results = [];
266
- let scanned = 0;
267
- let exhausted = true;
268
- const directories = ['.'];
269
- const normalizedQuery = query.toLocaleLowerCase();
270
- while (directories.length > 0) {
271
- if (Date.now() > deadline || scanned >= SEARCH_SCAN_LIMIT) {
272
- exhausted = false;
273
- break;
274
- }
275
- const directoryPath = directories.shift();
276
- const directory = await resolveWorkspaceEntry(root, directoryPath, true, options).catch((error) => {
277
- if (error instanceof Error && error.message === 'FILE_NOT_FOUND')
278
- return undefined;
279
- throw error;
280
- });
281
- if (directory === undefined)
282
- continue;
283
- const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
284
- if (error.code === 'ENOENT')
285
- return undefined;
286
- throw error;
287
- });
288
- if (entries === undefined)
289
- continue;
290
- for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
291
- if (entry.name === '.git')
292
- continue;
293
- scanned += 1;
294
- if (scanned > SEARCH_SCAN_LIMIT || Date.now() > deadline) {
295
- exhausted = false;
296
- break;
297
- }
298
- const requestPath = relative(root, resolve(directory, entry.name)).split(sep).join('/');
299
- const child = await resolveWorkspaceEntry(root, requestPath, false, options).catch(() => undefined);
300
- if (child === undefined)
301
- continue;
302
- const metadata = await lstat(child).catch(() => undefined);
303
- if (metadata === undefined || (!metadata.isFile() && !metadata.isDirectory()))
304
- continue;
305
- const safePath = relative(root, child).split(sep).join('/');
306
- if (metadata.isDirectory())
307
- directories.push(safePath);
308
- if (safePath <= (after ?? '') || !safePath.toLocaleLowerCase().includes(normalizedQuery))
309
- continue;
310
- results.push({
311
- path: safePath,
312
- name: entry.name,
313
- kind: metadata.isDirectory() ? 'DIRECTORY' : 'FILE',
314
- size: metadata.isFile() ? metadata.size : null,
315
- modifiedAt: metadata.mtimeMs
316
- });
317
- if (results.length > limit) {
318
- exhausted = false;
319
- break;
320
- }
321
- }
322
- if (!exhausted || results.length > limit)
323
- break;
521
+ export async function searchWorkspaceFiles(workspaceRoot, query, cursor, limit = SEARCH_RESULT_LIMIT) {
522
+ const index = new WorkspaceFileIndex(workspaceRoot);
523
+ try {
524
+ return await index.search(query, cursor, limit);
525
+ }
526
+ finally {
527
+ index.dispose();
324
528
  }
325
- results.sort((left, right) => left.path.localeCompare(right.path));
326
- const page = results.slice(0, limit);
327
- const nextCursor = !exhausted || results.length > limit
328
- ? encodeSearchCursor(query, page[page.length - 1]?.path ?? after ?? '')
329
- : null;
330
- return { entries: page, nextCursor, truncated: nextCursor !== null };
331
529
  }
332
- async function resolveWorkspaceEntry(workspaceRoot, requestedPath, directory, options = {}) {
530
+ async function resolveWorkspaceEntry(workspaceRoot, requestedPath, directory) {
333
531
  if (requestedPath.includes('\0') ||
334
532
  requestedPath.length === 0 ||
335
533
  requestedPath.startsWith('/') ||
@@ -353,20 +551,11 @@ async function resolveWorkspaceEntry(workspaceRoot, requestedPath, directory, op
353
551
  pathToCandidate === '..' ||
354
552
  pathToCandidate.startsWith(`..${sep}`))
355
553
  throw new Error('FILE_PATH_ESCAPE');
356
- assertNotPrivate(candidate, options.privateRoots);
357
554
  const metadata = await lstat(candidate);
358
555
  if (directory ? !metadata.isDirectory() : !metadata.isFile() && !metadata.isDirectory())
359
556
  throw new Error(directory ? 'FILE_NOT_DIRECTORY' : 'FILE_NOT_REGULAR');
360
557
  return candidate;
361
558
  }
362
- function assertNotPrivate(candidate, privateRoots) {
363
- if (privateRoots?.some((privateRoot) => isSameOrDescendant(privateRoot, candidate)))
364
- throw new Error('FILE_PATH_ESCAPE');
365
- }
366
- function isSameOrDescendant(root, candidate) {
367
- const relativePath = relative(root, candidate);
368
- return (relativePath === '' || (!relativePath.startsWith('..') && !relativePath.startsWith(`..${sep}`)));
369
- }
370
559
  function encodeDirectoryCursor(path, name) {
371
560
  return Buffer.from(JSON.stringify({ path, name })).toString('base64url');
372
561
  }
@@ -554,6 +743,7 @@ export async function readCurrentChanges(workspacePath) {
554
743
  }
555
744
  export async function readCurrentChangeDiff(workspacePath, requestedPath) {
556
745
  const path = safeGitWorkspacePath(requestedPath);
746
+ await assertCurrentChangePathAccessible(workspacePath, path);
557
747
  const result = await runGit(workspacePath, [
558
748
  'diff',
559
749
  '--no-ext-diff',
@@ -579,6 +769,33 @@ export async function readCurrentChangeDiff(workspacePath, requestedPath) {
579
769
  ...(after === undefined ? {} : { afterTruncated: after.truncated })
580
770
  };
581
771
  }
772
+ /** Current Changes must not expose an external link. */
773
+ export async function isWorkspaceChangeVisible(workspacePath, requestedPath) {
774
+ try {
775
+ await assertCurrentChangePathAccessible(workspacePath, safeGitWorkspacePath(requestedPath));
776
+ return true;
777
+ }
778
+ catch (error) {
779
+ if (error instanceof Error &&
780
+ (error.message === 'FILE_PATH_ESCAPE' ||
781
+ error.message === 'FILE_NOT_FOUND' ||
782
+ error.message === 'FILE_NOT_REGULAR'))
783
+ return false;
784
+ throw error;
785
+ }
786
+ }
787
+ async function assertCurrentChangePathAccessible(workspacePath, path) {
788
+ const root = await realpath(workspacePath);
789
+ const candidate = resolve(root, path);
790
+ const metadata = await lstat(candidate).catch((error) => {
791
+ if (error.code === 'ENOENT')
792
+ return undefined;
793
+ throw error;
794
+ });
795
+ if (metadata === undefined)
796
+ return;
797
+ await resolveWorkspaceEntry(root, path, false);
798
+ }
582
799
  async function readWorkingTreeText(workspacePath, path) {
583
800
  const root = await realpath(workspacePath);
584
801
  let file;
@@ -601,7 +818,7 @@ function diffTextFromBuffer(source, truncated) {
601
818
  end -= 1;
602
819
  return { binary: false, text: source.subarray(0, end).toString('utf8'), truncated };
603
820
  }
604
- export async function restoreCurrentChange(workspacePath, requestedPath, state, options = {}) {
821
+ export async function restoreCurrentChange(workspacePath, requestedPath, state) {
605
822
  const path = safeGitWorkspacePath(requestedPath);
606
823
  const pathspec = `:(literal)${path}`;
607
824
  if (state === 'UNTRACKED') {
@@ -619,7 +836,6 @@ export async function restoreCurrentChange(workspacePath, requestedPath, state,
619
836
  throw new Error('GIT_CHANGE_RESTORE_UNAVAILABLE');
620
837
  const root = await realpath(workspacePath);
621
838
  const file = resolve(root, path);
622
- assertNotPrivate(file, options.privateRoots);
623
839
  const metadata = await lstat(file);
624
840
  if (!metadata.isFile() || metadata.isSymbolicLink())
625
841
  throw new Error('GIT_CHANGE_RESTORE_UNAVAILABLE');