@young1lin/dsh-ui-gitworkbench 0.1.15 → 0.1.17
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 +26 -0
- package/CHANGELOG_EN.md +26 -0
- package/README.md +30 -5
- package/README_EN.md +1 -1
- package/lib/client.js +1600 -519
- package/lib/dir-listing.js +34 -0
- package/lib/fs-remove.js +5 -36
- package/lib/index.js +233 -52
- package/lib/path-lock.js +54 -0
- package/lib/worktree.js +133 -0
- package/lib/write-checked.js +1 -1
- package/package.json +1 -1
- package/src/client/ChromeGlyph.tsx +5 -0
- package/src/client/CodeEditor.tsx +19 -1
- package/src/client/DiffViews.tsx +122 -123
- package/src/client/FileBrowser.tsx +196 -23
- package/src/client/GitWorkbenchPanel.module.css +1 -0
- package/src/client/GitWorkbenchPanel.tsx +114 -12
- package/src/client/SideRails.tsx +106 -0
- package/src/client/diff-cells.tsx +147 -0
- package/src/client/diff-model.ts +20 -0
- package/src/client/diff-nav.ts +4 -1
- package/src/client/dir-tree.ts +31 -1
- package/src/client/file-rows.ts +40 -0
- package/src/client/h-rail.ts +70 -0
- package/src/client/ignored-cache.ts +193 -0
- package/src/client/index.ts +22 -3
- package/src/client/locales.ts +18 -4
- package/src/client/row-heights.ts +225 -0
- package/src/client/styles/changes.css +33 -2
- package/src/client/styles/controls.css +5 -0
- package/src/client/styles/files.css +5 -0
- package/src/client/styles/rails.css +72 -0
- package/src/client/use-row-window.ts +7 -3
- package/src/client/use-variable-row-window.ts +210 -0
- package/src/dir-listing.ts +47 -0
- package/src/fs-remove.ts +5 -36
- package/src/index.ts +257 -55
- package/src/path-lock.ts +56 -0
- package/src/types/dsh-shim.d.ts +12 -2
- package/src/worktree.ts +153 -0
- package/src/write-checked.ts +1 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One lazily listed directory, shaped for the wire.
|
|
3
|
+
*
|
|
4
|
+
* The Files tab browses ignored directories by reading them from the
|
|
5
|
+
* filesystem ONE level at a time — git cannot do this scoped: a pathspec
|
|
6
|
+
* under `--directory` still collapses the whole ignored directory to a
|
|
7
|
+
* single line, and dropping `--directory` would enumerate every file inside
|
|
8
|
+
* `node_modules` at once. A `readdir` is one level by construction, costs
|
|
9
|
+
* milliseconds, and says what a browser wants to know: what is HERE.
|
|
10
|
+
*
|
|
11
|
+
* Only the shaping is pure (ordering, capping); the read itself stays in
|
|
12
|
+
* `index.ts`, which vitest cannot load. Same split as `fs-remove.ts`.
|
|
13
|
+
*
|
|
14
|
+
* @module @young1lin/dsh-ui-gitworkbench/dir-listing
|
|
15
|
+
*/
|
|
16
|
+
/** The most entries one expansion returns. A real directory level is a few
|
|
17
|
+
* hundred at most (`node_modules`'s own top level); a cap beyond that is a
|
|
18
|
+
* reported fuse against a pathological directory, not a working number. */
|
|
19
|
+
export const DIR_CHILD_CAP = 5_000;
|
|
20
|
+
/**
|
|
21
|
+
* Order and cap raw readdir results: directories before files (the shape
|
|
22
|
+
* every file tree has, matching `treeRows`), each run by name, and a cut
|
|
23
|
+
* REPORTED rather than silent.
|
|
24
|
+
*
|
|
25
|
+
* @param raw - the directory's entries with their best-known dir-ness
|
|
26
|
+
* (symlinks already resolved by the caller).
|
|
27
|
+
* @param cap - most entries to return.
|
|
28
|
+
*/
|
|
29
|
+
export function shapeDirChildren(raw, cap = DIR_CHILD_CAP) {
|
|
30
|
+
const byName = (a, b) => a.name.localeCompare(b.name);
|
|
31
|
+
const ordered = [...raw.filter(entry => entry.dir).sort(byName), ...raw.filter(entry => !entry.dir).sort(byName)];
|
|
32
|
+
const truncated = ordered.length > cap;
|
|
33
|
+
return { entries: truncated ? ordered.slice(0, cap) : ordered, truncated };
|
|
34
|
+
}
|
package/lib/fs-remove.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* `git clean` refuses paths it cannot index, which on Windows includes every
|
|
7
7
|
* reserved device name (`nul`, `con`, `aux`, `com1`, and the same names with
|
|
8
8
|
* any extension). So the removal goes through the filesystem, where git's own
|
|
9
|
-
* refusal to leave the repository does not apply — hence the
|
|
10
|
-
* rather than a bare `rm`.
|
|
9
|
+
* refusal to leave the repository does not apply — hence the path lock in
|
|
10
|
+
* `path-lock.ts` rather than a bare `rm`.
|
|
11
11
|
*
|
|
12
12
|
* Lives outside `index.ts` so vitest can load it: the class there needs the
|
|
13
13
|
* dsh runtime, and the property worth testing is "what does this delete, and
|
|
@@ -16,36 +16,7 @@
|
|
|
16
16
|
* @module @young1lin/dsh-ui-gitworkbench/fs-remove
|
|
17
17
|
*/
|
|
18
18
|
import { rm } from 'node:fs/promises';
|
|
19
|
-
import {
|
|
20
|
-
import { isSafeRelativePath } from './discard-ops.js';
|
|
21
|
-
/**
|
|
22
|
-
* Resolve a repo-relative path against the worktree root, refusing to leave it.
|
|
23
|
-
*
|
|
24
|
-
* The second lock rather than the only one: {@link isSafeRelativePath} already
|
|
25
|
-
* rejected traversal spellings when the plan was made. This re-checks the
|
|
26
|
-
* RESOLVED path, which is the form the filesystem acts on, so a path that
|
|
27
|
-
* survives the first check by being spelled unusually still has to land inside
|
|
28
|
-
* the root to be acted on.
|
|
29
|
-
*
|
|
30
|
-
* @param root - the worktree directory, absolute.
|
|
31
|
-
* @param relative - repo-relative path from a plan step.
|
|
32
|
-
* @returns the absolute path to act on.
|
|
33
|
-
* @throws if the path is not a safe relative path, resolves outside the root,
|
|
34
|
-
* or IS the root.
|
|
35
|
-
*/
|
|
36
|
-
export function resolveInside(root, relative) {
|
|
37
|
-
if (!isSafeRelativePath(relative)) {
|
|
38
|
-
throw new Error(`unsafe path to delete: ${JSON.stringify(relative)}`);
|
|
39
|
-
}
|
|
40
|
-
const base = resolve(root);
|
|
41
|
-
const target = resolve(base, relative);
|
|
42
|
-
if (target === base)
|
|
43
|
-
throw new Error('refusing to delete the worktree root');
|
|
44
|
-
if (!target.startsWith(base + sep)) {
|
|
45
|
-
throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`);
|
|
46
|
-
}
|
|
47
|
-
return target;
|
|
48
|
-
}
|
|
19
|
+
import { resolveInside } from './path-lock.js';
|
|
49
20
|
/**
|
|
50
21
|
* Remove one entry from the worktree, having proven it is inside it.
|
|
51
22
|
*
|
|
@@ -60,10 +31,8 @@ export function resolveInside(root, relative) {
|
|
|
60
31
|
* `force` makes an absent entry a success: the reader asked for it to be gone,
|
|
61
32
|
* and it is.
|
|
62
33
|
*
|
|
63
|
-
* A symlinked directory inside the worktree could still point outward
|
|
64
|
-
*
|
|
65
|
-
* per segment on every delete would cost a stat per segment for a case git
|
|
66
|
-
* itself does not defend against.
|
|
34
|
+
* A symlinked directory inside the worktree could still point outward — the
|
|
35
|
+
* limit of a lexical resolve, stated where the lock is.
|
|
67
36
|
*
|
|
68
37
|
* @param root - the worktree directory, absolute.
|
|
69
38
|
* @param relative - repo-relative path from a plan step.
|
package/lib/index.js
CHANGED
|
@@ -76,7 +76,7 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
|
|
|
76
76
|
* @module @young1lin/dsh-ui-gitworkbench
|
|
77
77
|
*/
|
|
78
78
|
import { randomBytes } from 'node:crypto';
|
|
79
|
-
import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
79
|
+
import { mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
80
80
|
import { homedir, tmpdir } from 'node:os';
|
|
81
81
|
import { join } from 'node:path';
|
|
82
82
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
@@ -86,9 +86,11 @@ import { saveJsonAtomic } from './atomic-json.js';
|
|
|
86
86
|
import { runWriteChecked } from './write-checked.js';
|
|
87
87
|
import { CommitPayloadCache, cacheKey } from './commit-cache.js';
|
|
88
88
|
import { NETWORK_GRACE_MS, NON_INTERACTIVE_ENV, capBranches, classifyFailure, clipDiff, commitArgv, countBufferLines, decodesAsUtf8, fetchArgv, isBinaryPrefix, isNoMergeBaseError, isSafePathArg, parseNameStatus, parseNumstat, parseStatus, parseTracking, pullArgv, pushArgv, stageArgv, unstageArgv, } from './git-ops.js';
|
|
89
|
-
import { planFromStatus, } from './discard-ops.js';
|
|
89
|
+
import { isSafeRelativePath, planFromStatus, } from './discard-ops.js';
|
|
90
|
+
import { shapeDirChildren } from './dir-listing.js';
|
|
90
91
|
import { parseBlame } from './blame.js';
|
|
91
92
|
import { removePathInside } from './fs-remove.js';
|
|
93
|
+
import { resolveInside } from './path-lock.js';
|
|
92
94
|
import { resolveRepoRoot, rootedDir } from './repo-root.js';
|
|
93
95
|
import { diffTooLarge, targetTooLarge, SIDE_BYTE_CAP, SIDE_LINE_CAP } from './side-guard.js';
|
|
94
96
|
import { IMAGE_BYTE_CAP, sniffImage } from './image-sniff.js';
|
|
@@ -96,7 +98,7 @@ import { LOG_FORMAT, parseLog } from './git-log.js';
|
|
|
96
98
|
import { emptyLogFilter, logFilterArgs } from './log-filter.js';
|
|
97
99
|
import { parseShortlog } from './shortlog.js';
|
|
98
100
|
import { isBlankEntry, loadStyle, sanitizeEntry, stylePath, } from './style-store.js';
|
|
99
|
-
import { bindingsPath, findRegisteredWorktree, isRefName, loadBindings, parseWorktreeList, sanitizeName, saveBindings, worktreeDir, } from './worktree.js';
|
|
101
|
+
import { bindingNotice, bindingsPath, findRegisteredWorktree, isRefName, lineageEdgeOf, loadBindings, parseWorktreeList, resolveEffectiveBinding, resolveEnterBranch, sanitizeName, saveBindings, worktreeDir, } from './worktree.js';
|
|
100
102
|
/** Cap the bundled unified diff so a huge change cannot blow the RPC response. */
|
|
101
103
|
const DIFF_CHAR_CAP = 400_000;
|
|
102
104
|
/** Untracked files larger than this are listed + counted but never diffed. */
|
|
@@ -121,6 +123,11 @@ const SHORTLOG_CAP = 500;
|
|
|
121
123
|
/** Path list cap for the picker: a monorepo can outrun any popup; past this
|
|
122
124
|
* the tree is cut and the truncation reported, never silent. */
|
|
123
125
|
const TREE_PATH_CAP = 50_000;
|
|
126
|
+
/** Cap on the ignored entry listing `repoTree` rides along. The listing is
|
|
127
|
+
* proportional to the .gitignore's coverage, not the repository (git
|
|
128
|
+
* collapses every fully-ignored directory to one line), so real repos sit in
|
|
129
|
+
* the tens; this is a reported fuse for a pathological ignore setup. */
|
|
130
|
+
const IGNORED_PATH_CAP = 5_000;
|
|
124
131
|
/**
|
|
125
132
|
* Most branch names sent to the browser. `worktreeStatus` is polled, so an
|
|
126
133
|
* unbounded list would repeat on the wire every few seconds; the picker reports
|
|
@@ -164,6 +171,7 @@ let GitWorkbenchService = (() => {
|
|
|
164
171
|
let _commits_decorators;
|
|
165
172
|
let _authors_decorators;
|
|
166
173
|
let _repoTree_decorators;
|
|
174
|
+
let _ignoredDir_decorators;
|
|
167
175
|
let _compareRefs_decorators;
|
|
168
176
|
let _sessionWorktree_decorators;
|
|
169
177
|
let _worktreeEnter_decorators;
|
|
@@ -194,6 +202,7 @@ let GitWorkbenchService = (() => {
|
|
|
194
202
|
_commits_decorators = [Remote('commits')];
|
|
195
203
|
_authors_decorators = [Remote('authors')];
|
|
196
204
|
_repoTree_decorators = [Remote('repoTree')];
|
|
205
|
+
_ignoredDir_decorators = [Remote('ignoredDir')];
|
|
197
206
|
_compareRefs_decorators = [Remote('compareRefs')];
|
|
198
207
|
_sessionWorktree_decorators = [Remote('sessionWorktree')];
|
|
199
208
|
_worktreeEnter_decorators = [Remote('worktreeEnter')];
|
|
@@ -221,6 +230,7 @@ let GitWorkbenchService = (() => {
|
|
|
221
230
|
__esDecorate(this, null, _commits_decorators, { kind: "method", name: "commits", static: false, private: false, access: { has: obj => "commits" in obj, get: obj => obj.commits }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
222
231
|
__esDecorate(this, null, _authors_decorators, { kind: "method", name: "authors", static: false, private: false, access: { has: obj => "authors" in obj, get: obj => obj.authors }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
223
232
|
__esDecorate(this, null, _repoTree_decorators, { kind: "method", name: "repoTree", static: false, private: false, access: { has: obj => "repoTree" in obj, get: obj => obj.repoTree }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
233
|
+
__esDecorate(this, null, _ignoredDir_decorators, { kind: "method", name: "ignoredDir", static: false, private: false, access: { has: obj => "ignoredDir" in obj, get: obj => obj.ignoredDir }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
224
234
|
__esDecorate(this, null, _compareRefs_decorators, { kind: "method", name: "compareRefs", static: false, private: false, access: { has: obj => "compareRefs" in obj, get: obj => obj.compareRefs }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
225
235
|
__esDecorate(this, null, _sessionWorktree_decorators, { kind: "method", name: "sessionWorktree", static: false, private: false, access: { has: obj => "sessionWorktree" in obj, get: obj => obj.sessionWorktree }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
226
236
|
__esDecorate(this, null, _worktreeEnter_decorators, { kind: "method", name: "worktreeEnter", static: false, private: false, access: { has: obj => "worktreeEnter" in obj, get: obj => obj.worktreeEnter }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
@@ -250,10 +260,32 @@ let GitWorkbenchService = (() => {
|
|
|
250
260
|
* mutation updates this inside the same critical section that writes it.
|
|
251
261
|
*/
|
|
252
262
|
bindingMirror = new Map();
|
|
263
|
+
/**
|
|
264
|
+
* Session id → parent session id, as `agent/session-start` delivered it. A
|
|
265
|
+
* subagent header names its parent, and that edge is all the lineage walk
|
|
266
|
+
* needs: a child session without a binding of its own works under its
|
|
267
|
+
* nearest bound ancestor (see {@link resolveEffectiveBinding}). The map is
|
|
268
|
+
* never pruned — it holds one short string per session this process has
|
|
269
|
+
* seen, and a stale edge can only make a lookup walk further, never lie.
|
|
270
|
+
*/
|
|
271
|
+
parentOf = new Map();
|
|
253
272
|
constructor(ctx) {
|
|
254
273
|
super(ctx, 'gitWorkbench');
|
|
255
274
|
this.registerWorktreeTools(ctx);
|
|
256
275
|
this.registerWorktreePrompt(ctx);
|
|
276
|
+
// Fires for every session the process publishes — fresh subagents and
|
|
277
|
+
// sessions whose loop resumes from disk. An IDLE session's edge is absent
|
|
278
|
+
// until its loop (re)starts, so a lookup can simply find no ancestor right
|
|
279
|
+
// after a host restart — the pre-feature behavior, fail-soft. `events.on`
|
|
280
|
+
// (not the typed `ctx.on` overload) because the event is declared by
|
|
281
|
+
// @deepseek-ai/dsh-agent, which this plugin does not depend on; the
|
|
282
|
+
// listener lives on this ctx's fiber.
|
|
283
|
+
ctx.events.on('agent/session-start', (payload) => {
|
|
284
|
+
const session = payload.agent?.session;
|
|
285
|
+
const parent = lineageEdgeOf(session?.header);
|
|
286
|
+
if (session !== undefined && parent !== undefined)
|
|
287
|
+
this.parentOf.set(session.id, parent);
|
|
288
|
+
});
|
|
257
289
|
// Hydrate the mirror through the same queue as the mutations, so a binding
|
|
258
290
|
// written before hydration finishes is not overwritten by the stale read.
|
|
259
291
|
// A failed read leaves the mirror empty: sessions then get no standing
|
|
@@ -285,17 +317,20 @@ let GitWorkbenchService = (() => {
|
|
|
285
317
|
name: 'worktree:binding',
|
|
286
318
|
order: 115,
|
|
287
319
|
text: (context) => {
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
if (binding === undefined)
|
|
320
|
+
const session = context.agent?.session;
|
|
321
|
+
if (session === undefined)
|
|
291
322
|
return '';
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
323
|
+
// First hop straight off the live header: the prompt must not depend
|
|
324
|
+
// on the session-start event having been seen (a plugin reload
|
|
325
|
+
// mid-session repopulates the map only through later events).
|
|
326
|
+
const parent = lineageEdgeOf(session.header);
|
|
327
|
+
if (parent !== undefined && !this.parentOf.has(session.id)) {
|
|
328
|
+
this.parentOf.set(session.id, parent);
|
|
329
|
+
}
|
|
330
|
+
const effective = resolveEffectiveBinding(session.id, this.parentOf, id => this.bindingMirror.get(id));
|
|
331
|
+
if (effective === undefined)
|
|
332
|
+
return '';
|
|
333
|
+
return bindingNotice(effective.binding.name, effective.binding.branch, effective.inherited);
|
|
299
334
|
},
|
|
300
335
|
});
|
|
301
336
|
});
|
|
@@ -332,6 +367,7 @@ let GitWorkbenchService = (() => {
|
|
|
332
367
|
ok: { type: 'boolean' },
|
|
333
368
|
error: { type: 'string' },
|
|
334
369
|
binding: { oneOf: [{ type: 'null' }, { type: 'object', additionalProperties: true }] },
|
|
370
|
+
bindingInherited: { type: 'boolean' },
|
|
335
371
|
worktrees: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
336
372
|
branches: { type: 'array', items: { type: 'string' } },
|
|
337
373
|
branchesTruncated: { type: 'boolean' },
|
|
@@ -340,20 +376,22 @@ let GitWorkbenchService = (() => {
|
|
|
340
376
|
ctx.tools.register(defineTool({
|
|
341
377
|
name: 'worktree_enter',
|
|
342
378
|
description: 'Enter (create or reuse) an isolated git worktree at .agents/worktrees/<name> — the directory is '
|
|
343
|
-
+ 'always derived from the name (there is no dir parameter), the branch
|
|
379
|
+
+ 'always derived from the name (there is no dir parameter), the branch defaults to the name '
|
|
380
|
+
+ '(or pass branch to choose one — unlike the name it may contain slashes, e.g. feature/foo), '
|
|
344
381
|
+ 'and the session is bound to it. After entering, address the worktree relatively from the session cwd: '
|
|
345
382
|
+ 'for shell commands pass workdir ".agents/worktrees/<name>" (per-call workdir is supported and resolved '
|
|
346
383
|
+ 'against the session cwd); for file tools use paths prefixed with .agents/worktrees/<name>/. '
|
|
347
384
|
+ 'Call with no name to auto-generate one. Use worktree_exit to leave.',
|
|
348
385
|
parameters: {
|
|
349
|
-
name: { type: 'string', description: 'Optional worktree name: letters, digits, . _ - + (must start alphanumeric, max 64 chars; ".." and a trailing dot are refused). The name
|
|
386
|
+
name: { type: 'string', description: 'Optional worktree name: letters, digits, . _ - + (must start alphanumeric, max 64 chars; ".." and a trailing dot are refused). The name doubles as the default branch — pass branch to split them (no prefix is added either way). If the target directory already holds a registered worktree (e.g. one made by another tool), it is reused as-is with its own branch. Auto-generated when omitted or illegal.' },
|
|
387
|
+
branch: { type: 'string', description: 'Optional branch for a NEW worktree; defaults to the name. Unlike the name it may contain slashes (feature/foo) — the one spelling a directory name cannot express. Validated and REFUSED when illegal (never auto-generated); set aside — the worktree keeps its own branch, the hint says so — when the target directory already holds a registered worktree.' },
|
|
350
388
|
},
|
|
351
389
|
output: output(OP_SCHEMA),
|
|
352
390
|
execute: async (args, exec) => {
|
|
353
391
|
const session = exec.agent?.session;
|
|
354
392
|
if (session === undefined)
|
|
355
393
|
return { ok: false, error: 'worktree tools require a calling session' };
|
|
356
|
-
return this.worktreeEnter(session.id, session.header.cwd ?? '', args?.name, exec.signal);
|
|
394
|
+
return this.worktreeEnter(session.id, session.header.cwd ?? '', args?.name, args?.branch, exec.signal);
|
|
357
395
|
},
|
|
358
396
|
presentCall: () => ({ card: 'generic', title: 'Enter worktree', kind: 'other' }),
|
|
359
397
|
}));
|
|
@@ -375,7 +413,8 @@ let GitWorkbenchService = (() => {
|
|
|
375
413
|
}));
|
|
376
414
|
ctx.tools.register(defineTool({
|
|
377
415
|
name: 'worktree_status',
|
|
378
|
-
description: 'Show this session\'s
|
|
416
|
+
description: 'Show this session\'s worktree (its own, or the one its parent session entered — bindingInherited says which) '
|
|
417
|
+
+ 'and the repository\'s existing worktrees with branches.',
|
|
379
418
|
parameters: {},
|
|
380
419
|
output: output(STATUS_SCHEMA),
|
|
381
420
|
execute: async (_args, exec) => {
|
|
@@ -535,6 +574,13 @@ let GitWorkbenchService = (() => {
|
|
|
535
574
|
const tracked = await this.git(root, ['diff', 'HEAD', '--', path], signal);
|
|
536
575
|
if (tracked.stdout.trim().length > 0)
|
|
537
576
|
return { diff: tracked.stdout };
|
|
577
|
+
// Empty for a TRACKED file is real, not a missing diff: the line-ending
|
|
578
|
+
// phantom (autocrlf / eol attributes make the stat check and the clean
|
|
579
|
+
// filter disagree) lists such files modified forever while git itself
|
|
580
|
+
// finds no content difference. Synthesizing a new-file segment for one
|
|
581
|
+
// would paint a whole-file addition git does not see.
|
|
582
|
+
if (!await this.isUntracked(root, path, signal))
|
|
583
|
+
return { diff: '' };
|
|
538
584
|
return { diff: await untrackedSegment(root, path) ?? '' };
|
|
539
585
|
}
|
|
540
586
|
/**
|
|
@@ -560,24 +606,28 @@ let GitWorkbenchService = (() => {
|
|
|
560
606
|
if (layer !== 'unstaged' && layer !== 'staged') {
|
|
561
607
|
throw new Error(`unknown layer "${String(layer)}"; expected 'unstaged' or 'staged'`);
|
|
562
608
|
}
|
|
563
|
-
if (typeof path !== 'string' || !
|
|
609
|
+
if (typeof path !== 'string' || !isSafeRelativePath(path)) {
|
|
564
610
|
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
|
|
565
611
|
}
|
|
566
612
|
// Repository root, not the session's directory: every path below is
|
|
567
613
|
// repository-relative (pathspecs resolve against the cwd, and so does
|
|
568
614
|
// the file read for the editor's target). See repo-root.ts.
|
|
569
615
|
const root = await this.rootedDirOf(worktreePath, signal);
|
|
616
|
+
// The unstaged layer READS THE FILE, so its absolute path is built by the
|
|
617
|
+
// lock rather than by a join — see path-lock.ts. Done here, once, so the
|
|
618
|
+
// one place that resolves a client path is visible in this method.
|
|
570
619
|
return layer === 'unstaged'
|
|
571
|
-
? await this.unstagedSides(root, path, signal)
|
|
620
|
+
? await this.unstagedSides(root, path, resolveInside(root, path), signal)
|
|
572
621
|
: await this.stagedSides(root, path, signal);
|
|
573
622
|
}
|
|
574
|
-
/** The unstaged layer: diff index→worktree, target = the working-tree file.
|
|
575
|
-
|
|
623
|
+
/** The unstaged layer: diff index→worktree, target = the working-tree file.
|
|
624
|
+
* `full` is that file's absolute path, already through the lock. */
|
|
625
|
+
async unstagedSides(root, path, full, signal) {
|
|
576
626
|
// Size guard first, off the stat rather than a read: declining a file past
|
|
577
627
|
// the cap must not mean loading a pathological one whole first. Bytes are
|
|
578
628
|
// all a stat knows; the line half of the guard needs the read below.
|
|
579
629
|
try {
|
|
580
|
-
const info = await stat(
|
|
630
|
+
const info = await stat(full);
|
|
581
631
|
if (info.isFile() && targetTooLarge(info.size, 0))
|
|
582
632
|
return { ...emptySides(), tooLarge: true };
|
|
583
633
|
}
|
|
@@ -586,7 +636,7 @@ let GitWorkbenchService = (() => {
|
|
|
586
636
|
}
|
|
587
637
|
let bytes = null;
|
|
588
638
|
try {
|
|
589
|
-
bytes = await readFile(
|
|
639
|
+
bytes = await readFile(full);
|
|
590
640
|
}
|
|
591
641
|
catch {
|
|
592
642
|
bytes = null;
|
|
@@ -832,12 +882,14 @@ let GitWorkbenchService = (() => {
|
|
|
832
882
|
* @param signal - abort signal.
|
|
833
883
|
*/
|
|
834
884
|
async fileImage(worktreePath, path, signal) {
|
|
835
|
-
if (typeof path !== 'string' || !
|
|
885
|
+
if (typeof path !== 'string' || !isSafeRelativePath(path)) {
|
|
836
886
|
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
|
|
837
887
|
}
|
|
838
888
|
// The repository root — the image is read from disk at the same base
|
|
839
|
-
// every other path in this plugin is relative to (repo-root.ts)
|
|
840
|
-
|
|
889
|
+
// every other path in this plugin is relative to (repo-root.ts) — and
|
|
890
|
+
// through the lock, because this is a raw read with no git in the way
|
|
891
|
+
// to refuse a path that leaves the repository (path-lock.ts).
|
|
892
|
+
const full = resolveInside(await this.rootedDirOf(worktreePath, signal), path);
|
|
841
893
|
let size = 0;
|
|
842
894
|
try {
|
|
843
895
|
const info = await stat(full);
|
|
@@ -1041,11 +1093,21 @@ let GitWorkbenchService = (() => {
|
|
|
1041
1093
|
}
|
|
1042
1094
|
/**
|
|
1043
1095
|
* Every file path on HEAD — the filter popup's path picker, aggregated into
|
|
1044
|
-
* a directory tree client-side
|
|
1096
|
+
* a directory tree client-side — plus the ignored-but-present entries the
|
|
1097
|
+
* Files tab browses.
|
|
1045
1098
|
*
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1099
|
+
* The ignored listing is `ls-files --others --ignored --exclude-standard
|
|
1100
|
+
* --directory`: every ignored FILE is listed verbatim (`application-local.yml`
|
|
1101
|
+
* is exactly the file a browser must find and `ls-tree HEAD` cannot), while
|
|
1102
|
+
* every directory a rule ignores as a whole collapses to ONE line with a
|
|
1103
|
+
* trailing slash — `node_modules/` costs one entry, not the ~40k files
|
|
1104
|
+
* inside it. The list therefore scales with the .gitignore's coverage, not
|
|
1105
|
+
* the repository: measured 54 entries / 48ms on this checkout with
|
|
1106
|
+
* node_modules present.
|
|
1107
|
+
*
|
|
1108
|
+
* `-z` is load-bearing on both spawns: NUL-separated output is UNQUOTED,
|
|
1109
|
+
* while the default would render non-ASCII names as quoted octal escapes
|
|
1110
|
+
* under `core.quotepath` and hand the picker garbage.
|
|
1049
1111
|
* @param worktreePath - worktree whose HEAD is listed; empty falls back to the host cwd.
|
|
1050
1112
|
* @param signal - abort signal.
|
|
1051
1113
|
*/
|
|
@@ -1055,10 +1117,88 @@ let GitWorkbenchService = (() => {
|
|
|
1055
1117
|
// `server/` prefix and the picker would feed the log filter pathspecs
|
|
1056
1118
|
// that match nothing (repo-root.ts).
|
|
1057
1119
|
const root = await this.rootedDirOf(worktreePath, signal);
|
|
1058
|
-
const res = await
|
|
1120
|
+
const [res, ignoredRes] = await Promise.all([
|
|
1121
|
+
this.git(root, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal),
|
|
1122
|
+
this.git(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], signal),
|
|
1123
|
+
]);
|
|
1059
1124
|
const all = res.stdout.split('\0').filter(path => path.length > 0);
|
|
1060
1125
|
const truncated = all.length > TREE_PATH_CAP;
|
|
1061
|
-
|
|
1126
|
+
const ignoredAll = ignoredRes.stdout.split('\0').filter(path => path.length > 0);
|
|
1127
|
+
const ignoredTruncated = ignoredAll.length > IGNORED_PATH_CAP;
|
|
1128
|
+
// `ls-tree`'s exit code is deliberately NOT checked: a repository with no
|
|
1129
|
+
// HEAD yet fails it, and "this repository has no files" is the honest
|
|
1130
|
+
// answer there. The ignored listing has no such legitimate failure, so a
|
|
1131
|
+
// non-zero exit is reported rather than served as an empty list — the one
|
|
1132
|
+
// shape that is indistinguishable from a repository ignoring nothing.
|
|
1133
|
+
const ignoredError = ignoredRes.exitCode === 0
|
|
1134
|
+
? undefined
|
|
1135
|
+
: (ignoredRes.stderr.trim() || `git ls-files failed (exit ${ignoredRes.exitCode})`).slice(-500);
|
|
1136
|
+
return {
|
|
1137
|
+
paths: truncated ? all.slice(0, TREE_PATH_CAP) : all,
|
|
1138
|
+
truncated,
|
|
1139
|
+
ignored: ignoredTruncated ? ignoredAll.slice(0, IGNORED_PATH_CAP) : ignoredAll,
|
|
1140
|
+
ignoredTruncated,
|
|
1141
|
+
...(ignoredError !== undefined ? { ignoredError } : {}),
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* One level of one ignored directory, read from the filesystem — the step
|
|
1146
|
+
* behind clicking `node_modules/` open in the Files tab.
|
|
1147
|
+
*
|
|
1148
|
+
* Deliberately NOT a git listing: with `--directory`, a pathspec under an
|
|
1149
|
+
* ignored directory still collapses to that one directory line (probed:
|
|
1150
|
+
* `ls-files --others --ignored --exclude-standard --directory -- 'node_modules/*'`
|
|
1151
|
+
* answers `node_modules/` and nothing else), and dropping `--directory`
|
|
1152
|
+
* would enumerate every file inside at once — the very flood the collapsed
|
|
1153
|
+
* listing exists to avoid. `readdir` is one level by construction and costs
|
|
1154
|
+
* milliseconds. What the entries ARE is inherited anyway: everything below
|
|
1155
|
+
* an ignored directory is ignored by descent, so the browser owes the
|
|
1156
|
+
* reader the directory's contents, not git's opinion of them.
|
|
1157
|
+
*
|
|
1158
|
+
* A directory that vanished between the listing and the click answers
|
|
1159
|
+
* empty: nothing to browse is the honest answer, and the next refresh
|
|
1160
|
+
* drops the row.
|
|
1161
|
+
*
|
|
1162
|
+
* The name says what it is FOR, not what it permits: nothing here checks
|
|
1163
|
+
* that `dir` is ignored, so this lists any directory inside the worktree.
|
|
1164
|
+
* That is the same reach the reader already has through `fileSides` and
|
|
1165
|
+
* `fileImage`, and adding an ignore check would only make the browser ask
|
|
1166
|
+
* git a second question to learn what it already knows from the listing it
|
|
1167
|
+
* was handed. What it does NOT permit is leaving the worktree, which is
|
|
1168
|
+
* what the path lock below is for.
|
|
1169
|
+
* @param worktreePath - worktree the directory lives in; empty falls back to the host cwd.
|
|
1170
|
+
* @param dir - repo-relative directory path, as the collapsed listing named it.
|
|
1171
|
+
* @param signal - abort signal.
|
|
1172
|
+
*/
|
|
1173
|
+
async ignoredDir(worktreePath, dir, signal) {
|
|
1174
|
+
// The same lock every filesystem read in this plugin passes: the browser
|
|
1175
|
+
// is a less trusted source of paths than git's own output, and `readdir`
|
|
1176
|
+
// obeys no repository boundary (path-lock.ts).
|
|
1177
|
+
const target = resolveInside(await this.rootedDirOf(worktreePath, signal), dir);
|
|
1178
|
+
let dirents;
|
|
1179
|
+
try {
|
|
1180
|
+
dirents = await readdir(target, { withFileTypes: true });
|
|
1181
|
+
}
|
|
1182
|
+
catch {
|
|
1183
|
+
return { entries: [], truncated: false };
|
|
1184
|
+
}
|
|
1185
|
+
const plain = dirents
|
|
1186
|
+
.filter(entry => !entry.isSymbolicLink())
|
|
1187
|
+
.map(entry => ({ name: entry.name, dir: entry.isDirectory() }));
|
|
1188
|
+
// A pnpm-style layout makes every package row a symlink into the store;
|
|
1189
|
+
// `withFileTypes` reports the LINK, so a follow-up stat decides whether
|
|
1190
|
+
// the row expands. A broken link reads as a file and simply fails to
|
|
1191
|
+
// open, like any other dangling name.
|
|
1192
|
+
const links = dirents.filter(entry => entry.isSymbolicLink());
|
|
1193
|
+
const resolved = await Promise.all(links.map(async (entry) => {
|
|
1194
|
+
try {
|
|
1195
|
+
return { name: entry.name, dir: (await stat(join(target, entry.name))).isDirectory() };
|
|
1196
|
+
}
|
|
1197
|
+
catch {
|
|
1198
|
+
return { name: entry.name, dir: false };
|
|
1199
|
+
}
|
|
1200
|
+
}));
|
|
1201
|
+
return shapeDirChildren([...plain, ...resolved]);
|
|
1062
1202
|
}
|
|
1063
1203
|
/**
|
|
1064
1204
|
* Compare two refs, in the same {@link WorkbenchStats} shape as every other view.
|
|
@@ -1131,16 +1271,40 @@ let GitWorkbenchService = (() => {
|
|
|
1131
1271
|
files, diff, commits: parseLog(log.stdout),
|
|
1132
1272
|
};
|
|
1133
1273
|
}
|
|
1134
|
-
/**
|
|
1274
|
+
/**
|
|
1275
|
+
* The session's EFFECTIVE worktree binding — its own, else the nearest bound
|
|
1276
|
+
* ancestor's — or nulls when neither exists. This is what the chip follows,
|
|
1277
|
+
* so a subagent session shows the worktree its conversation works in without
|
|
1278
|
+
* ever holding a binding of its own (plain-identifier params; signal last).
|
|
1279
|
+
*/
|
|
1135
1280
|
async sessionWorktree(sessionId, signal) {
|
|
1136
1281
|
if (typeof sessionId !== 'string' || sessionId.length === 0)
|
|
1137
|
-
return { worktreePath: null, name: null };
|
|
1282
|
+
return { worktreePath: null, name: null, inherited: false };
|
|
1138
1283
|
const file = await this.bindingsIo().load();
|
|
1139
|
-
const
|
|
1140
|
-
return
|
|
1284
|
+
const effective = resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id]);
|
|
1285
|
+
return effective === undefined
|
|
1286
|
+
? { worktreePath: null, name: null, inherited: false }
|
|
1287
|
+
: { worktreePath: effective.binding.worktreePath, name: effective.binding.name, inherited: effective.inherited };
|
|
1141
1288
|
}
|
|
1142
|
-
/**
|
|
1143
|
-
|
|
1289
|
+
/**
|
|
1290
|
+
* Create (or reuse) a git worktree under `<repoRoot>/.agents/worktrees/`
|
|
1291
|
+
* and bind the session to it.
|
|
1292
|
+
*
|
|
1293
|
+
* The name is the identity — the directory — and the default branch.
|
|
1294
|
+
* branchName (optional) splits the two for the one spelling the name can
|
|
1295
|
+
* never express: a slash branch (`feature/foo` is a legal ref and an
|
|
1296
|
+
* impossible Windows directory). It applies to a FRESH create only — a
|
|
1297
|
+
* reused worktree keeps its own branch, and the hint says so when an
|
|
1298
|
+
* explicit request was set aside. An illegal branchName is refused, never
|
|
1299
|
+
* substituted: a branch is semantic in a way a directory label is not.
|
|
1300
|
+
* @param sessionId - session to bind to the worktree.
|
|
1301
|
+
* @param repoPath - caller's directory, used to locate the repository.
|
|
1302
|
+
* @param name - worktree name (directory + default branch), sanitized;
|
|
1303
|
+
* auto-generated when omitted or illegal.
|
|
1304
|
+
* @param branchName - branch for a fresh create; defaults to the name.
|
|
1305
|
+
* @param signal - abort signal.
|
|
1306
|
+
*/
|
|
1307
|
+
async worktreeEnter(sessionId, repoPath, name, branchName, signal) {
|
|
1144
1308
|
if (typeof sessionId !== 'string' || sessionId.length === 0)
|
|
1145
1309
|
return { ok: false, error: 'sessionId is required' };
|
|
1146
1310
|
const cwd = typeof repoPath === 'string' && repoPath.length > 0 ? repoPath.replace(/\\/g, '/') : process.cwd();
|
|
@@ -1167,13 +1331,17 @@ let GitWorkbenchService = (() => {
|
|
|
1167
1331
|
// started. Reuse paths recover it with merge-base instead (theirs is historical).
|
|
1168
1332
|
const headBefore = (await this.git(repoRoot, ['rev-parse', 'HEAD'], signal)).stdout.trim();
|
|
1169
1333
|
// The branch the session actually lands on: the reused worktree's own branch,
|
|
1170
|
-
//
|
|
1171
|
-
|
|
1334
|
+
// branchName when the caller asked for one (slashes allowed — a branch can
|
|
1335
|
+
// spell what a directory cannot), else the name VERBATIM — no forced prefix.
|
|
1336
|
+
const choice = resolveEnterBranch(wtName, branchName, existing?.branch);
|
|
1337
|
+
if (!choice.ok)
|
|
1338
|
+
return { ok: false, error: choice.error };
|
|
1339
|
+
const branch = choice.branch;
|
|
1172
1340
|
let reusedWorktree = false;
|
|
1173
1341
|
let reusedBranch = false;
|
|
1174
1342
|
let baseCommit;
|
|
1175
1343
|
if (existing === undefined) {
|
|
1176
|
-
const add = await this.git(repoRoot, ['worktree', 'add', '-b',
|
|
1344
|
+
const add = await this.git(repoRoot, ['worktree', 'add', '-b', branch, dir], signal);
|
|
1177
1345
|
if (add.exitCode === 0) {
|
|
1178
1346
|
baseCommit = headBefore.length > 0 ? headBefore : undefined;
|
|
1179
1347
|
}
|
|
@@ -1182,11 +1350,11 @@ let GitWorkbenchService = (() => {
|
|
|
1182
1350
|
// commits), so a re-enter after remove finds the name present as a
|
|
1183
1351
|
// branch: verify the ref and check the existing branch out instead
|
|
1184
1352
|
// of failing on `-b`.
|
|
1185
|
-
const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${
|
|
1353
|
+
const verified = await this.git(repoRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], signal);
|
|
1186
1354
|
if (verified.exitCode !== 0) {
|
|
1187
1355
|
return { ok: false, error: `git worktree add failed (exit ${add.exitCode})${add.stderr.length > 0 ? `: ${add.stderr}` : ''}` };
|
|
1188
1356
|
}
|
|
1189
|
-
const retry = await this.git(repoRoot, ['worktree', 'add', dir,
|
|
1357
|
+
const retry = await this.git(repoRoot, ['worktree', 'add', dir, branch], signal);
|
|
1190
1358
|
if (retry.exitCode !== 0) {
|
|
1191
1359
|
return { ok: false, error: `git worktree add failed (exit ${retry.exitCode})${retry.stderr.length > 0 ? `: ${retry.stderr}` : ''}` };
|
|
1192
1360
|
}
|
|
@@ -1217,7 +1385,7 @@ let GitWorkbenchService = (() => {
|
|
|
1217
1385
|
const rel = `.agents/worktrees/${wtName}`;
|
|
1218
1386
|
return {
|
|
1219
1387
|
ok: true, worktreePath: dir, branch,
|
|
1220
|
-
hint: `Session bound to worktree "${wtName}" (branch ${branch}) at ${rel}/. For shell commands pass workdir "${rel}" (per-call workdir is supported and resolved against the session cwd); for file tools use paths relative to the session cwd prefixed with ${rel}/. Call worktree_exit to unbind.${reusedWorktree ? ` Note: reused the worktree already registered there; its branch ${branch} was kept.` : ''}${reusedBranch ? ` Note: reused existing branch ${branch} (carries its prior commits).` : ''}`,
|
|
1388
|
+
hint: `Session bound to worktree "${wtName}" (branch ${branch}) at ${rel}/. For shell commands pass workdir "${rel}" (per-call workdir is supported and resolved against the session cwd); for file tools use paths relative to the session cwd prefixed with ${rel}/. Call worktree_exit to unbind.${reusedWorktree ? ` Note: reused the worktree already registered there; its branch ${branch} was kept.` : ''}${choice.branchOverridden ? ` Note: requested branch ${branchName} was not used; the reused worktree keeps its branch ${branch}.` : ''}${reusedBranch ? ` Note: reused existing branch ${branch} (carries its prior commits).` : ''}`,
|
|
1221
1389
|
};
|
|
1222
1390
|
}
|
|
1223
1391
|
/** Unbind the session's worktree; with remove=true, delete a clean worktree from disk. */
|
|
@@ -1229,8 +1397,15 @@ let GitWorkbenchService = (() => {
|
|
|
1229
1397
|
return this.withBindings(async (io) => {
|
|
1230
1398
|
const file = await io.load();
|
|
1231
1399
|
const binding = file.bindings[sessionId];
|
|
1232
|
-
if (binding === undefined)
|
|
1233
|
-
|
|
1400
|
+
if (binding === undefined) {
|
|
1401
|
+
// A subagent CAN see a worktree in its status while holding no binding
|
|
1402
|
+
// of its own (it works under its parent's). Naming that here keeps the
|
|
1403
|
+
// model from retrying an exit that cannot succeed.
|
|
1404
|
+
const inherited = resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id]);
|
|
1405
|
+
return inherited === undefined
|
|
1406
|
+
? { ok: false, error: 'no worktree binding for this session' }
|
|
1407
|
+
: { ok: false, error: 'this session has no binding of its own; its worktree is entered by a parent session — ask the parent session to call worktree_exit' };
|
|
1408
|
+
}
|
|
1234
1409
|
if (remove === true) {
|
|
1235
1410
|
const status = await this.git(binding.worktreePath, ['status', '--porcelain'], signal);
|
|
1236
1411
|
if (status.exitCode !== 0) {
|
|
@@ -1261,21 +1436,26 @@ let GitWorkbenchService = (() => {
|
|
|
1261
1436
|
* Branches come back most-recently-committed first. With hundreds of them the
|
|
1262
1437
|
* order is what makes the list usable — the handful anyone is working on sit
|
|
1263
1438
|
* at the top, so the picker is useful before a single character is typed.
|
|
1264
|
-
* @param sessionId - session whose binding is looked up.
|
|
1439
|
+
* @param sessionId - session whose effective binding is looked up.
|
|
1265
1440
|
* @param repoPath - caller's directory, used when the session is unbound.
|
|
1266
1441
|
* @param signal - abort signal.
|
|
1267
|
-
* @returns the binding
|
|
1442
|
+
* @returns the effective binding (with whether an ancestor lent it), the
|
|
1443
|
+
* repository's worktrees, and its local branches.
|
|
1268
1444
|
*/
|
|
1269
1445
|
async worktreeStatus(sessionId, repoPath, signal) {
|
|
1270
1446
|
const file = await this.bindingsIo().load();
|
|
1271
|
-
const
|
|
1447
|
+
const effective = typeof sessionId === 'string' && sessionId.length > 0
|
|
1448
|
+
? resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id])
|
|
1449
|
+
: undefined;
|
|
1450
|
+
const binding = effective?.binding ?? null;
|
|
1451
|
+
const bindingInherited = effective?.inherited ?? false;
|
|
1272
1452
|
// Unbound: list the CALLER's repo. Falling back to the host's launch directory
|
|
1273
1453
|
// would answer about whatever directory dsh was started in, not this session's.
|
|
1274
1454
|
const caller = typeof repoPath === 'string' && repoPath.length > 0 ? repoPath.replace(/\\/g, '/') : process.cwd();
|
|
1275
1455
|
const cwd = binding?.repoRoot ?? caller;
|
|
1276
1456
|
const root = await this.repoRootOf(cwd, signal);
|
|
1277
1457
|
if (root === null)
|
|
1278
|
-
return { binding, worktrees: [], branches: [], branchesTruncated: false };
|
|
1458
|
+
return { binding, bindingInherited, worktrees: [], branches: [], branchesTruncated: false };
|
|
1279
1459
|
const [listed, named] = await Promise.all([
|
|
1280
1460
|
this.git(root, ['worktree', 'list', '--porcelain'], signal),
|
|
1281
1461
|
this.git(root, ['branch', '--sort=-committerdate', '--format=%(refname:short)'], signal),
|
|
@@ -1284,6 +1464,7 @@ let GitWorkbenchService = (() => {
|
|
|
1284
1464
|
const { branches, branchesTruncated } = capBranches(all, BRANCH_LIST_CAP);
|
|
1285
1465
|
return {
|
|
1286
1466
|
binding,
|
|
1467
|
+
bindingInherited,
|
|
1287
1468
|
worktrees: parseWorktreeList(listed.stdout),
|
|
1288
1469
|
branches,
|
|
1289
1470
|
branchesTruncated,
|
|
@@ -1725,7 +1906,7 @@ async function mapPooled(items, limit, task) {
|
|
|
1725
1906
|
async function measureUntracked(root, path) {
|
|
1726
1907
|
let bytes;
|
|
1727
1908
|
try {
|
|
1728
|
-
bytes = await readFile(
|
|
1909
|
+
bytes = await readFile(resolveInside(root, path));
|
|
1729
1910
|
}
|
|
1730
1911
|
catch {
|
|
1731
1912
|
return { lineCount: 0, binary: false, diffable: false };
|
|
@@ -1752,7 +1933,7 @@ async function measureUntracked(root, path) {
|
|
|
1752
1933
|
async function untrackedSegment(root, path, byteCap = UNTRACKED_FILE_BYTE_CAP) {
|
|
1753
1934
|
let bytes;
|
|
1754
1935
|
try {
|
|
1755
|
-
bytes = await readFile(
|
|
1936
|
+
bytes = await readFile(resolveInside(root, path));
|
|
1756
1937
|
}
|
|
1757
1938
|
catch {
|
|
1758
1939
|
return null;
|