@young1lin/dsh-ui-gitworkbench 0.1.14 → 0.1.16
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 +23 -0
- package/CHANGELOG_EN.md +23 -0
- package/README.md +29 -3
- package/README_EN.md +1 -1
- package/lib/client.js +1512 -470
- package/lib/dir-listing.js +34 -0
- package/lib/fs-remove.js +5 -36
- package/lib/index.js +346 -117
- package/lib/path-lock.js +54 -0
- package/lib/repo-root.js +60 -0
- package/lib/worktree.js +83 -0
- package/lib/write-checked.js +1 -1
- package/package.json +5 -5
- package/src/client/ChromeGlyph.tsx +5 -0
- package/src/client/CodeEditor.tsx +19 -1
- package/src/client/DiffViews.tsx +116 -121
- package/src/client/FileBrowser.tsx +196 -23
- package/src/client/GitWorkbenchPanel.module.css +1 -0
- package/src/client/GitWorkbenchPanel.tsx +100 -12
- package/src/client/SideRails.tsx +106 -0
- package/src/client/diff-cells.tsx +147 -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 +12 -2
- 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 +372 -118
- package/src/path-lock.ts +56 -0
- package/src/repo-root.ts +66 -0
- package/src/types/dsh-shim.d.ts +12 -2
- package/src/worktree.ts +97 -0
- package/src/write-checked.ts +1 -1
package/src/index.ts
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
* @module @young1lin/dsh-ui-gitworkbench
|
|
43
43
|
*/
|
|
44
44
|
import { randomBytes } from 'node:crypto'
|
|
45
|
-
import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
45
|
+
import { mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
46
46
|
import { homedir, tmpdir } from 'node:os'
|
|
47
47
|
import { join } from 'node:path'
|
|
48
48
|
import type { Readable } from 'node:stream'
|
|
@@ -62,11 +62,14 @@ import {
|
|
|
62
62
|
type OpFailure, type PullMode, type Tracking,
|
|
63
63
|
} from './git-ops.js'
|
|
64
64
|
import {
|
|
65
|
-
planFromStatus,
|
|
65
|
+
isSafeRelativePath, planFromStatus,
|
|
66
66
|
type DiscardEffect, type DiscardPlan,
|
|
67
67
|
} from './discard-ops.js'
|
|
68
|
+
import { shapeDirChildren, type DirChild } from './dir-listing.js'
|
|
68
69
|
import { parseBlame, type BlameLine } from './blame.js'
|
|
69
70
|
import { removePathInside } from './fs-remove.js'
|
|
71
|
+
import { resolveInside } from './path-lock.js'
|
|
72
|
+
import { resolveRepoRoot, rootedDir } from './repo-root.js'
|
|
70
73
|
import { diffTooLarge, targetTooLarge, SIDE_BYTE_CAP, SIDE_LINE_CAP } from './side-guard.js'
|
|
71
74
|
import { IMAGE_BYTE_CAP, sniffImage } from './image-sniff.js'
|
|
72
75
|
import { LOG_FORMAT, parseLog, type GitCommit } from './git-log.js'
|
|
@@ -77,7 +80,7 @@ import {
|
|
|
77
80
|
type StyleEntry, type StyleFile,
|
|
78
81
|
} from './style-store.js'
|
|
79
82
|
import {
|
|
80
|
-
bindingsPath, findRegisteredWorktree, isRefName, loadBindings, parseWorktreeList, sanitizeName, saveBindings, worktreeDir,
|
|
83
|
+
bindingNotice, bindingsPath, findRegisteredWorktree, isRefName, lineageEdgeOf, loadBindings, parseWorktreeList, resolveEffectiveBinding, sanitizeName, saveBindings, worktreeDir,
|
|
81
84
|
type BindingsFile, type WorktreeBinding, type WorktreeEntry, type WorktreeOpResult,
|
|
82
85
|
} from './worktree.js'
|
|
83
86
|
|
|
@@ -112,6 +115,11 @@ const SHORTLOG_CAP = 500
|
|
|
112
115
|
/** Path list cap for the picker: a monorepo can outrun any popup; past this
|
|
113
116
|
* the tree is cut and the truncation reported, never silent. */
|
|
114
117
|
const TREE_PATH_CAP = 50_000
|
|
118
|
+
/** Cap on the ignored entry listing `repoTree` rides along. The listing is
|
|
119
|
+
* proportional to the .gitignore's coverage, not the repository (git
|
|
120
|
+
* collapses every fully-ignored directory to one line), so real repos sit in
|
|
121
|
+
* the tens; this is a reported fuse for a pathological ignore setup. */
|
|
122
|
+
const IGNORED_PATH_CAP = 5_000
|
|
115
123
|
/**
|
|
116
124
|
* Most branch names sent to the browser. `worktreeStatus` is polled, so an
|
|
117
125
|
* unbounded list would repeat on the wire every few seconds; the picker reports
|
|
@@ -127,6 +135,21 @@ const COMMIT_HASH = /^[0-9a-fA-F]{4,40}$/
|
|
|
127
135
|
|
|
128
136
|
export type { GitCommit } from './git-log.js'
|
|
129
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The slice of dsh's `agent/session-start` payload this plugin reads: just the
|
|
140
|
+
* session identity and its parent edge. Declared structurally (not imported
|
|
141
|
+
* from `@deepseek-ai/dsh-agent`, which is not a peer of this plugin) so the
|
|
142
|
+
* shape this code depends on is pinned here regardless of host-side changes.
|
|
143
|
+
*/
|
|
144
|
+
interface SessionStartEvent {
|
|
145
|
+
readonly agent: {
|
|
146
|
+
readonly session: {
|
|
147
|
+
readonly id: string
|
|
148
|
+
readonly header: { readonly parentSession?: string }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
130
153
|
export interface WorkbenchStats {
|
|
131
154
|
readonly worktreePath: string
|
|
132
155
|
readonly branch: string
|
|
@@ -269,10 +292,32 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
269
292
|
*/
|
|
270
293
|
private readonly bindingMirror = new Map<string, WorktreeBinding>()
|
|
271
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Session id → parent session id, as `agent/session-start` delivered it. A
|
|
297
|
+
* subagent header names its parent, and that edge is all the lineage walk
|
|
298
|
+
* needs: a child session without a binding of its own works under its
|
|
299
|
+
* nearest bound ancestor (see {@link resolveEffectiveBinding}). The map is
|
|
300
|
+
* never pruned — it holds one short string per session this process has
|
|
301
|
+
* seen, and a stale edge can only make a lookup walk further, never lie.
|
|
302
|
+
*/
|
|
303
|
+
private readonly parentOf = new Map<string, string>()
|
|
304
|
+
|
|
272
305
|
constructor(ctx: Context) {
|
|
273
306
|
super(ctx, 'gitWorkbench')
|
|
274
307
|
this.registerWorktreeTools(ctx)
|
|
275
308
|
this.registerWorktreePrompt(ctx)
|
|
309
|
+
// Fires for every session the process publishes — fresh subagents and
|
|
310
|
+
// sessions whose loop resumes from disk. An IDLE session's edge is absent
|
|
311
|
+
// until its loop (re)starts, so a lookup can simply find no ancestor right
|
|
312
|
+
// after a host restart — the pre-feature behavior, fail-soft. `events.on`
|
|
313
|
+
// (not the typed `ctx.on` overload) because the event is declared by
|
|
314
|
+
// @deepseek-ai/dsh-agent, which this plugin does not depend on; the
|
|
315
|
+
// listener lives on this ctx's fiber.
|
|
316
|
+
ctx.events.on('agent/session-start', (payload: SessionStartEvent) => {
|
|
317
|
+
const session = payload.agent?.session
|
|
318
|
+
const parent = lineageEdgeOf(session?.header)
|
|
319
|
+
if (session !== undefined && parent !== undefined) this.parentOf.set(session.id, parent)
|
|
320
|
+
})
|
|
276
321
|
// Hydrate the mirror through the same queue as the mutations, so a binding
|
|
277
322
|
// written before hydration finishes is not overwritten by the stale read.
|
|
278
323
|
// A failed read leaves the mirror empty: sessions then get no standing
|
|
@@ -304,16 +349,18 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
304
349
|
name: 'worktree:binding',
|
|
305
350
|
order: 115,
|
|
306
351
|
text: (context) => {
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
352
|
+
const session = context.agent?.session
|
|
353
|
+
if (session === undefined) return ''
|
|
354
|
+
// First hop straight off the live header: the prompt must not depend
|
|
355
|
+
// on the session-start event having been seen (a plugin reload
|
|
356
|
+
// mid-session repopulates the map only through later events).
|
|
357
|
+
const parent = lineageEdgeOf(session.header)
|
|
358
|
+
if (parent !== undefined && !this.parentOf.has(session.id)) {
|
|
359
|
+
this.parentOf.set(session.id, parent)
|
|
360
|
+
}
|
|
361
|
+
const effective = resolveEffectiveBinding(session.id, this.parentOf, id => this.bindingMirror.get(id))
|
|
362
|
+
if (effective === undefined) return ''
|
|
363
|
+
return bindingNotice(effective.binding.name, effective.binding.branch, effective.inherited)
|
|
317
364
|
},
|
|
318
365
|
})
|
|
319
366
|
})
|
|
@@ -351,6 +398,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
351
398
|
ok: { type: 'boolean' },
|
|
352
399
|
error: { type: 'string' },
|
|
353
400
|
binding: { oneOf: [{ type: 'null' }, { type: 'object', additionalProperties: true }] },
|
|
401
|
+
bindingInherited: { type: 'boolean' },
|
|
354
402
|
worktrees: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
355
403
|
branches: { type: 'array', items: { type: 'string' } },
|
|
356
404
|
branchesTruncated: { type: 'boolean' },
|
|
@@ -395,7 +443,8 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
395
443
|
|
|
396
444
|
ctx.tools.register(defineTool({
|
|
397
445
|
name: 'worktree_status',
|
|
398
|
-
description: 'Show this session\'s
|
|
446
|
+
description: 'Show this session\'s worktree (its own, or the one its parent session entered — bindingInherited says which) '
|
|
447
|
+
+ 'and the repository\'s existing worktrees with branches.',
|
|
399
448
|
parameters: {},
|
|
400
449
|
output: output(STATUS_SCHEMA),
|
|
401
450
|
execute: async (_args: Record<string, never>, exec: ToolRunContext) => {
|
|
@@ -424,10 +473,20 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
424
473
|
// the browser. The tree and the counters need only `status` and
|
|
425
474
|
// `--numstat`, both of which stay around 110-140ms at that size, and the
|
|
426
475
|
// pane already fetches the file it is actually showing through `fileDiff`.
|
|
427
|
-
|
|
476
|
+
// The fourth read resolves where the rest of this method must run: the
|
|
477
|
+
// repository ROOT. The paths this method handles are repository-relative
|
|
478
|
+
// (that is what porcelain status prints wherever it runs), so the
|
|
479
|
+
// untracked files read below must be joined against the root — a session
|
|
480
|
+
// opened at `repo/server` would otherwise look for `repo/server/server/f`.
|
|
481
|
+
// The three git reads themselves are cwd-INSENSITIVE (their output names
|
|
482
|
+
// paths from the repository root whatever directory they run in), so they
|
|
483
|
+
// stay on the session's own directory and the root resolve rides along in
|
|
484
|
+
// the same batch: the polled call pays for it in wall time not at all.
|
|
485
|
+
const [statusInfo, numstat, revInfo, root] = await Promise.all([
|
|
428
486
|
this.git(cwd, ['status', '--porcelain=v1', '--branch', '--untracked-files=all'], signal),
|
|
429
487
|
this.git(cwd, ['diff', 'HEAD', '--numstat'], signal),
|
|
430
488
|
this.git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], signal),
|
|
489
|
+
this.rootedDirOf(worktreePath, signal),
|
|
431
490
|
])
|
|
432
491
|
if (statusInfo.exitCode !== 0) {
|
|
433
492
|
const detail = statusInfo.stderr.length > 0 ? `: ${statusInfo.stderr}` : ''
|
|
@@ -443,7 +502,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
443
502
|
// segments into the payload — is gone with the payload itself; `fileDiff`
|
|
444
503
|
// synthesizes the one segment the reader has actually opened.
|
|
445
504
|
const untracked = files.filter(file => file.status === 'untracked')
|
|
446
|
-
const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(
|
|
505
|
+
const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(root, file.path))
|
|
447
506
|
|
|
448
507
|
for (const [index, file] of untracked.entries()) {
|
|
449
508
|
const measure = measured[index]
|
|
@@ -513,33 +572,38 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
513
572
|
*/
|
|
514
573
|
@Remote('fileDiff')
|
|
515
574
|
async fileDiff(worktreePath: string, path: string, commit: string | undefined, base: string | undefined, head: string | undefined, signal: AbortSignal): Promise<{ readonly diff: string }> {
|
|
516
|
-
const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
|
|
517
575
|
if (typeof path !== 'string' || path.length === 0) return { diff: '' }
|
|
576
|
+
// The repository root, not the session's directory: the pathspec below
|
|
577
|
+
// is repository-relative, and git resolves pathspecs against the cwd —
|
|
578
|
+
// from a subdirectory `diff HEAD -- server/f` looks for
|
|
579
|
+
// `server/server/f`, matches nothing, and reports an EMPTY diff with
|
|
580
|
+
// exit 0. (repo-root.ts holds the whole story.)
|
|
581
|
+
const root = await this.rootedDirOf(worktreePath, signal)
|
|
518
582
|
if (typeof base === 'string' && base.length > 0 && typeof head === 'string' && head.length > 0) {
|
|
519
583
|
if (!isRefName(base) || !isRefName(head)) return { diff: '' }
|
|
520
|
-
const ranged = await this.git(
|
|
584
|
+
const ranged = await this.git(root, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal)
|
|
521
585
|
if (ranged.exitCode === 0) return { diff: ranged.stdout }
|
|
522
586
|
// Unrelated histories have no merge base for `A...B` to diff from; the
|
|
523
587
|
// two-tip diff still answers what differs, exactly as `compareRefs` does.
|
|
524
588
|
if (!isNoMergeBaseError(ranged.stderr)) return { diff: '' }
|
|
525
|
-
const tips = await this.git(
|
|
589
|
+
const tips = await this.git(root, ['diff', '--no-renames', base, head, '--', path], signal)
|
|
526
590
|
return { diff: tips.exitCode === 0 ? tips.stdout : '' }
|
|
527
591
|
}
|
|
528
592
|
if (typeof commit === 'string' && commit.length > 0) {
|
|
529
593
|
if (!COMMIT_HASH.test(commit)) return { diff: '' }
|
|
530
|
-
const key = cacheKey(
|
|
594
|
+
const key = cacheKey(root, commit, path)
|
|
531
595
|
const cached = this.commitDiffCache.get(key)
|
|
532
596
|
if (cached !== undefined) return { diff: cached }
|
|
533
597
|
// `--first-parent` for the same reason as in `commitStats`: without it a
|
|
534
598
|
// merge commit has no diff to show and the pane opens empty.
|
|
535
|
-
const shown = await this.git(
|
|
599
|
+
const shown = await this.git(root, ['show', commit, '--first-parent', '--format=', '--no-renames', '--', path], signal)
|
|
536
600
|
if (shown.exitCode !== 0) return { diff: '' }
|
|
537
601
|
this.commitDiffCache.set(key, shown.stdout)
|
|
538
602
|
return { diff: shown.stdout }
|
|
539
603
|
}
|
|
540
|
-
const tracked = await this.git(
|
|
604
|
+
const tracked = await this.git(root, ['diff', 'HEAD', '--', path], signal)
|
|
541
605
|
if (tracked.stdout.trim().length > 0) return { diff: tracked.stdout }
|
|
542
|
-
return { diff: await untrackedSegment(
|
|
606
|
+
return { diff: await untrackedSegment(root, path) ?? '' }
|
|
543
607
|
}
|
|
544
608
|
|
|
545
609
|
/**
|
|
@@ -555,7 +619,8 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
555
619
|
* nothing for them and the unstaged layer falls back to the synthesized
|
|
556
620
|
* new-file segment `fileDiff` already uses.
|
|
557
621
|
*
|
|
558
|
-
* @param worktreePath - directory
|
|
622
|
+
* @param worktreePath - directory the session opened; git runs at its
|
|
623
|
+
* repository root. Empty falls back to the host cwd.
|
|
559
624
|
* @param path - repository-relative path, as the drawer lists it.
|
|
560
625
|
* @param layer - `unstaged` (index→worktree) or `staged` (HEAD→index).
|
|
561
626
|
* @param signal - abort signal.
|
|
@@ -565,41 +630,48 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
565
630
|
if (layer !== 'unstaged' && layer !== 'staged') {
|
|
566
631
|
throw new Error(`unknown layer "${String(layer)}"; expected 'unstaged' or 'staged'`)
|
|
567
632
|
}
|
|
568
|
-
if (typeof path !== 'string' || !
|
|
633
|
+
if (typeof path !== 'string' || !isSafeRelativePath(path)) {
|
|
569
634
|
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
|
|
570
635
|
}
|
|
571
|
-
|
|
636
|
+
// Repository root, not the session's directory: every path below is
|
|
637
|
+
// repository-relative (pathspecs resolve against the cwd, and so does
|
|
638
|
+
// the file read for the editor's target). See repo-root.ts.
|
|
639
|
+
const root = await this.rootedDirOf(worktreePath, signal)
|
|
640
|
+
// The unstaged layer READS THE FILE, so its absolute path is built by the
|
|
641
|
+
// lock rather than by a join — see path-lock.ts. Done here, once, so the
|
|
642
|
+
// one place that resolves a client path is visible in this method.
|
|
572
643
|
return layer === 'unstaged'
|
|
573
|
-
? await this.unstagedSides(
|
|
574
|
-
: await this.stagedSides(
|
|
644
|
+
? await this.unstagedSides(root, path, resolveInside(root, path), signal)
|
|
645
|
+
: await this.stagedSides(root, path, signal)
|
|
575
646
|
}
|
|
576
647
|
|
|
577
|
-
/** The unstaged layer: diff index→worktree, target = the working-tree file.
|
|
578
|
-
|
|
648
|
+
/** The unstaged layer: diff index→worktree, target = the working-tree file.
|
|
649
|
+
* `full` is that file's absolute path, already through the lock. */
|
|
650
|
+
private async unstagedSides(root: string, path: string, full: string, signal: AbortSignal): Promise<FileSides> {
|
|
579
651
|
// Size guard first, off the stat rather than a read: declining a file past
|
|
580
652
|
// the cap must not mean loading a pathological one whole first. Bytes are
|
|
581
653
|
// all a stat knows; the line half of the guard needs the read below.
|
|
582
654
|
try {
|
|
583
|
-
const info = await stat(
|
|
655
|
+
const info = await stat(full)
|
|
584
656
|
if (info.isFile() && targetTooLarge(info.size, 0)) return { ...emptySides(), tooLarge: true }
|
|
585
657
|
} catch {
|
|
586
658
|
// Missing file: deleted in the working tree, which the diff below states.
|
|
587
659
|
}
|
|
588
660
|
let bytes: Buffer | null = null
|
|
589
661
|
try {
|
|
590
|
-
bytes = await readFile(
|
|
662
|
+
bytes = await readFile(full)
|
|
591
663
|
} catch {
|
|
592
664
|
bytes = null
|
|
593
665
|
}
|
|
594
666
|
if (bytes !== null && isBinaryPrefix(bytes, BINARY_SNIFF_BYTES)) {
|
|
595
|
-
return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(
|
|
667
|
+
return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(root, path, signal) }
|
|
596
668
|
}
|
|
597
669
|
if (bytes !== null && targetTooLarge(bytes.length, countBufferLines(bytes))) {
|
|
598
670
|
return { ...emptySides(), tooLarge: true }
|
|
599
671
|
}
|
|
600
|
-
const diff = await this.layerDiffText(
|
|
672
|
+
const diff = await this.layerDiffText(root, path, 'unstaged', signal)
|
|
601
673
|
if (binaryDiffOutput(diff)) {
|
|
602
|
-
return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(
|
|
674
|
+
return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(root, path, signal) }
|
|
603
675
|
}
|
|
604
676
|
// The diff half of the guard, AFTER the artifact exists: the target-side
|
|
605
677
|
// checks above cannot see a worktree-deleted large file (no target to
|
|
@@ -610,7 +682,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
610
682
|
diff,
|
|
611
683
|
diffSha: sha1Hex(diff),
|
|
612
684
|
targetText: bytes === null ? '' : bytes.toString('utf8'),
|
|
613
|
-
targetSha: bytes === null ? '' : await this.worktreeBlobSha(
|
|
685
|
+
targetSha: bytes === null ? '' : await this.worktreeBlobSha(root, path, signal),
|
|
614
686
|
binary: false,
|
|
615
687
|
tooLarge: false,
|
|
616
688
|
// Only the unstaged layer can report this, and only it needs to: the
|
|
@@ -620,11 +692,11 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
620
692
|
}
|
|
621
693
|
|
|
622
694
|
/** The staged layer: diff HEAD→index, target = the index blob. */
|
|
623
|
-
private async stagedSides(
|
|
695
|
+
private async stagedSides(root: string, path: string, signal: AbortSignal): Promise<FileSides> {
|
|
624
696
|
// `:path` resolves the stage-0 index entry: the target text when it exists,
|
|
625
697
|
// and a failed resolution (no entry) is the empty target, not an error.
|
|
626
|
-
const shown = await this.git(
|
|
627
|
-
const sha = (await this.git(
|
|
698
|
+
const shown = await this.git(root, ['show', `:${path}`], signal)
|
|
699
|
+
const sha = (await this.git(root, ['rev-parse', '--verify', '--quiet', `:${path}`], signal)).stdout.trim()
|
|
628
700
|
const targetText = shown.exitCode === 0 ? shown.stdout : ''
|
|
629
701
|
const targetBytes = Buffer.from(targetText, 'utf8')
|
|
630
702
|
if (targetText.length > 0 && isBinaryPrefix(targetBytes, BINARY_SNIFF_BYTES)) {
|
|
@@ -633,7 +705,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
633
705
|
if (targetTooLarge(targetBytes.length, countBufferLines(targetBytes))) {
|
|
634
706
|
return { ...emptySides(), tooLarge: true, targetSha: sha }
|
|
635
707
|
}
|
|
636
|
-
const diff = await this.layerDiffText(
|
|
708
|
+
const diff = await this.layerDiffText(root, path, 'staged', signal)
|
|
637
709
|
if (binaryDiffOutput(diff)) {
|
|
638
710
|
return { ...emptySides(), binary: true, targetSha: sha }
|
|
639
711
|
}
|
|
@@ -655,8 +727,8 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
655
727
|
}
|
|
656
728
|
|
|
657
729
|
/** Blob sha of the working-tree file, '' when git cannot hash it. */
|
|
658
|
-
private async worktreeBlobSha(
|
|
659
|
-
const hashed = await this.git(
|
|
730
|
+
private async worktreeBlobSha(root: string, path: string, signal: AbortSignal): Promise<string> {
|
|
731
|
+
const hashed = await this.git(root, ['hash-object', '--', path], signal)
|
|
660
732
|
return hashed.exitCode === 0 ? hashed.stdout.trim() : ''
|
|
661
733
|
}
|
|
662
734
|
|
|
@@ -669,19 +741,19 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
669
741
|
* its own fresh fetch, so the two ends of the stale comparison are over the
|
|
670
742
|
* SAME text by construction, not by two fetch sites staying in step.
|
|
671
743
|
*/
|
|
672
|
-
private async layerDiffText(
|
|
744
|
+
private async layerDiffText(root: string, path: string, layer: 'unstaged' | 'staged', signal: AbortSignal): Promise<string> {
|
|
673
745
|
if (layer === 'staged') {
|
|
674
|
-
return (await this.git(
|
|
746
|
+
return (await this.git(root, ['diff', '--cached', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout
|
|
675
747
|
}
|
|
676
|
-
const diff = (await this.git(
|
|
748
|
+
const diff = (await this.git(root, ['diff', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout
|
|
677
749
|
// Untracked files have no index entry, so `git diff` reports nothing for
|
|
678
750
|
// them; the synthesized new-file segment is their unstaged diff. The
|
|
679
751
|
// trailing newline is added here because every diff git prints carries
|
|
680
752
|
// one: this text is what `applyBlocks` re-emits as a patch file, and a
|
|
681
753
|
// patch whose last line has no LF is "corrupt patch" to `git apply` —
|
|
682
754
|
// which is exactly what a real-git drive of the untracked path caught.
|
|
683
|
-
if (diff.length === 0 && await this.isUntracked(
|
|
684
|
-
const segment = await untrackedSegment(
|
|
755
|
+
if (diff.length === 0 && await this.isUntracked(root, path, signal)) {
|
|
756
|
+
const segment = await untrackedSegment(root, path, SIDE_BYTE_CAP)
|
|
685
757
|
return segment === null ? '' : `${segment}\n`
|
|
686
758
|
}
|
|
687
759
|
return diff
|
|
@@ -700,7 +772,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
700
772
|
* shared with `fileSides`, and the tmpfile pair. Every failure comes back as
|
|
701
773
|
* a result — the method never throws across the RPC boundary.
|
|
702
774
|
*
|
|
703
|
-
* @param worktreePath - directory
|
|
775
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
704
776
|
* @param path - repository-relative path, as the drawer lists it.
|
|
705
777
|
* @param layer - the layer the block was selected on; the mode decides which
|
|
706
778
|
* one that may be.
|
|
@@ -712,14 +784,16 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
712
784
|
*/
|
|
713
785
|
@Remote('applyBlocks')
|
|
714
786
|
async applyBlocks(worktreePath: string, path: string, layer: string, diffSha: string, lines: readonly number[], mode: string, signal: AbortSignal): Promise<GitOpResult> {
|
|
715
|
-
|
|
787
|
+
// The repository root — the patch's pathspecs are repository-relative
|
|
788
|
+
// and `git apply` resolves them against the cwd (repo-root.ts).
|
|
789
|
+
const root = await this.rootedDirOf(worktreePath, signal)
|
|
716
790
|
const io: ApplyBlocksIo = {
|
|
717
791
|
git: (dir, argv) => this.git(dir, argv, signal),
|
|
718
|
-
layerDiff: (file, which) => this.layerDiffText(
|
|
792
|
+
layerDiff: (file, which) => this.layerDiffText(root, file, which === 'staged' ? 'staged' : 'unstaged', signal),
|
|
719
793
|
writePatch: writeTmpPatch,
|
|
720
794
|
dropPatch: dropTmpPatch,
|
|
721
795
|
}
|
|
722
|
-
return runApplyBlocks(io,
|
|
796
|
+
return runApplyBlocks(io, root, path, layer, String(diffSha ?? ''), lines, mode)
|
|
723
797
|
}
|
|
724
798
|
|
|
725
799
|
/**
|
|
@@ -741,7 +815,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
741
815
|
* check and this method are one thing, and no unchecked write RPC exists or
|
|
742
816
|
* may be added in this plugin.
|
|
743
817
|
*
|
|
744
|
-
* @param worktreePath - directory
|
|
818
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
745
819
|
* @param path - repository-relative path, as the drawer lists it.
|
|
746
820
|
* @param text - the editor buffer, verbatim; written as bytes (LF as given).
|
|
747
821
|
* @param expectedSha - the `targetSha` the buffer was opened with ('' when
|
|
@@ -751,7 +825,10 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
751
825
|
*/
|
|
752
826
|
@Remote('writeChecked')
|
|
753
827
|
async writeChecked(worktreePath: string, path: string, text: string, expectedSha: string, signal: AbortSignal): Promise<WriteResult> {
|
|
754
|
-
|
|
828
|
+
// The repository root — the save target joins the same base every
|
|
829
|
+
// other path here is relative to, and that base is the root, not the
|
|
830
|
+
// directory the session opened (repo-root.ts).
|
|
831
|
+
const root = await this.rootedDirOf(worktreePath, signal)
|
|
755
832
|
const io: WriteCheckedIo = {
|
|
756
833
|
git: (dir, argv) => this.git(dir, argv, signal),
|
|
757
834
|
exists: async p => {
|
|
@@ -768,7 +845,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
768
845
|
remove: async p => { await rm(p, { force: true }) },
|
|
769
846
|
delay: ms => new Promise(resolve => { setTimeout(resolve, ms) }),
|
|
770
847
|
}
|
|
771
|
-
return runWriteChecked(io,
|
|
848
|
+
return runWriteChecked(io, root, path, typeof text === 'string' ? text : '', typeof expectedSha === 'string' ? expectedSha : '')
|
|
772
849
|
}
|
|
773
850
|
|
|
774
851
|
/**
|
|
@@ -780,7 +857,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
780
857
|
* rather than missing. Read-only, no index or worktree is touched, so this
|
|
781
858
|
* needs none of the confirmation machinery the write paths carry.
|
|
782
859
|
*
|
|
783
|
-
* @param worktreePath - directory
|
|
860
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
784
861
|
* @param path - repository-relative path, as the drawer lists it.
|
|
785
862
|
* @param signal - abort signal.
|
|
786
863
|
*/
|
|
@@ -789,10 +866,13 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
789
866
|
if (typeof path !== 'string' || !isSafePathArg(path)) {
|
|
790
867
|
return { lines: [], truncated: false, error: `unsafe path argument: ${JSON.stringify(path)}` }
|
|
791
868
|
}
|
|
792
|
-
|
|
869
|
+
// The repository root: blame's pathspec resolves against the cwd, and
|
|
870
|
+
// from a subdirectory the repository-relative path doubles up
|
|
871
|
+
// (`server/server/f`) into a fatal "no such path" (repo-root.ts).
|
|
872
|
+
const root = await this.rootedDirOf(worktreePath, signal)
|
|
793
873
|
// `--` keeps a path that looks like a revision from being read as one, as
|
|
794
874
|
// every other pathspec in this plugin does.
|
|
795
|
-
const run = await this.git(
|
|
875
|
+
const run = await this.git(root, ['blame', '--line-porcelain', '--', path], signal)
|
|
796
876
|
if (run.exitCode !== 0) {
|
|
797
877
|
// An untracked file has no blame, and git says so; that message is the
|
|
798
878
|
// honest thing to show rather than an empty gutter.
|
|
@@ -826,16 +906,20 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
826
906
|
*
|
|
827
907
|
* Read-only — nothing is spawned, nothing is written.
|
|
828
908
|
*
|
|
829
|
-
* @param worktreePath - directory
|
|
909
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
830
910
|
* @param path - repository-relative path, as the drawer lists it.
|
|
831
911
|
* @param signal - abort signal.
|
|
832
912
|
*/
|
|
833
913
|
@Remote('fileImage')
|
|
834
914
|
async fileImage(worktreePath: string, path: string, signal: AbortSignal): Promise<FileImage> {
|
|
835
|
-
if (typeof path !== 'string' || !
|
|
915
|
+
if (typeof path !== 'string' || !isSafeRelativePath(path)) {
|
|
836
916
|
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
|
|
837
917
|
}
|
|
838
|
-
|
|
918
|
+
// The repository root — the image is read from disk at the same base
|
|
919
|
+
// every other path in this plugin is relative to (repo-root.ts) — and
|
|
920
|
+
// through the lock, because this is a raw read with no git in the way
|
|
921
|
+
// to refuse a path that leaves the repository (path-lock.ts).
|
|
922
|
+
const full = resolveInside(await this.rootedDirOf(worktreePath, signal), path)
|
|
839
923
|
let size = 0
|
|
840
924
|
try {
|
|
841
925
|
const info = await stat(full)
|
|
@@ -871,10 +955,10 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
871
955
|
* Those are the files whose diff has to be synthesized rather than asked of
|
|
872
956
|
* `git diff`, which reports nothing for them.
|
|
873
957
|
*/
|
|
874
|
-
private async isUntracked(
|
|
875
|
-
const listed = await this.git(
|
|
958
|
+
private async isUntracked(root: string, path: string, signal: AbortSignal): Promise<boolean> {
|
|
959
|
+
const listed = await this.git(root, ['ls-files', '--', path], signal)
|
|
876
960
|
if (listed.exitCode !== 0 || listed.stdout.trim().length > 0) return false
|
|
877
|
-
const head = await this.git(
|
|
961
|
+
const head = await this.git(root, ['rev-parse', '--verify', '--quiet', `HEAD:${path}`], signal)
|
|
878
962
|
return head.exitCode !== 0
|
|
879
963
|
}
|
|
880
964
|
|
|
@@ -909,11 +993,17 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
909
993
|
// empty pane. Against the first parent the answer is well defined and is the
|
|
910
994
|
// useful one: what this merge brought into the branch it landed on. On a
|
|
911
995
|
// single-parent commit the flag is a no-op, byte for byte.
|
|
996
|
+
// The repository root: no pathspec here today, but the cache key below
|
|
997
|
+
// must describe the same repository to a later caller, and running from
|
|
998
|
+
// the root is the one rule every read in this plugin follows
|
|
999
|
+
// (repo-root.ts). Resolved after the cache probe so a hit spawns
|
|
1000
|
+
// nothing.
|
|
1001
|
+
const root = await this.rootedDirOf(worktreePath, signal)
|
|
912
1002
|
const [meta, numstat, nameStatus, patch] = await Promise.all([
|
|
913
|
-
this.git(
|
|
914
|
-
this.git(
|
|
915
|
-
this.git(
|
|
916
|
-
this.git(
|
|
1003
|
+
this.git(root, ['show', hash, '--no-patch', `--format=${LOG_FORMAT}`], signal),
|
|
1004
|
+
this.git(root, ['show', hash, '--first-parent', '--numstat', '--format=', '--no-renames'], signal),
|
|
1005
|
+
this.git(root, ['show', hash, '--first-parent', '--name-status', '--format=', '--no-renames'], signal),
|
|
1006
|
+
this.git(root, ['show', hash, '--first-parent', '--format=', '--no-renames'], signal),
|
|
917
1007
|
])
|
|
918
1008
|
if (meta.exitCode !== 0) {
|
|
919
1009
|
const detail = meta.stderr.length > 0 ? `: ${meta.stderr}` : ''
|
|
@@ -979,6 +1069,10 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
979
1069
|
const from = Number.isInteger(skip) && skip >= 0 ? skip : 0
|
|
980
1070
|
const size = Number.isInteger(limit) && limit > 0 && limit <= HISTORY_PAGE_MAX ? limit : HISTORY_PAGE
|
|
981
1071
|
const effective = filter ?? emptyLogFilter()
|
|
1072
|
+
// The repository root: a filter's pathspec is repository-relative, and
|
|
1073
|
+
// git resolves pathspecs against the cwd — from a subdirectory a
|
|
1074
|
+
// filtered history silently comes back EMPTY with exit 0 (repo-root.ts).
|
|
1075
|
+
const root = await this.rootedDirOf(worktreePath, signal)
|
|
982
1076
|
// Reading one row beyond the page answers "is there more" without a second
|
|
983
1077
|
// traversal of the log.
|
|
984
1078
|
//
|
|
@@ -992,7 +1086,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
992
1086
|
// Filter args go LAST: their segment ends with `--` + pathspecs, and
|
|
993
1087
|
// nothing after that separator may be parsed as a flag.
|
|
994
1088
|
const log = await this.git(
|
|
995
|
-
|
|
1089
|
+
root,
|
|
996
1090
|
['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`, ...logFilterArgs(effective)],
|
|
997
1091
|
signal,
|
|
998
1092
|
)
|
|
@@ -1032,21 +1126,124 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1032
1126
|
|
|
1033
1127
|
/**
|
|
1034
1128
|
* Every file path on HEAD — the filter popup's path picker, aggregated into
|
|
1035
|
-
* a directory tree client-side
|
|
1129
|
+
* a directory tree client-side — plus the ignored-but-present entries the
|
|
1130
|
+
* Files tab browses.
|
|
1131
|
+
*
|
|
1132
|
+
* The ignored listing is `ls-files --others --ignored --exclude-standard
|
|
1133
|
+
* --directory`: every ignored FILE is listed verbatim (`application-local.yml`
|
|
1134
|
+
* is exactly the file a browser must find and `ls-tree HEAD` cannot), while
|
|
1135
|
+
* every directory a rule ignores as a whole collapses to ONE line with a
|
|
1136
|
+
* trailing slash — `node_modules/` costs one entry, not the ~40k files
|
|
1137
|
+
* inside it. The list therefore scales with the .gitignore's coverage, not
|
|
1138
|
+
* the repository: measured 54 entries / 48ms on this checkout with
|
|
1139
|
+
* node_modules present.
|
|
1036
1140
|
*
|
|
1037
|
-
* `-z` is load-bearing: NUL-separated output is UNQUOTED,
|
|
1038
|
-
* would render non-ASCII names as quoted octal escapes
|
|
1039
|
-
* `core.quotepath` and hand the picker garbage.
|
|
1141
|
+
* `-z` is load-bearing on both spawns: NUL-separated output is UNQUOTED,
|
|
1142
|
+
* while the default would render non-ASCII names as quoted octal escapes
|
|
1143
|
+
* under `core.quotepath` and hand the picker garbage.
|
|
1040
1144
|
* @param worktreePath - worktree whose HEAD is listed; empty falls back to the host cwd.
|
|
1041
1145
|
* @param signal - abort signal.
|
|
1042
1146
|
*/
|
|
1043
1147
|
@Remote('repoTree')
|
|
1044
|
-
async repoTree(worktreePath: string, signal: AbortSignal): Promise<{
|
|
1045
|
-
|
|
1046
|
-
|
|
1148
|
+
async repoTree(worktreePath: string, signal: AbortSignal): Promise<{
|
|
1149
|
+
paths: string[]
|
|
1150
|
+
truncated: boolean
|
|
1151
|
+
/** Ignored entries verbatim; directories keep their trailing slash, which
|
|
1152
|
+
* is the only thing that tells them from files. */
|
|
1153
|
+
ignored: string[]
|
|
1154
|
+
ignoredTruncated: boolean
|
|
1155
|
+
/** Present only when the ignored listing FAILED, so the browser can tell
|
|
1156
|
+
* "this repository ignores nothing" from "the question could not be
|
|
1157
|
+
* asked". Omitted on success — never `undefined`, which is not JSON. */
|
|
1158
|
+
ignoredError?: string
|
|
1159
|
+
}> {
|
|
1160
|
+
// The repository root: unlike status and numstat, `ls-tree` prints
|
|
1161
|
+
// cwd-RELATIVE paths — from a subdirectory every entry would lose the
|
|
1162
|
+
// `server/` prefix and the picker would feed the log filter pathspecs
|
|
1163
|
+
// that match nothing (repo-root.ts).
|
|
1164
|
+
const root = await this.rootedDirOf(worktreePath, signal)
|
|
1165
|
+
const [res, ignoredRes] = await Promise.all([
|
|
1166
|
+
this.git(root, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal),
|
|
1167
|
+
this.git(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], signal),
|
|
1168
|
+
])
|
|
1047
1169
|
const all = res.stdout.split('\0').filter(path => path.length > 0)
|
|
1048
1170
|
const truncated = all.length > TREE_PATH_CAP
|
|
1049
|
-
|
|
1171
|
+
const ignoredAll = ignoredRes.stdout.split('\0').filter(path => path.length > 0)
|
|
1172
|
+
const ignoredTruncated = ignoredAll.length > IGNORED_PATH_CAP
|
|
1173
|
+
// `ls-tree`'s exit code is deliberately NOT checked: a repository with no
|
|
1174
|
+
// HEAD yet fails it, and "this repository has no files" is the honest
|
|
1175
|
+
// answer there. The ignored listing has no such legitimate failure, so a
|
|
1176
|
+
// non-zero exit is reported rather than served as an empty list — the one
|
|
1177
|
+
// shape that is indistinguishable from a repository ignoring nothing.
|
|
1178
|
+
const ignoredError = ignoredRes.exitCode === 0
|
|
1179
|
+
? undefined
|
|
1180
|
+
: (ignoredRes.stderr.trim() || `git ls-files failed (exit ${ignoredRes.exitCode})`).slice(-500)
|
|
1181
|
+
return {
|
|
1182
|
+
paths: truncated ? all.slice(0, TREE_PATH_CAP) : all,
|
|
1183
|
+
truncated,
|
|
1184
|
+
ignored: ignoredTruncated ? ignoredAll.slice(0, IGNORED_PATH_CAP) : ignoredAll,
|
|
1185
|
+
ignoredTruncated,
|
|
1186
|
+
...(ignoredError !== undefined ? { ignoredError } : {}),
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* One level of one ignored directory, read from the filesystem — the step
|
|
1192
|
+
* behind clicking `node_modules/` open in the Files tab.
|
|
1193
|
+
*
|
|
1194
|
+
* Deliberately NOT a git listing: with `--directory`, a pathspec under an
|
|
1195
|
+
* ignored directory still collapses to that one directory line (probed:
|
|
1196
|
+
* `ls-files --others --ignored --exclude-standard --directory -- 'node_modules/*'`
|
|
1197
|
+
* answers `node_modules/` and nothing else), and dropping `--directory`
|
|
1198
|
+
* would enumerate every file inside at once — the very flood the collapsed
|
|
1199
|
+
* listing exists to avoid. `readdir` is one level by construction and costs
|
|
1200
|
+
* milliseconds. What the entries ARE is inherited anyway: everything below
|
|
1201
|
+
* an ignored directory is ignored by descent, so the browser owes the
|
|
1202
|
+
* reader the directory's contents, not git's opinion of them.
|
|
1203
|
+
*
|
|
1204
|
+
* A directory that vanished between the listing and the click answers
|
|
1205
|
+
* empty: nothing to browse is the honest answer, and the next refresh
|
|
1206
|
+
* drops the row.
|
|
1207
|
+
*
|
|
1208
|
+
* The name says what it is FOR, not what it permits: nothing here checks
|
|
1209
|
+
* that `dir` is ignored, so this lists any directory inside the worktree.
|
|
1210
|
+
* That is the same reach the reader already has through `fileSides` and
|
|
1211
|
+
* `fileImage`, and adding an ignore check would only make the browser ask
|
|
1212
|
+
* git a second question to learn what it already knows from the listing it
|
|
1213
|
+
* was handed. What it does NOT permit is leaving the worktree, which is
|
|
1214
|
+
* what the path lock below is for.
|
|
1215
|
+
* @param worktreePath - worktree the directory lives in; empty falls back to the host cwd.
|
|
1216
|
+
* @param dir - repo-relative directory path, as the collapsed listing named it.
|
|
1217
|
+
* @param signal - abort signal.
|
|
1218
|
+
*/
|
|
1219
|
+
@Remote('ignoredDir')
|
|
1220
|
+
async ignoredDir(worktreePath: string, dir: string, signal: AbortSignal): Promise<{ entries: DirChild[]; truncated: boolean }> {
|
|
1221
|
+
// The same lock every filesystem read in this plugin passes: the browser
|
|
1222
|
+
// is a less trusted source of paths than git's own output, and `readdir`
|
|
1223
|
+
// obeys no repository boundary (path-lock.ts).
|
|
1224
|
+
const target = resolveInside(await this.rootedDirOf(worktreePath, signal), dir)
|
|
1225
|
+
let dirents
|
|
1226
|
+
try {
|
|
1227
|
+
dirents = await readdir(target, { withFileTypes: true })
|
|
1228
|
+
} catch {
|
|
1229
|
+
return { entries: [], truncated: false }
|
|
1230
|
+
}
|
|
1231
|
+
const plain = dirents
|
|
1232
|
+
.filter(entry => !entry.isSymbolicLink())
|
|
1233
|
+
.map(entry => ({ name: entry.name, dir: entry.isDirectory() }))
|
|
1234
|
+
// A pnpm-style layout makes every package row a symlink into the store;
|
|
1235
|
+
// `withFileTypes` reports the LINK, so a follow-up stat decides whether
|
|
1236
|
+
// the row expands. A broken link reads as a file and simply fails to
|
|
1237
|
+
// open, like any other dangling name.
|
|
1238
|
+
const links = dirents.filter(entry => entry.isSymbolicLink())
|
|
1239
|
+
const resolved = await Promise.all(links.map(async entry => {
|
|
1240
|
+
try {
|
|
1241
|
+
return { name: entry.name, dir: (await stat(join(target, entry.name))).isDirectory() }
|
|
1242
|
+
} catch {
|
|
1243
|
+
return { name: entry.name, dir: false }
|
|
1244
|
+
}
|
|
1245
|
+
}))
|
|
1246
|
+
return shapeDirChildren([...plain, ...resolved])
|
|
1050
1247
|
}
|
|
1051
1248
|
|
|
1052
1249
|
/**
|
|
@@ -1120,13 +1317,20 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1120
1317
|
}
|
|
1121
1318
|
}
|
|
1122
1319
|
|
|
1123
|
-
/**
|
|
1320
|
+
/**
|
|
1321
|
+
* The session's EFFECTIVE worktree binding — its own, else the nearest bound
|
|
1322
|
+
* ancestor's — or nulls when neither exists. This is what the chip follows,
|
|
1323
|
+
* so a subagent session shows the worktree its conversation works in without
|
|
1324
|
+
* ever holding a binding of its own (plain-identifier params; signal last).
|
|
1325
|
+
*/
|
|
1124
1326
|
@Remote('sessionWorktree')
|
|
1125
|
-
async sessionWorktree(sessionId: string, signal: AbortSignal): Promise<{ worktreePath: string | null; name: string | null }> {
|
|
1126
|
-
if (typeof sessionId !== 'string' || sessionId.length === 0) return { worktreePath: null, name: null }
|
|
1327
|
+
async sessionWorktree(sessionId: string, signal: AbortSignal): Promise<{ worktreePath: string | null; name: string | null; inherited: boolean }> {
|
|
1328
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return { worktreePath: null, name: null, inherited: false }
|
|
1127
1329
|
const file = await this.bindingsIo().load()
|
|
1128
|
-
const
|
|
1129
|
-
return
|
|
1330
|
+
const effective = resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id])
|
|
1331
|
+
return effective === undefined
|
|
1332
|
+
? { worktreePath: null, name: null, inherited: false }
|
|
1333
|
+
: { worktreePath: effective.binding.worktreePath, name: effective.binding.name, inherited: effective.inherited }
|
|
1130
1334
|
}
|
|
1131
1335
|
|
|
1132
1336
|
/** Create (or reuse) a git worktree under `<repoRoot>/.agents/worktrees/` and bind the session to it. */
|
|
@@ -1221,7 +1425,15 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1221
1425
|
return this.withBindings(async io => {
|
|
1222
1426
|
const file = await io.load()
|
|
1223
1427
|
const binding = file.bindings[sessionId]
|
|
1224
|
-
if (binding === undefined)
|
|
1428
|
+
if (binding === undefined) {
|
|
1429
|
+
// A subagent CAN see a worktree in its status while holding no binding
|
|
1430
|
+
// of its own (it works under its parent's). Naming that here keeps the
|
|
1431
|
+
// model from retrying an exit that cannot succeed.
|
|
1432
|
+
const inherited = resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id])
|
|
1433
|
+
return inherited === undefined
|
|
1434
|
+
? { ok: false, error: 'no worktree binding for this session' }
|
|
1435
|
+
: { 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' }
|
|
1436
|
+
}
|
|
1225
1437
|
if (remove === true) {
|
|
1226
1438
|
const status = await this.git(binding.worktreePath, ['status', '--porcelain'], signal)
|
|
1227
1439
|
if (status.exitCode !== 0) {
|
|
@@ -1253,21 +1465,26 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1253
1465
|
* Branches come back most-recently-committed first. With hundreds of them the
|
|
1254
1466
|
* order is what makes the list usable — the handful anyone is working on sit
|
|
1255
1467
|
* at the top, so the picker is useful before a single character is typed.
|
|
1256
|
-
* @param sessionId - session whose binding is looked up.
|
|
1468
|
+
* @param sessionId - session whose effective binding is looked up.
|
|
1257
1469
|
* @param repoPath - caller's directory, used when the session is unbound.
|
|
1258
1470
|
* @param signal - abort signal.
|
|
1259
|
-
* @returns the binding
|
|
1471
|
+
* @returns the effective binding (with whether an ancestor lent it), the
|
|
1472
|
+
* repository's worktrees, and its local branches.
|
|
1260
1473
|
*/
|
|
1261
1474
|
@Remote('worktreeStatus')
|
|
1262
|
-
async worktreeStatus(sessionId: string, repoPath: string, signal: AbortSignal): Promise<{ binding: WorktreeBinding | null; worktrees: WorktreeEntry[]; branches: string[]; branchesTruncated: boolean }> {
|
|
1475
|
+
async worktreeStatus(sessionId: string, repoPath: string, signal: AbortSignal): Promise<{ binding: WorktreeBinding | null; bindingInherited: boolean; worktrees: WorktreeEntry[]; branches: string[]; branchesTruncated: boolean }> {
|
|
1263
1476
|
const file = await this.bindingsIo().load()
|
|
1264
|
-
const
|
|
1477
|
+
const effective = typeof sessionId === 'string' && sessionId.length > 0
|
|
1478
|
+
? resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id])
|
|
1479
|
+
: undefined
|
|
1480
|
+
const binding = effective?.binding ?? null
|
|
1481
|
+
const bindingInherited = effective?.inherited ?? false
|
|
1265
1482
|
// Unbound: list the CALLER's repo. Falling back to the host's launch directory
|
|
1266
1483
|
// would answer about whatever directory dsh was started in, not this session's.
|
|
1267
1484
|
const caller = typeof repoPath === 'string' && repoPath.length > 0 ? repoPath.replace(/\\/g, '/') : process.cwd()
|
|
1268
1485
|
const cwd = binding?.repoRoot ?? caller
|
|
1269
1486
|
const root = await this.repoRootOf(cwd, signal)
|
|
1270
|
-
if (root === null) return { binding, worktrees: [], branches: [], branchesTruncated: false }
|
|
1487
|
+
if (root === null) return { binding, bindingInherited, worktrees: [], branches: [], branchesTruncated: false }
|
|
1271
1488
|
const [listed, named] = await Promise.all([
|
|
1272
1489
|
this.git(root, ['worktree', 'list', '--porcelain'], signal),
|
|
1273
1490
|
this.git(root, ['branch', '--sort=-committerdate', '--format=%(refname:short)'], signal),
|
|
@@ -1276,6 +1493,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1276
1493
|
const { branches, branchesTruncated } = capBranches(all, BRANCH_LIST_CAP)
|
|
1277
1494
|
return {
|
|
1278
1495
|
binding,
|
|
1496
|
+
bindingInherited,
|
|
1279
1497
|
worktrees: parseWorktreeList(listed.stdout),
|
|
1280
1498
|
branches,
|
|
1281
1499
|
branchesTruncated,
|
|
@@ -1358,25 +1576,31 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1358
1576
|
|
|
1359
1577
|
/**
|
|
1360
1578
|
* Add paths to the index.
|
|
1361
|
-
* @param worktreePath - directory
|
|
1579
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
1362
1580
|
* @param paths - repository-relative paths; an empty list is refused rather
|
|
1363
1581
|
* than turned into a whole-tree `git add`.
|
|
1364
1582
|
* @param signal - abort signal.
|
|
1365
1583
|
*/
|
|
1366
1584
|
@Remote('stage')
|
|
1367
1585
|
async stage(worktreePath: string, paths: readonly string[], signal: AbortSignal): Promise<GitOpResult> {
|
|
1368
|
-
|
|
1586
|
+
// The repository root: `git add` resolves its pathspecs against the cwd,
|
|
1587
|
+
// and from a subdirectory a repository-relative path dies with
|
|
1588
|
+
// "pathspec did not match any files" (repo-root.ts).
|
|
1589
|
+
const root = await this.rootedDirOf(worktreePath, signal)
|
|
1590
|
+
return this.writeOp(root, () => stageArgv(asPathList(paths)), signal)
|
|
1369
1591
|
}
|
|
1370
1592
|
|
|
1371
1593
|
/**
|
|
1372
1594
|
* Remove paths from the index, leaving the working tree untouched.
|
|
1373
|
-
* @param worktreePath - directory
|
|
1595
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
1374
1596
|
* @param paths - repository-relative paths.
|
|
1375
1597
|
* @param signal - abort signal.
|
|
1376
1598
|
*/
|
|
1377
1599
|
@Remote('unstage')
|
|
1378
1600
|
async unstage(worktreePath: string, paths: readonly string[], signal: AbortSignal): Promise<GitOpResult> {
|
|
1379
|
-
|
|
1601
|
+
// Same rule as `stage` — the pathspecs are repository-relative.
|
|
1602
|
+
const root = await this.rootedDirOf(worktreePath, signal)
|
|
1603
|
+
return this.writeOp(root, () => unstageArgv(asPathList(paths)), signal)
|
|
1380
1604
|
}
|
|
1381
1605
|
|
|
1382
1606
|
/**
|
|
@@ -1388,7 +1612,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1388
1612
|
* cannot come back" is exactly the difference the reader is being asked
|
|
1389
1613
|
* about. So the dialog is built from this, read fresh, rather than from the
|
|
1390
1614
|
* row that was clicked.
|
|
1391
|
-
* @param worktreePath - directory
|
|
1615
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
1392
1616
|
* @param path - repository-relative path, as the drawer lists it.
|
|
1393
1617
|
* @param signal - abort signal.
|
|
1394
1618
|
* @returns the effect and whether it is irreversible; `effect` is absent when
|
|
@@ -1401,9 +1625,12 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1401
1625
|
previousPath?: string
|
|
1402
1626
|
error?: string
|
|
1403
1627
|
}> {
|
|
1628
|
+
// The repository root — the plan's argv and delete paths are
|
|
1629
|
+
// repository-relative (repo-root.ts).
|
|
1630
|
+
const root = await this.rootedDirOf(worktreePath, signal)
|
|
1404
1631
|
let plan: DiscardPlan | null
|
|
1405
1632
|
try {
|
|
1406
|
-
plan = await this.planDiscard(
|
|
1633
|
+
plan = await this.planDiscard(root, path, signal)
|
|
1407
1634
|
} catch (error) {
|
|
1408
1635
|
return { error: error instanceof Error ? error.message : String(error) }
|
|
1409
1636
|
}
|
|
@@ -1427,7 +1654,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1427
1654
|
* it. `expectedEffect` is what the reader was shown and agreed to: if the
|
|
1428
1655
|
* file changed underneath the dialog — staged, edited, reverted by someone
|
|
1429
1656
|
* else — the freshly derived effect no longer matches and nothing is done.
|
|
1430
|
-
* @param worktreePath - directory
|
|
1657
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
1431
1658
|
* @param path - repository-relative path, as the drawer lists it.
|
|
1432
1659
|
* @param expectedEffect - the effect the confirmation stated; blank skips
|
|
1433
1660
|
* the agreement check, which only the reversible
|
|
@@ -1437,9 +1664,13 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1437
1664
|
*/
|
|
1438
1665
|
@Remote('discardFile')
|
|
1439
1666
|
async discardFile(worktreePath: string, path: string, expectedEffect: string | undefined, signal: AbortSignal): Promise<GitOpResult & { effect?: DiscardEffect }> {
|
|
1667
|
+
// The repository root, resolved ONCE for the plan and the steps alike:
|
|
1668
|
+
// every step's argv and delete path came out of a whole-tree porcelain
|
|
1669
|
+
// status, so they are repository-relative (repo-root.ts).
|
|
1670
|
+
const root = await this.rootedDirOf(worktreePath, signal)
|
|
1440
1671
|
let plan: DiscardPlan | null
|
|
1441
1672
|
try {
|
|
1442
|
-
plan = await this.planDiscard(
|
|
1673
|
+
plan = await this.planDiscard(root, path, signal)
|
|
1443
1674
|
} catch (error) {
|
|
1444
1675
|
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
|
|
1445
1676
|
}
|
|
@@ -1454,10 +1685,9 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1454
1685
|
}
|
|
1455
1686
|
}
|
|
1456
1687
|
|
|
1457
|
-
const cwd = this.cwdOf(worktreePath)
|
|
1458
1688
|
for (const step of plan.steps) {
|
|
1459
1689
|
if (step.kind === 'git') {
|
|
1460
|
-
const result = await this.git(
|
|
1690
|
+
const result = await this.git(root, step.argv, signal)
|
|
1461
1691
|
const failure = classifyFailure(result.exitCode, result.stderr, result.stdout)
|
|
1462
1692
|
if (failure !== null) {
|
|
1463
1693
|
return { ok: false, failure, error: (result.stderr || result.stdout).trim().slice(-1000) }
|
|
@@ -1465,7 +1695,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1465
1695
|
continue
|
|
1466
1696
|
}
|
|
1467
1697
|
try {
|
|
1468
|
-
await removePathInside(
|
|
1698
|
+
await removePathInside(root, step.path)
|
|
1469
1699
|
} catch (error) {
|
|
1470
1700
|
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
|
|
1471
1701
|
}
|
|
@@ -1481,12 +1711,14 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1481
1711
|
* reports `D` plus `??` instead — which plans as "restore one, DELETE the
|
|
1482
1712
|
* other" where the truth is "undo the rename".
|
|
1483
1713
|
*/
|
|
1484
|
-
private async planDiscard(
|
|
1714
|
+
private async planDiscard(root: string, path: string, signal: AbortSignal): Promise<DiscardPlan | null> {
|
|
1485
1715
|
if (typeof path !== 'string' || !isSafePathArg(path)) {
|
|
1486
1716
|
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
|
|
1487
1717
|
}
|
|
1488
|
-
|
|
1489
|
-
|
|
1718
|
+
// Runs at the repository root the caller resolved: the plan's paths
|
|
1719
|
+
// must agree with the directory its steps execute in, and with the
|
|
1720
|
+
// client's `path`, which the drawer lists repository-relative.
|
|
1721
|
+
const status = await this.git(root, ['status', '--porcelain=v1', '--untracked-files=all'], signal)
|
|
1490
1722
|
if (status.exitCode !== 0) {
|
|
1491
1723
|
throw new Error((status.stderr || status.stdout).trim().slice(-1000) || 'git status failed')
|
|
1492
1724
|
}
|
|
@@ -1502,7 +1734,8 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1502
1734
|
*/
|
|
1503
1735
|
@Remote('commit')
|
|
1504
1736
|
async commit(worktreePath: string, message: string, amend: boolean | undefined, signal: AbortSignal): Promise<GitOpResult> {
|
|
1505
|
-
|
|
1737
|
+
// No pathspec in this argv — the session's own directory suffices.
|
|
1738
|
+
return this.writeOp(this.cwdOf(worktreePath), () => commitArgv(String(message ?? ''), amend === true), signal)
|
|
1506
1739
|
}
|
|
1507
1740
|
|
|
1508
1741
|
/**
|
|
@@ -1513,7 +1746,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1513
1746
|
*/
|
|
1514
1747
|
@Remote('fetch')
|
|
1515
1748
|
async fetch(worktreePath: string, signal: AbortSignal): Promise<GitOpResult & { tracking?: Tracking }> {
|
|
1516
|
-
const result = await this.writeOp(worktreePath, () => fetchArgv(), signal, NETWORK_GRACE_MS)
|
|
1749
|
+
const result = await this.writeOp(this.cwdOf(worktreePath), () => fetchArgv(), signal, NETWORK_GRACE_MS)
|
|
1517
1750
|
if (!result.ok) return result
|
|
1518
1751
|
// The point of fetching is the count it produces, so report it in the same
|
|
1519
1752
|
// round trip rather than making the client ask again.
|
|
@@ -1531,7 +1764,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1531
1764
|
@Remote('pull')
|
|
1532
1765
|
async pull(worktreePath: string, mode: string | undefined, signal: AbortSignal): Promise<GitOpResult> {
|
|
1533
1766
|
const chosen: PullMode = mode === 'rebase' || mode === 'merge' ? mode : 'ff-only'
|
|
1534
|
-
return this.writeOp(worktreePath, () => pullArgv(chosen), signal, NETWORK_GRACE_MS)
|
|
1767
|
+
return this.writeOp(this.cwdOf(worktreePath), () => pullArgv(chosen), signal, NETWORK_GRACE_MS)
|
|
1535
1768
|
}
|
|
1536
1769
|
|
|
1537
1770
|
/**
|
|
@@ -1550,12 +1783,18 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1550
1783
|
const tracking = parseTracking(status.stdout)
|
|
1551
1784
|
if (tracking.detached) return { ok: false, failure: 'unknown', error: 'HEAD is detached; nothing to push' }
|
|
1552
1785
|
if (tracking.branch.length === 0) return { ok: false, failure: 'unknown', error: 'no branch to push' }
|
|
1553
|
-
return this.writeOp(worktreePath, () => pushArgv(tracking.branch, tracking.upstream !== null), signal, NETWORK_GRACE_MS)
|
|
1786
|
+
return this.writeOp(this.cwdOf(worktreePath), () => pushArgv(tracking.branch, tracking.upstream !== null), signal, NETWORK_GRACE_MS)
|
|
1554
1787
|
}
|
|
1555
1788
|
|
|
1556
|
-
/** Shared shape for every write op: run it, classify what went wrong.
|
|
1789
|
+
/** Shared shape for every write op: run it, classify what went wrong.
|
|
1790
|
+
*
|
|
1791
|
+
* Takes the DIRECTORY to run in, already resolved: callers carrying
|
|
1792
|
+
* repository-relative pathspecs pass the rooted directory
|
|
1793
|
+
* ({@link GitWorkbenchService.rootedDirOf}); pathspec-free operations may
|
|
1794
|
+
* pass the session's own directory.
|
|
1795
|
+
*/
|
|
1557
1796
|
private async writeOp(
|
|
1558
|
-
|
|
1797
|
+
dir: string,
|
|
1559
1798
|
build: () => readonly string[],
|
|
1560
1799
|
signal: AbortSignal,
|
|
1561
1800
|
graceMs?: number,
|
|
@@ -1568,7 +1807,7 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1568
1807
|
// list or a blank commit message takes.
|
|
1569
1808
|
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
|
|
1570
1809
|
}
|
|
1571
|
-
const result = await this.git(
|
|
1810
|
+
const result = await this.git(dir, argv, signal, graceMs)
|
|
1572
1811
|
const failure = classifyFailure(result.exitCode, result.stderr, result.stdout)
|
|
1573
1812
|
if (failure === null) return { ok: true, output: result.stdout.trim().slice(-1000) }
|
|
1574
1813
|
// The classification is a hint; the real text rides along beside it, because
|
|
@@ -1637,10 +1876,25 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
1637
1876
|
}
|
|
1638
1877
|
|
|
1639
1878
|
/** Resolve the repo root for a directory (null when not a git repo). Always forward slashes. */
|
|
1640
|
-
private
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1879
|
+
private repoRootOf(cwd: string, signal: AbortSignal): Promise<string | null> {
|
|
1880
|
+
return resolveRepoRoot((dir, argv) => this.git(dir, argv, signal), cwd)
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* The directory to run git in and join paths against for a session's
|
|
1885
|
+
* `worktreePath`: the repository ROOT, falling back to the directory
|
|
1886
|
+
* itself outside a repository (the caller's own git run then fails the
|
|
1887
|
+
* way it always did, and that error is the honest one to show).
|
|
1888
|
+
*
|
|
1889
|
+
* The drawer's paths are repository-relative — porcelain status and
|
|
1890
|
+
* `--numstat` print them that way wherever they run — while pathspecs,
|
|
1891
|
+
* `:path` revisions, `hash-object` arguments and `join(dir, path)` all
|
|
1892
|
+
* resolve against the directory a command runs in. Those two halves only
|
|
1893
|
+
* agree at the root, so every method that carries a path runs there.
|
|
1894
|
+
* `repo-root.ts` tells the whole story, with the probes that caught it.
|
|
1895
|
+
*/
|
|
1896
|
+
private rootedDirOf(worktreePath: string | undefined, signal: AbortSignal): Promise<string> {
|
|
1897
|
+
return rootedDir((dir, argv) => this.git(dir, argv, signal), this.cwdOf(worktreePath))
|
|
1644
1898
|
}
|
|
1645
1899
|
|
|
1646
1900
|
/**
|
|
@@ -1718,14 +1972,14 @@ interface UntrackedMeasure {
|
|
|
1718
1972
|
* count newlines is the expensive half of this pass, and most untracked files
|
|
1719
1973
|
* never reach the bundled diff. Never throws; an unreadable file reports zero
|
|
1720
1974
|
* lines and nothing to diff.
|
|
1721
|
-
* @param
|
|
1975
|
+
* @param root - repository root the path is relative to.
|
|
1722
1976
|
* @param path - repository-relative file path.
|
|
1723
1977
|
* @returns the file's line count, binary flag, and whether a diff may be built.
|
|
1724
1978
|
*/
|
|
1725
|
-
async function measureUntracked(
|
|
1979
|
+
async function measureUntracked(root: string, path: string): Promise<UntrackedMeasure> {
|
|
1726
1980
|
let bytes: Buffer
|
|
1727
1981
|
try {
|
|
1728
|
-
bytes = await readFile(
|
|
1982
|
+
bytes = await readFile(resolveInside(root, path))
|
|
1729
1983
|
} catch {
|
|
1730
1984
|
return { lineCount: 0, binary: false, diffable: false }
|
|
1731
1985
|
}
|
|
@@ -1742,16 +1996,16 @@ async function measureUntracked(cwd: string, path: string): Promise<UntrackedMea
|
|
|
1742
1996
|
*
|
|
1743
1997
|
* `git diff --no-index /dev/null <f>` is NOT used: on Windows git resolves
|
|
1744
1998
|
* `/dev/null` as a repo-relative path. Never throws.
|
|
1745
|
-
* @param
|
|
1999
|
+
* @param root - repository root the path is relative to.
|
|
1746
2000
|
* @param path - repository-relative file path.
|
|
1747
2001
|
* @param byteCap - refuse files larger than this; defaults to the stats
|
|
1748
2002
|
* payload's budget, which `fileSides` raises to its own.
|
|
1749
2003
|
* @returns the segment, or null when the file is missing, binary, or oversized.
|
|
1750
2004
|
*/
|
|
1751
|
-
async function untrackedSegment(
|
|
2005
|
+
async function untrackedSegment(root: string, path: string, byteCap: number = UNTRACKED_FILE_BYTE_CAP): Promise<string | null> {
|
|
1752
2006
|
let bytes: Buffer
|
|
1753
2007
|
try {
|
|
1754
|
-
bytes = await readFile(
|
|
2008
|
+
bytes = await readFile(resolveInside(root, path))
|
|
1755
2009
|
} catch {
|
|
1756
2010
|
return null
|
|
1757
2011
|
}
|