@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/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,16 +86,19 @@ 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';
|
|
94
|
+
import { resolveRepoRoot, rootedDir } from './repo-root.js';
|
|
92
95
|
import { diffTooLarge, targetTooLarge, SIDE_BYTE_CAP, SIDE_LINE_CAP } from './side-guard.js';
|
|
93
96
|
import { IMAGE_BYTE_CAP, sniffImage } from './image-sniff.js';
|
|
94
97
|
import { LOG_FORMAT, parseLog } from './git-log.js';
|
|
95
98
|
import { emptyLogFilter, logFilterArgs } from './log-filter.js';
|
|
96
99
|
import { parseShortlog } from './shortlog.js';
|
|
97
100
|
import { isBlankEntry, loadStyle, sanitizeEntry, stylePath, } from './style-store.js';
|
|
98
|
-
import { bindingsPath, findRegisteredWorktree, isRefName, loadBindings, parseWorktreeList, sanitizeName, saveBindings, worktreeDir, } from './worktree.js';
|
|
101
|
+
import { bindingNotice, bindingsPath, findRegisteredWorktree, isRefName, lineageEdgeOf, loadBindings, parseWorktreeList, resolveEffectiveBinding, sanitizeName, saveBindings, worktreeDir, } from './worktree.js';
|
|
99
102
|
/** Cap the bundled unified diff so a huge change cannot blow the RPC response. */
|
|
100
103
|
const DIFF_CHAR_CAP = 400_000;
|
|
101
104
|
/** Untracked files larger than this are listed + counted but never diffed. */
|
|
@@ -120,6 +123,11 @@ const SHORTLOG_CAP = 500;
|
|
|
120
123
|
/** Path list cap for the picker: a monorepo can outrun any popup; past this
|
|
121
124
|
* the tree is cut and the truncation reported, never silent. */
|
|
122
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;
|
|
123
131
|
/**
|
|
124
132
|
* Most branch names sent to the browser. `worktreeStatus` is polled, so an
|
|
125
133
|
* unbounded list would repeat on the wire every few seconds; the picker reports
|
|
@@ -163,6 +171,7 @@ let GitWorkbenchService = (() => {
|
|
|
163
171
|
let _commits_decorators;
|
|
164
172
|
let _authors_decorators;
|
|
165
173
|
let _repoTree_decorators;
|
|
174
|
+
let _ignoredDir_decorators;
|
|
166
175
|
let _compareRefs_decorators;
|
|
167
176
|
let _sessionWorktree_decorators;
|
|
168
177
|
let _worktreeEnter_decorators;
|
|
@@ -193,6 +202,7 @@ let GitWorkbenchService = (() => {
|
|
|
193
202
|
_commits_decorators = [Remote('commits')];
|
|
194
203
|
_authors_decorators = [Remote('authors')];
|
|
195
204
|
_repoTree_decorators = [Remote('repoTree')];
|
|
205
|
+
_ignoredDir_decorators = [Remote('ignoredDir')];
|
|
196
206
|
_compareRefs_decorators = [Remote('compareRefs')];
|
|
197
207
|
_sessionWorktree_decorators = [Remote('sessionWorktree')];
|
|
198
208
|
_worktreeEnter_decorators = [Remote('worktreeEnter')];
|
|
@@ -220,6 +230,7 @@ let GitWorkbenchService = (() => {
|
|
|
220
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);
|
|
221
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);
|
|
222
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);
|
|
223
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);
|
|
224
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);
|
|
225
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);
|
|
@@ -249,10 +260,32 @@ let GitWorkbenchService = (() => {
|
|
|
249
260
|
* mutation updates this inside the same critical section that writes it.
|
|
250
261
|
*/
|
|
251
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();
|
|
252
272
|
constructor(ctx) {
|
|
253
273
|
super(ctx, 'gitWorkbench');
|
|
254
274
|
this.registerWorktreeTools(ctx);
|
|
255
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
|
+
});
|
|
256
289
|
// Hydrate the mirror through the same queue as the mutations, so a binding
|
|
257
290
|
// written before hydration finishes is not overwritten by the stale read.
|
|
258
291
|
// A failed read leaves the mirror empty: sessions then get no standing
|
|
@@ -284,17 +317,20 @@ let GitWorkbenchService = (() => {
|
|
|
284
317
|
name: 'worktree:binding',
|
|
285
318
|
order: 115,
|
|
286
319
|
text: (context) => {
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
if (binding === undefined)
|
|
320
|
+
const session = context.agent?.session;
|
|
321
|
+
if (session === undefined)
|
|
290
322
|
return '';
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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);
|
|
298
334
|
},
|
|
299
335
|
});
|
|
300
336
|
});
|
|
@@ -331,6 +367,7 @@ let GitWorkbenchService = (() => {
|
|
|
331
367
|
ok: { type: 'boolean' },
|
|
332
368
|
error: { type: 'string' },
|
|
333
369
|
binding: { oneOf: [{ type: 'null' }, { type: 'object', additionalProperties: true }] },
|
|
370
|
+
bindingInherited: { type: 'boolean' },
|
|
334
371
|
worktrees: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
335
372
|
branches: { type: 'array', items: { type: 'string' } },
|
|
336
373
|
branchesTruncated: { type: 'boolean' },
|
|
@@ -374,7 +411,8 @@ let GitWorkbenchService = (() => {
|
|
|
374
411
|
}));
|
|
375
412
|
ctx.tools.register(defineTool({
|
|
376
413
|
name: 'worktree_status',
|
|
377
|
-
description: 'Show this session\'s
|
|
414
|
+
description: 'Show this session\'s worktree (its own, or the one its parent session entered — bindingInherited says which) '
|
|
415
|
+
+ 'and the repository\'s existing worktrees with branches.',
|
|
378
416
|
parameters: {},
|
|
379
417
|
output: output(STATUS_SCHEMA),
|
|
380
418
|
execute: async (_args, exec) => {
|
|
@@ -401,10 +439,20 @@ let GitWorkbenchService = (() => {
|
|
|
401
439
|
// the browser. The tree and the counters need only `status` and
|
|
402
440
|
// `--numstat`, both of which stay around 110-140ms at that size, and the
|
|
403
441
|
// pane already fetches the file it is actually showing through `fileDiff`.
|
|
404
|
-
|
|
442
|
+
// The fourth read resolves where the rest of this method must run: the
|
|
443
|
+
// repository ROOT. The paths this method handles are repository-relative
|
|
444
|
+
// (that is what porcelain status prints wherever it runs), so the
|
|
445
|
+
// untracked files read below must be joined against the root — a session
|
|
446
|
+
// opened at `repo/server` would otherwise look for `repo/server/server/f`.
|
|
447
|
+
// The three git reads themselves are cwd-INSENSITIVE (their output names
|
|
448
|
+
// paths from the repository root whatever directory they run in), so they
|
|
449
|
+
// stay on the session's own directory and the root resolve rides along in
|
|
450
|
+
// the same batch: the polled call pays for it in wall time not at all.
|
|
451
|
+
const [statusInfo, numstat, revInfo, root] = await Promise.all([
|
|
405
452
|
this.git(cwd, ['status', '--porcelain=v1', '--branch', '--untracked-files=all'], signal),
|
|
406
453
|
this.git(cwd, ['diff', 'HEAD', '--numstat'], signal),
|
|
407
454
|
this.git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], signal),
|
|
455
|
+
this.rootedDirOf(worktreePath, signal),
|
|
408
456
|
]);
|
|
409
457
|
if (statusInfo.exitCode !== 0) {
|
|
410
458
|
const detail = statusInfo.stderr.length > 0 ? `: ${statusInfo.stderr}` : '';
|
|
@@ -418,7 +466,7 @@ let GitWorkbenchService = (() => {
|
|
|
418
466
|
// segments into the payload — is gone with the payload itself; `fileDiff`
|
|
419
467
|
// synthesizes the one segment the reader has actually opened.
|
|
420
468
|
const untracked = files.filter(file => file.status === 'untracked');
|
|
421
|
-
const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(
|
|
469
|
+
const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(root, file.path));
|
|
422
470
|
for (const [index, file] of untracked.entries()) {
|
|
423
471
|
const measure = measured[index];
|
|
424
472
|
file.addedLines = measure.lineCount;
|
|
@@ -485,41 +533,46 @@ let GitWorkbenchService = (() => {
|
|
|
485
533
|
* Plain-identifier params, signal last.
|
|
486
534
|
*/
|
|
487
535
|
async fileDiff(worktreePath, path, commit, base, head, signal) {
|
|
488
|
-
const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd();
|
|
489
536
|
if (typeof path !== 'string' || path.length === 0)
|
|
490
537
|
return { diff: '' };
|
|
538
|
+
// The repository root, not the session's directory: the pathspec below
|
|
539
|
+
// is repository-relative, and git resolves pathspecs against the cwd —
|
|
540
|
+
// from a subdirectory `diff HEAD -- server/f` looks for
|
|
541
|
+
// `server/server/f`, matches nothing, and reports an EMPTY diff with
|
|
542
|
+
// exit 0. (repo-root.ts holds the whole story.)
|
|
543
|
+
const root = await this.rootedDirOf(worktreePath, signal);
|
|
491
544
|
if (typeof base === 'string' && base.length > 0 && typeof head === 'string' && head.length > 0) {
|
|
492
545
|
if (!isRefName(base) || !isRefName(head))
|
|
493
546
|
return { diff: '' };
|
|
494
|
-
const ranged = await this.git(
|
|
547
|
+
const ranged = await this.git(root, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal);
|
|
495
548
|
if (ranged.exitCode === 0)
|
|
496
549
|
return { diff: ranged.stdout };
|
|
497
550
|
// Unrelated histories have no merge base for `A...B` to diff from; the
|
|
498
551
|
// two-tip diff still answers what differs, exactly as `compareRefs` does.
|
|
499
552
|
if (!isNoMergeBaseError(ranged.stderr))
|
|
500
553
|
return { diff: '' };
|
|
501
|
-
const tips = await this.git(
|
|
554
|
+
const tips = await this.git(root, ['diff', '--no-renames', base, head, '--', path], signal);
|
|
502
555
|
return { diff: tips.exitCode === 0 ? tips.stdout : '' };
|
|
503
556
|
}
|
|
504
557
|
if (typeof commit === 'string' && commit.length > 0) {
|
|
505
558
|
if (!COMMIT_HASH.test(commit))
|
|
506
559
|
return { diff: '' };
|
|
507
|
-
const key = cacheKey(
|
|
560
|
+
const key = cacheKey(root, commit, path);
|
|
508
561
|
const cached = this.commitDiffCache.get(key);
|
|
509
562
|
if (cached !== undefined)
|
|
510
563
|
return { diff: cached };
|
|
511
564
|
// `--first-parent` for the same reason as in `commitStats`: without it a
|
|
512
565
|
// merge commit has no diff to show and the pane opens empty.
|
|
513
|
-
const shown = await this.git(
|
|
566
|
+
const shown = await this.git(root, ['show', commit, '--first-parent', '--format=', '--no-renames', '--', path], signal);
|
|
514
567
|
if (shown.exitCode !== 0)
|
|
515
568
|
return { diff: '' };
|
|
516
569
|
this.commitDiffCache.set(key, shown.stdout);
|
|
517
570
|
return { diff: shown.stdout };
|
|
518
571
|
}
|
|
519
|
-
const tracked = await this.git(
|
|
572
|
+
const tracked = await this.git(root, ['diff', 'HEAD', '--', path], signal);
|
|
520
573
|
if (tracked.stdout.trim().length > 0)
|
|
521
574
|
return { diff: tracked.stdout };
|
|
522
|
-
return { diff: await untrackedSegment(
|
|
575
|
+
return { diff: await untrackedSegment(root, path) ?? '' };
|
|
523
576
|
}
|
|
524
577
|
/**
|
|
525
578
|
* One layer of one file for the side-by-side diff pane: the layer's
|
|
@@ -534,7 +587,8 @@ let GitWorkbenchService = (() => {
|
|
|
534
587
|
* nothing for them and the unstaged layer falls back to the synthesized
|
|
535
588
|
* new-file segment `fileDiff` already uses.
|
|
536
589
|
*
|
|
537
|
-
* @param worktreePath - directory
|
|
590
|
+
* @param worktreePath - directory the session opened; git runs at its
|
|
591
|
+
* repository root. Empty falls back to the host cwd.
|
|
538
592
|
* @param path - repository-relative path, as the drawer lists it.
|
|
539
593
|
* @param layer - `unstaged` (index→worktree) or `staged` (HEAD→index).
|
|
540
594
|
* @param signal - abort signal.
|
|
@@ -543,21 +597,28 @@ let GitWorkbenchService = (() => {
|
|
|
543
597
|
if (layer !== 'unstaged' && layer !== 'staged') {
|
|
544
598
|
throw new Error(`unknown layer "${String(layer)}"; expected 'unstaged' or 'staged'`);
|
|
545
599
|
}
|
|
546
|
-
if (typeof path !== 'string' || !
|
|
600
|
+
if (typeof path !== 'string' || !isSafeRelativePath(path)) {
|
|
547
601
|
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
|
|
548
602
|
}
|
|
549
|
-
|
|
603
|
+
// Repository root, not the session's directory: every path below is
|
|
604
|
+
// repository-relative (pathspecs resolve against the cwd, and so does
|
|
605
|
+
// the file read for the editor's target). See repo-root.ts.
|
|
606
|
+
const root = await this.rootedDirOf(worktreePath, signal);
|
|
607
|
+
// The unstaged layer READS THE FILE, so its absolute path is built by the
|
|
608
|
+
// lock rather than by a join — see path-lock.ts. Done here, once, so the
|
|
609
|
+
// one place that resolves a client path is visible in this method.
|
|
550
610
|
return layer === 'unstaged'
|
|
551
|
-
? await this.unstagedSides(
|
|
552
|
-
: await this.stagedSides(
|
|
611
|
+
? await this.unstagedSides(root, path, resolveInside(root, path), signal)
|
|
612
|
+
: await this.stagedSides(root, path, signal);
|
|
553
613
|
}
|
|
554
|
-
/** The unstaged layer: diff index→worktree, target = the working-tree file.
|
|
555
|
-
|
|
614
|
+
/** The unstaged layer: diff index→worktree, target = the working-tree file.
|
|
615
|
+
* `full` is that file's absolute path, already through the lock. */
|
|
616
|
+
async unstagedSides(root, path, full, signal) {
|
|
556
617
|
// Size guard first, off the stat rather than a read: declining a file past
|
|
557
618
|
// the cap must not mean loading a pathological one whole first. Bytes are
|
|
558
619
|
// all a stat knows; the line half of the guard needs the read below.
|
|
559
620
|
try {
|
|
560
|
-
const info = await stat(
|
|
621
|
+
const info = await stat(full);
|
|
561
622
|
if (info.isFile() && targetTooLarge(info.size, 0))
|
|
562
623
|
return { ...emptySides(), tooLarge: true };
|
|
563
624
|
}
|
|
@@ -566,20 +627,20 @@ let GitWorkbenchService = (() => {
|
|
|
566
627
|
}
|
|
567
628
|
let bytes = null;
|
|
568
629
|
try {
|
|
569
|
-
bytes = await readFile(
|
|
630
|
+
bytes = await readFile(full);
|
|
570
631
|
}
|
|
571
632
|
catch {
|
|
572
633
|
bytes = null;
|
|
573
634
|
}
|
|
574
635
|
if (bytes !== null && isBinaryPrefix(bytes, BINARY_SNIFF_BYTES)) {
|
|
575
|
-
return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(
|
|
636
|
+
return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(root, path, signal) };
|
|
576
637
|
}
|
|
577
638
|
if (bytes !== null && targetTooLarge(bytes.length, countBufferLines(bytes))) {
|
|
578
639
|
return { ...emptySides(), tooLarge: true };
|
|
579
640
|
}
|
|
580
|
-
const diff = await this.layerDiffText(
|
|
641
|
+
const diff = await this.layerDiffText(root, path, 'unstaged', signal);
|
|
581
642
|
if (binaryDiffOutput(diff)) {
|
|
582
|
-
return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(
|
|
643
|
+
return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(root, path, signal) };
|
|
583
644
|
}
|
|
584
645
|
// The diff half of the guard, AFTER the artifact exists: the target-side
|
|
585
646
|
// checks above cannot see a worktree-deleted large file (no target to
|
|
@@ -591,7 +652,7 @@ let GitWorkbenchService = (() => {
|
|
|
591
652
|
diff,
|
|
592
653
|
diffSha: sha1Hex(diff),
|
|
593
654
|
targetText: bytes === null ? '' : bytes.toString('utf8'),
|
|
594
|
-
targetSha: bytes === null ? '' : await this.worktreeBlobSha(
|
|
655
|
+
targetSha: bytes === null ? '' : await this.worktreeBlobSha(root, path, signal),
|
|
595
656
|
binary: false,
|
|
596
657
|
tooLarge: false,
|
|
597
658
|
// Only the unstaged layer can report this, and only it needs to: the
|
|
@@ -600,11 +661,11 @@ let GitWorkbenchService = (() => {
|
|
|
600
661
|
};
|
|
601
662
|
}
|
|
602
663
|
/** The staged layer: diff HEAD→index, target = the index blob. */
|
|
603
|
-
async stagedSides(
|
|
664
|
+
async stagedSides(root, path, signal) {
|
|
604
665
|
// `:path` resolves the stage-0 index entry: the target text when it exists,
|
|
605
666
|
// and a failed resolution (no entry) is the empty target, not an error.
|
|
606
|
-
const shown = await this.git(
|
|
607
|
-
const sha = (await this.git(
|
|
667
|
+
const shown = await this.git(root, ['show', `:${path}`], signal);
|
|
668
|
+
const sha = (await this.git(root, ['rev-parse', '--verify', '--quiet', `:${path}`], signal)).stdout.trim();
|
|
608
669
|
const targetText = shown.exitCode === 0 ? shown.stdout : '';
|
|
609
670
|
const targetBytes = Buffer.from(targetText, 'utf8');
|
|
610
671
|
if (targetText.length > 0 && isBinaryPrefix(targetBytes, BINARY_SNIFF_BYTES)) {
|
|
@@ -613,7 +674,7 @@ let GitWorkbenchService = (() => {
|
|
|
613
674
|
if (targetTooLarge(targetBytes.length, countBufferLines(targetBytes))) {
|
|
614
675
|
return { ...emptySides(), tooLarge: true, targetSha: sha };
|
|
615
676
|
}
|
|
616
|
-
const diff = await this.layerDiffText(
|
|
677
|
+
const diff = await this.layerDiffText(root, path, 'staged', signal);
|
|
617
678
|
if (binaryDiffOutput(diff)) {
|
|
618
679
|
return { ...emptySides(), binary: true, targetSha: sha };
|
|
619
680
|
}
|
|
@@ -634,8 +695,8 @@ let GitWorkbenchService = (() => {
|
|
|
634
695
|
};
|
|
635
696
|
}
|
|
636
697
|
/** Blob sha of the working-tree file, '' when git cannot hash it. */
|
|
637
|
-
async worktreeBlobSha(
|
|
638
|
-
const hashed = await this.git(
|
|
698
|
+
async worktreeBlobSha(root, path, signal) {
|
|
699
|
+
const hashed = await this.git(root, ['hash-object', '--', path], signal);
|
|
639
700
|
return hashed.exitCode === 0 ? hashed.stdout.trim() : '';
|
|
640
701
|
}
|
|
641
702
|
/**
|
|
@@ -647,19 +708,19 @@ let GitWorkbenchService = (() => {
|
|
|
647
708
|
* its own fresh fetch, so the two ends of the stale comparison are over the
|
|
648
709
|
* SAME text by construction, not by two fetch sites staying in step.
|
|
649
710
|
*/
|
|
650
|
-
async layerDiffText(
|
|
711
|
+
async layerDiffText(root, path, layer, signal) {
|
|
651
712
|
if (layer === 'staged') {
|
|
652
|
-
return (await this.git(
|
|
713
|
+
return (await this.git(root, ['diff', '--cached', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout;
|
|
653
714
|
}
|
|
654
|
-
const diff = (await this.git(
|
|
715
|
+
const diff = (await this.git(root, ['diff', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout;
|
|
655
716
|
// Untracked files have no index entry, so `git diff` reports nothing for
|
|
656
717
|
// them; the synthesized new-file segment is their unstaged diff. The
|
|
657
718
|
// trailing newline is added here because every diff git prints carries
|
|
658
719
|
// one: this text is what `applyBlocks` re-emits as a patch file, and a
|
|
659
720
|
// patch whose last line has no LF is "corrupt patch" to `git apply` —
|
|
660
721
|
// which is exactly what a real-git drive of the untracked path caught.
|
|
661
|
-
if (diff.length === 0 && await this.isUntracked(
|
|
662
|
-
const segment = await untrackedSegment(
|
|
722
|
+
if (diff.length === 0 && await this.isUntracked(root, path, signal)) {
|
|
723
|
+
const segment = await untrackedSegment(root, path, SIDE_BYTE_CAP);
|
|
663
724
|
return segment === null ? '' : `${segment}\n`;
|
|
664
725
|
}
|
|
665
726
|
return diff;
|
|
@@ -677,7 +738,7 @@ let GitWorkbenchService = (() => {
|
|
|
677
738
|
* shared with `fileSides`, and the tmpfile pair. Every failure comes back as
|
|
678
739
|
* a result — the method never throws across the RPC boundary.
|
|
679
740
|
*
|
|
680
|
-
* @param worktreePath - directory
|
|
741
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
681
742
|
* @param path - repository-relative path, as the drawer lists it.
|
|
682
743
|
* @param layer - the layer the block was selected on; the mode decides which
|
|
683
744
|
* one that may be.
|
|
@@ -688,14 +749,16 @@ let GitWorkbenchService = (() => {
|
|
|
688
749
|
* @param signal - abort signal.
|
|
689
750
|
*/
|
|
690
751
|
async applyBlocks(worktreePath, path, layer, diffSha, lines, mode, signal) {
|
|
691
|
-
|
|
752
|
+
// The repository root — the patch's pathspecs are repository-relative
|
|
753
|
+
// and `git apply` resolves them against the cwd (repo-root.ts).
|
|
754
|
+
const root = await this.rootedDirOf(worktreePath, signal);
|
|
692
755
|
const io = {
|
|
693
756
|
git: (dir, argv) => this.git(dir, argv, signal),
|
|
694
|
-
layerDiff: (file, which) => this.layerDiffText(
|
|
757
|
+
layerDiff: (file, which) => this.layerDiffText(root, file, which === 'staged' ? 'staged' : 'unstaged', signal),
|
|
695
758
|
writePatch: writeTmpPatch,
|
|
696
759
|
dropPatch: dropTmpPatch,
|
|
697
760
|
};
|
|
698
|
-
return runApplyBlocks(io,
|
|
761
|
+
return runApplyBlocks(io, root, path, layer, String(diffSha ?? ''), lines, mode);
|
|
699
762
|
}
|
|
700
763
|
/**
|
|
701
764
|
* Save the side-by-side editor's buffer over the working-tree file it was
|
|
@@ -716,7 +779,7 @@ let GitWorkbenchService = (() => {
|
|
|
716
779
|
* check and this method are one thing, and no unchecked write RPC exists or
|
|
717
780
|
* may be added in this plugin.
|
|
718
781
|
*
|
|
719
|
-
* @param worktreePath - directory
|
|
782
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
720
783
|
* @param path - repository-relative path, as the drawer lists it.
|
|
721
784
|
* @param text - the editor buffer, verbatim; written as bytes (LF as given).
|
|
722
785
|
* @param expectedSha - the `targetSha` the buffer was opened with ('' when
|
|
@@ -725,7 +788,10 @@ let GitWorkbenchService = (() => {
|
|
|
725
788
|
* @param signal - abort signal.
|
|
726
789
|
*/
|
|
727
790
|
async writeChecked(worktreePath, path, text, expectedSha, signal) {
|
|
728
|
-
|
|
791
|
+
// The repository root — the save target joins the same base every
|
|
792
|
+
// other path here is relative to, and that base is the root, not the
|
|
793
|
+
// directory the session opened (repo-root.ts).
|
|
794
|
+
const root = await this.rootedDirOf(worktreePath, signal);
|
|
729
795
|
const io = {
|
|
730
796
|
git: (dir, argv) => this.git(dir, argv, signal),
|
|
731
797
|
exists: async (p) => {
|
|
@@ -743,7 +809,7 @@ let GitWorkbenchService = (() => {
|
|
|
743
809
|
remove: async (p) => { await rm(p, { force: true }); },
|
|
744
810
|
delay: ms => new Promise(resolve => { setTimeout(resolve, ms); }),
|
|
745
811
|
};
|
|
746
|
-
return runWriteChecked(io,
|
|
812
|
+
return runWriteChecked(io, root, path, typeof text === 'string' ? text : '', typeof expectedSha === 'string' ? expectedSha : '');
|
|
747
813
|
}
|
|
748
814
|
/**
|
|
749
815
|
* One file's provenance, line by line — the side pane's blame gutter
|
|
@@ -754,7 +820,7 @@ let GitWorkbenchService = (() => {
|
|
|
754
820
|
* rather than missing. Read-only, no index or worktree is touched, so this
|
|
755
821
|
* needs none of the confirmation machinery the write paths carry.
|
|
756
822
|
*
|
|
757
|
-
* @param worktreePath - directory
|
|
823
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
758
824
|
* @param path - repository-relative path, as the drawer lists it.
|
|
759
825
|
* @param signal - abort signal.
|
|
760
826
|
*/
|
|
@@ -762,10 +828,13 @@ let GitWorkbenchService = (() => {
|
|
|
762
828
|
if (typeof path !== 'string' || !isSafePathArg(path)) {
|
|
763
829
|
return { lines: [], truncated: false, error: `unsafe path argument: ${JSON.stringify(path)}` };
|
|
764
830
|
}
|
|
765
|
-
|
|
831
|
+
// The repository root: blame's pathspec resolves against the cwd, and
|
|
832
|
+
// from a subdirectory the repository-relative path doubles up
|
|
833
|
+
// (`server/server/f`) into a fatal "no such path" (repo-root.ts).
|
|
834
|
+
const root = await this.rootedDirOf(worktreePath, signal);
|
|
766
835
|
// `--` keeps a path that looks like a revision from being read as one, as
|
|
767
836
|
// every other pathspec in this plugin does.
|
|
768
|
-
const run = await this.git(
|
|
837
|
+
const run = await this.git(root, ['blame', '--line-porcelain', '--', path], signal);
|
|
769
838
|
if (run.exitCode !== 0) {
|
|
770
839
|
// An untracked file has no blame, and git says so; that message is the
|
|
771
840
|
// honest thing to show rather than an empty gutter.
|
|
@@ -799,15 +868,19 @@ let GitWorkbenchService = (() => {
|
|
|
799
868
|
*
|
|
800
869
|
* Read-only — nothing is spawned, nothing is written.
|
|
801
870
|
*
|
|
802
|
-
* @param worktreePath - directory
|
|
871
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
803
872
|
* @param path - repository-relative path, as the drawer lists it.
|
|
804
873
|
* @param signal - abort signal.
|
|
805
874
|
*/
|
|
806
875
|
async fileImage(worktreePath, path, signal) {
|
|
807
|
-
if (typeof path !== 'string' || !
|
|
876
|
+
if (typeof path !== 'string' || !isSafeRelativePath(path)) {
|
|
808
877
|
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
|
|
809
878
|
}
|
|
810
|
-
|
|
879
|
+
// The repository root — the image is read from disk at the same base
|
|
880
|
+
// every other path in this plugin is relative to (repo-root.ts) — and
|
|
881
|
+
// through the lock, because this is a raw read with no git in the way
|
|
882
|
+
// to refuse a path that leaves the repository (path-lock.ts).
|
|
883
|
+
const full = resolveInside(await this.rootedDirOf(worktreePath, signal), path);
|
|
811
884
|
let size = 0;
|
|
812
885
|
try {
|
|
813
886
|
const info = await stat(full);
|
|
@@ -848,11 +921,11 @@ let GitWorkbenchService = (() => {
|
|
|
848
921
|
* Those are the files whose diff has to be synthesized rather than asked of
|
|
849
922
|
* `git diff`, which reports nothing for them.
|
|
850
923
|
*/
|
|
851
|
-
async isUntracked(
|
|
852
|
-
const listed = await this.git(
|
|
924
|
+
async isUntracked(root, path, signal) {
|
|
925
|
+
const listed = await this.git(root, ['ls-files', '--', path], signal);
|
|
853
926
|
if (listed.exitCode !== 0 || listed.stdout.trim().length > 0)
|
|
854
927
|
return false;
|
|
855
|
-
const head = await this.git(
|
|
928
|
+
const head = await this.git(root, ['rev-parse', '--verify', '--quiet', `HEAD:${path}`], signal);
|
|
856
929
|
return head.exitCode !== 0;
|
|
857
930
|
}
|
|
858
931
|
/**
|
|
@@ -885,11 +958,17 @@ let GitWorkbenchService = (() => {
|
|
|
885
958
|
// empty pane. Against the first parent the answer is well defined and is the
|
|
886
959
|
// useful one: what this merge brought into the branch it landed on. On a
|
|
887
960
|
// single-parent commit the flag is a no-op, byte for byte.
|
|
961
|
+
// The repository root: no pathspec here today, but the cache key below
|
|
962
|
+
// must describe the same repository to a later caller, and running from
|
|
963
|
+
// the root is the one rule every read in this plugin follows
|
|
964
|
+
// (repo-root.ts). Resolved after the cache probe so a hit spawns
|
|
965
|
+
// nothing.
|
|
966
|
+
const root = await this.rootedDirOf(worktreePath, signal);
|
|
888
967
|
const [meta, numstat, nameStatus, patch] = await Promise.all([
|
|
889
|
-
this.git(
|
|
890
|
-
this.git(
|
|
891
|
-
this.git(
|
|
892
|
-
this.git(
|
|
968
|
+
this.git(root, ['show', hash, '--no-patch', `--format=${LOG_FORMAT}`], signal),
|
|
969
|
+
this.git(root, ['show', hash, '--first-parent', '--numstat', '--format=', '--no-renames'], signal),
|
|
970
|
+
this.git(root, ['show', hash, '--first-parent', '--name-status', '--format=', '--no-renames'], signal),
|
|
971
|
+
this.git(root, ['show', hash, '--first-parent', '--format=', '--no-renames'], signal),
|
|
893
972
|
]);
|
|
894
973
|
if (meta.exitCode !== 0) {
|
|
895
974
|
const detail = meta.stderr.length > 0 ? `: ${meta.stderr}` : '';
|
|
@@ -954,6 +1033,10 @@ let GitWorkbenchService = (() => {
|
|
|
954
1033
|
const from = Number.isInteger(skip) && skip >= 0 ? skip : 0;
|
|
955
1034
|
const size = Number.isInteger(limit) && limit > 0 && limit <= HISTORY_PAGE_MAX ? limit : HISTORY_PAGE;
|
|
956
1035
|
const effective = filter ?? emptyLogFilter();
|
|
1036
|
+
// The repository root: a filter's pathspec is repository-relative, and
|
|
1037
|
+
// git resolves pathspecs against the cwd — from a subdirectory a
|
|
1038
|
+
// filtered history silently comes back EMPTY with exit 0 (repo-root.ts).
|
|
1039
|
+
const root = await this.rootedDirOf(worktreePath, signal);
|
|
957
1040
|
// Reading one row beyond the page answers "is there more" without a second
|
|
958
1041
|
// traversal of the log.
|
|
959
1042
|
//
|
|
@@ -966,7 +1049,7 @@ let GitWorkbenchService = (() => {
|
|
|
966
1049
|
//
|
|
967
1050
|
// Filter args go LAST: their segment ends with `--` + pathspecs, and
|
|
968
1051
|
// nothing after that separator may be parsed as a flag.
|
|
969
|
-
const log = await this.git(
|
|
1052
|
+
const log = await this.git(root, ['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`, ...logFilterArgs(effective)], signal);
|
|
970
1053
|
// A bad filter (unparsable regex, invalid date) dies here, and an empty
|
|
971
1054
|
// page is indistinguishable from "no match" unless the failure speaks —
|
|
972
1055
|
// §6.13: the exit code + stderr tail is the only honest answer.
|
|
@@ -1001,20 +1084,112 @@ let GitWorkbenchService = (() => {
|
|
|
1001
1084
|
}
|
|
1002
1085
|
/**
|
|
1003
1086
|
* Every file path on HEAD — the filter popup's path picker, aggregated into
|
|
1004
|
-
* a directory tree client-side
|
|
1087
|
+
* a directory tree client-side — plus the ignored-but-present entries the
|
|
1088
|
+
* Files tab browses.
|
|
1089
|
+
*
|
|
1090
|
+
* The ignored listing is `ls-files --others --ignored --exclude-standard
|
|
1091
|
+
* --directory`: every ignored FILE is listed verbatim (`application-local.yml`
|
|
1092
|
+
* is exactly the file a browser must find and `ls-tree HEAD` cannot), while
|
|
1093
|
+
* every directory a rule ignores as a whole collapses to ONE line with a
|
|
1094
|
+
* trailing slash — `node_modules/` costs one entry, not the ~40k files
|
|
1095
|
+
* inside it. The list therefore scales with the .gitignore's coverage, not
|
|
1096
|
+
* the repository: measured 54 entries / 48ms on this checkout with
|
|
1097
|
+
* node_modules present.
|
|
1005
1098
|
*
|
|
1006
|
-
* `-z` is load-bearing: NUL-separated output is UNQUOTED,
|
|
1007
|
-
* would render non-ASCII names as quoted octal escapes
|
|
1008
|
-
* `core.quotepath` and hand the picker garbage.
|
|
1099
|
+
* `-z` is load-bearing on both spawns: NUL-separated output is UNQUOTED,
|
|
1100
|
+
* while the default would render non-ASCII names as quoted octal escapes
|
|
1101
|
+
* under `core.quotepath` and hand the picker garbage.
|
|
1009
1102
|
* @param worktreePath - worktree whose HEAD is listed; empty falls back to the host cwd.
|
|
1010
1103
|
* @param signal - abort signal.
|
|
1011
1104
|
*/
|
|
1012
1105
|
async repoTree(worktreePath, signal) {
|
|
1013
|
-
|
|
1014
|
-
|
|
1106
|
+
// The repository root: unlike status and numstat, `ls-tree` prints
|
|
1107
|
+
// cwd-RELATIVE paths — from a subdirectory every entry would lose the
|
|
1108
|
+
// `server/` prefix and the picker would feed the log filter pathspecs
|
|
1109
|
+
// that match nothing (repo-root.ts).
|
|
1110
|
+
const root = await this.rootedDirOf(worktreePath, signal);
|
|
1111
|
+
const [res, ignoredRes] = await Promise.all([
|
|
1112
|
+
this.git(root, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal),
|
|
1113
|
+
this.git(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], signal),
|
|
1114
|
+
]);
|
|
1015
1115
|
const all = res.stdout.split('\0').filter(path => path.length > 0);
|
|
1016
1116
|
const truncated = all.length > TREE_PATH_CAP;
|
|
1017
|
-
|
|
1117
|
+
const ignoredAll = ignoredRes.stdout.split('\0').filter(path => path.length > 0);
|
|
1118
|
+
const ignoredTruncated = ignoredAll.length > IGNORED_PATH_CAP;
|
|
1119
|
+
// `ls-tree`'s exit code is deliberately NOT checked: a repository with no
|
|
1120
|
+
// HEAD yet fails it, and "this repository has no files" is the honest
|
|
1121
|
+
// answer there. The ignored listing has no such legitimate failure, so a
|
|
1122
|
+
// non-zero exit is reported rather than served as an empty list — the one
|
|
1123
|
+
// shape that is indistinguishable from a repository ignoring nothing.
|
|
1124
|
+
const ignoredError = ignoredRes.exitCode === 0
|
|
1125
|
+
? undefined
|
|
1126
|
+
: (ignoredRes.stderr.trim() || `git ls-files failed (exit ${ignoredRes.exitCode})`).slice(-500);
|
|
1127
|
+
return {
|
|
1128
|
+
paths: truncated ? all.slice(0, TREE_PATH_CAP) : all,
|
|
1129
|
+
truncated,
|
|
1130
|
+
ignored: ignoredTruncated ? ignoredAll.slice(0, IGNORED_PATH_CAP) : ignoredAll,
|
|
1131
|
+
ignoredTruncated,
|
|
1132
|
+
...(ignoredError !== undefined ? { ignoredError } : {}),
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* One level of one ignored directory, read from the filesystem — the step
|
|
1137
|
+
* behind clicking `node_modules/` open in the Files tab.
|
|
1138
|
+
*
|
|
1139
|
+
* Deliberately NOT a git listing: with `--directory`, a pathspec under an
|
|
1140
|
+
* ignored directory still collapses to that one directory line (probed:
|
|
1141
|
+
* `ls-files --others --ignored --exclude-standard --directory -- 'node_modules/*'`
|
|
1142
|
+
* answers `node_modules/` and nothing else), and dropping `--directory`
|
|
1143
|
+
* would enumerate every file inside at once — the very flood the collapsed
|
|
1144
|
+
* listing exists to avoid. `readdir` is one level by construction and costs
|
|
1145
|
+
* milliseconds. What the entries ARE is inherited anyway: everything below
|
|
1146
|
+
* an ignored directory is ignored by descent, so the browser owes the
|
|
1147
|
+
* reader the directory's contents, not git's opinion of them.
|
|
1148
|
+
*
|
|
1149
|
+
* A directory that vanished between the listing and the click answers
|
|
1150
|
+
* empty: nothing to browse is the honest answer, and the next refresh
|
|
1151
|
+
* drops the row.
|
|
1152
|
+
*
|
|
1153
|
+
* The name says what it is FOR, not what it permits: nothing here checks
|
|
1154
|
+
* that `dir` is ignored, so this lists any directory inside the worktree.
|
|
1155
|
+
* That is the same reach the reader already has through `fileSides` and
|
|
1156
|
+
* `fileImage`, and adding an ignore check would only make the browser ask
|
|
1157
|
+
* git a second question to learn what it already knows from the listing it
|
|
1158
|
+
* was handed. What it does NOT permit is leaving the worktree, which is
|
|
1159
|
+
* what the path lock below is for.
|
|
1160
|
+
* @param worktreePath - worktree the directory lives in; empty falls back to the host cwd.
|
|
1161
|
+
* @param dir - repo-relative directory path, as the collapsed listing named it.
|
|
1162
|
+
* @param signal - abort signal.
|
|
1163
|
+
*/
|
|
1164
|
+
async ignoredDir(worktreePath, dir, signal) {
|
|
1165
|
+
// The same lock every filesystem read in this plugin passes: the browser
|
|
1166
|
+
// is a less trusted source of paths than git's own output, and `readdir`
|
|
1167
|
+
// obeys no repository boundary (path-lock.ts).
|
|
1168
|
+
const target = resolveInside(await this.rootedDirOf(worktreePath, signal), dir);
|
|
1169
|
+
let dirents;
|
|
1170
|
+
try {
|
|
1171
|
+
dirents = await readdir(target, { withFileTypes: true });
|
|
1172
|
+
}
|
|
1173
|
+
catch {
|
|
1174
|
+
return { entries: [], truncated: false };
|
|
1175
|
+
}
|
|
1176
|
+
const plain = dirents
|
|
1177
|
+
.filter(entry => !entry.isSymbolicLink())
|
|
1178
|
+
.map(entry => ({ name: entry.name, dir: entry.isDirectory() }));
|
|
1179
|
+
// A pnpm-style layout makes every package row a symlink into the store;
|
|
1180
|
+
// `withFileTypes` reports the LINK, so a follow-up stat decides whether
|
|
1181
|
+
// the row expands. A broken link reads as a file and simply fails to
|
|
1182
|
+
// open, like any other dangling name.
|
|
1183
|
+
const links = dirents.filter(entry => entry.isSymbolicLink());
|
|
1184
|
+
const resolved = await Promise.all(links.map(async (entry) => {
|
|
1185
|
+
try {
|
|
1186
|
+
return { name: entry.name, dir: (await stat(join(target, entry.name))).isDirectory() };
|
|
1187
|
+
}
|
|
1188
|
+
catch {
|
|
1189
|
+
return { name: entry.name, dir: false };
|
|
1190
|
+
}
|
|
1191
|
+
}));
|
|
1192
|
+
return shapeDirChildren([...plain, ...resolved]);
|
|
1018
1193
|
}
|
|
1019
1194
|
/**
|
|
1020
1195
|
* Compare two refs, in the same {@link WorkbenchStats} shape as every other view.
|
|
@@ -1087,13 +1262,20 @@ let GitWorkbenchService = (() => {
|
|
|
1087
1262
|
files, diff, commits: parseLog(log.stdout),
|
|
1088
1263
|
};
|
|
1089
1264
|
}
|
|
1090
|
-
/**
|
|
1265
|
+
/**
|
|
1266
|
+
* The session's EFFECTIVE worktree binding — its own, else the nearest bound
|
|
1267
|
+
* ancestor's — or nulls when neither exists. This is what the chip follows,
|
|
1268
|
+
* so a subagent session shows the worktree its conversation works in without
|
|
1269
|
+
* ever holding a binding of its own (plain-identifier params; signal last).
|
|
1270
|
+
*/
|
|
1091
1271
|
async sessionWorktree(sessionId, signal) {
|
|
1092
1272
|
if (typeof sessionId !== 'string' || sessionId.length === 0)
|
|
1093
|
-
return { worktreePath: null, name: null };
|
|
1273
|
+
return { worktreePath: null, name: null, inherited: false };
|
|
1094
1274
|
const file = await this.bindingsIo().load();
|
|
1095
|
-
const
|
|
1096
|
-
return
|
|
1275
|
+
const effective = resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id]);
|
|
1276
|
+
return effective === undefined
|
|
1277
|
+
? { worktreePath: null, name: null, inherited: false }
|
|
1278
|
+
: { worktreePath: effective.binding.worktreePath, name: effective.binding.name, inherited: effective.inherited };
|
|
1097
1279
|
}
|
|
1098
1280
|
/** Create (or reuse) a git worktree under `<repoRoot>/.agents/worktrees/` and bind the session to it. */
|
|
1099
1281
|
async worktreeEnter(sessionId, repoPath, name, signal) {
|
|
@@ -1185,8 +1367,15 @@ let GitWorkbenchService = (() => {
|
|
|
1185
1367
|
return this.withBindings(async (io) => {
|
|
1186
1368
|
const file = await io.load();
|
|
1187
1369
|
const binding = file.bindings[sessionId];
|
|
1188
|
-
if (binding === undefined)
|
|
1189
|
-
|
|
1370
|
+
if (binding === undefined) {
|
|
1371
|
+
// A subagent CAN see a worktree in its status while holding no binding
|
|
1372
|
+
// of its own (it works under its parent's). Naming that here keeps the
|
|
1373
|
+
// model from retrying an exit that cannot succeed.
|
|
1374
|
+
const inherited = resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id]);
|
|
1375
|
+
return inherited === undefined
|
|
1376
|
+
? { ok: false, error: 'no worktree binding for this session' }
|
|
1377
|
+
: { 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' };
|
|
1378
|
+
}
|
|
1190
1379
|
if (remove === true) {
|
|
1191
1380
|
const status = await this.git(binding.worktreePath, ['status', '--porcelain'], signal);
|
|
1192
1381
|
if (status.exitCode !== 0) {
|
|
@@ -1217,21 +1406,26 @@ let GitWorkbenchService = (() => {
|
|
|
1217
1406
|
* Branches come back most-recently-committed first. With hundreds of them the
|
|
1218
1407
|
* order is what makes the list usable — the handful anyone is working on sit
|
|
1219
1408
|
* at the top, so the picker is useful before a single character is typed.
|
|
1220
|
-
* @param sessionId - session whose binding is looked up.
|
|
1409
|
+
* @param sessionId - session whose effective binding is looked up.
|
|
1221
1410
|
* @param repoPath - caller's directory, used when the session is unbound.
|
|
1222
1411
|
* @param signal - abort signal.
|
|
1223
|
-
* @returns the binding
|
|
1412
|
+
* @returns the effective binding (with whether an ancestor lent it), the
|
|
1413
|
+
* repository's worktrees, and its local branches.
|
|
1224
1414
|
*/
|
|
1225
1415
|
async worktreeStatus(sessionId, repoPath, signal) {
|
|
1226
1416
|
const file = await this.bindingsIo().load();
|
|
1227
|
-
const
|
|
1417
|
+
const effective = typeof sessionId === 'string' && sessionId.length > 0
|
|
1418
|
+
? resolveEffectiveBinding(sessionId, this.parentOf, id => file.bindings[id])
|
|
1419
|
+
: undefined;
|
|
1420
|
+
const binding = effective?.binding ?? null;
|
|
1421
|
+
const bindingInherited = effective?.inherited ?? false;
|
|
1228
1422
|
// Unbound: list the CALLER's repo. Falling back to the host's launch directory
|
|
1229
1423
|
// would answer about whatever directory dsh was started in, not this session's.
|
|
1230
1424
|
const caller = typeof repoPath === 'string' && repoPath.length > 0 ? repoPath.replace(/\\/g, '/') : process.cwd();
|
|
1231
1425
|
const cwd = binding?.repoRoot ?? caller;
|
|
1232
1426
|
const root = await this.repoRootOf(cwd, signal);
|
|
1233
1427
|
if (root === null)
|
|
1234
|
-
return { binding, worktrees: [], branches: [], branchesTruncated: false };
|
|
1428
|
+
return { binding, bindingInherited, worktrees: [], branches: [], branchesTruncated: false };
|
|
1235
1429
|
const [listed, named] = await Promise.all([
|
|
1236
1430
|
this.git(root, ['worktree', 'list', '--porcelain'], signal),
|
|
1237
1431
|
this.git(root, ['branch', '--sort=-committerdate', '--format=%(refname:short)'], signal),
|
|
@@ -1240,6 +1434,7 @@ let GitWorkbenchService = (() => {
|
|
|
1240
1434
|
const { branches, branchesTruncated } = capBranches(all, BRANCH_LIST_CAP);
|
|
1241
1435
|
return {
|
|
1242
1436
|
binding,
|
|
1437
|
+
bindingInherited,
|
|
1243
1438
|
worktrees: parseWorktreeList(listed.stdout),
|
|
1244
1439
|
branches,
|
|
1245
1440
|
branchesTruncated,
|
|
@@ -1318,22 +1513,28 @@ let GitWorkbenchService = (() => {
|
|
|
1318
1513
|
}
|
|
1319
1514
|
/**
|
|
1320
1515
|
* Add paths to the index.
|
|
1321
|
-
* @param worktreePath - directory
|
|
1516
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
1322
1517
|
* @param paths - repository-relative paths; an empty list is refused rather
|
|
1323
1518
|
* than turned into a whole-tree `git add`.
|
|
1324
1519
|
* @param signal - abort signal.
|
|
1325
1520
|
*/
|
|
1326
1521
|
async stage(worktreePath, paths, signal) {
|
|
1327
|
-
|
|
1522
|
+
// The repository root: `git add` resolves its pathspecs against the cwd,
|
|
1523
|
+
// and from a subdirectory a repository-relative path dies with
|
|
1524
|
+
// "pathspec did not match any files" (repo-root.ts).
|
|
1525
|
+
const root = await this.rootedDirOf(worktreePath, signal);
|
|
1526
|
+
return this.writeOp(root, () => stageArgv(asPathList(paths)), signal);
|
|
1328
1527
|
}
|
|
1329
1528
|
/**
|
|
1330
1529
|
* Remove paths from the index, leaving the working tree untouched.
|
|
1331
|
-
* @param worktreePath - directory
|
|
1530
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
1332
1531
|
* @param paths - repository-relative paths.
|
|
1333
1532
|
* @param signal - abort signal.
|
|
1334
1533
|
*/
|
|
1335
1534
|
async unstage(worktreePath, paths, signal) {
|
|
1336
|
-
|
|
1535
|
+
// Same rule as `stage` — the pathspecs are repository-relative.
|
|
1536
|
+
const root = await this.rootedDirOf(worktreePath, signal);
|
|
1537
|
+
return this.writeOp(root, () => unstageArgv(asPathList(paths)), signal);
|
|
1337
1538
|
}
|
|
1338
1539
|
/**
|
|
1339
1540
|
* What discarding this file WOULD do, without doing it.
|
|
@@ -1344,16 +1545,19 @@ let GitWorkbenchService = (() => {
|
|
|
1344
1545
|
* cannot come back" is exactly the difference the reader is being asked
|
|
1345
1546
|
* about. So the dialog is built from this, read fresh, rather than from the
|
|
1346
1547
|
* row that was clicked.
|
|
1347
|
-
* @param worktreePath - directory
|
|
1548
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
1348
1549
|
* @param path - repository-relative path, as the drawer lists it.
|
|
1349
1550
|
* @param signal - abort signal.
|
|
1350
1551
|
* @returns the effect and whether it is irreversible; `effect` is absent when
|
|
1351
1552
|
* git reports nothing to discard for that path.
|
|
1352
1553
|
*/
|
|
1353
1554
|
async discardPlan(worktreePath, path, signal) {
|
|
1555
|
+
// The repository root — the plan's argv and delete paths are
|
|
1556
|
+
// repository-relative (repo-root.ts).
|
|
1557
|
+
const root = await this.rootedDirOf(worktreePath, signal);
|
|
1354
1558
|
let plan;
|
|
1355
1559
|
try {
|
|
1356
|
-
plan = await this.planDiscard(
|
|
1560
|
+
plan = await this.planDiscard(root, path, signal);
|
|
1357
1561
|
}
|
|
1358
1562
|
catch (error) {
|
|
1359
1563
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
@@ -1378,7 +1582,7 @@ let GitWorkbenchService = (() => {
|
|
|
1378
1582
|
* it. `expectedEffect` is what the reader was shown and agreed to: if the
|
|
1379
1583
|
* file changed underneath the dialog — staged, edited, reverted by someone
|
|
1380
1584
|
* else — the freshly derived effect no longer matches and nothing is done.
|
|
1381
|
-
* @param worktreePath - directory
|
|
1585
|
+
* @param worktreePath - directory the session opened; git runs at its repository root.
|
|
1382
1586
|
* @param path - repository-relative path, as the drawer lists it.
|
|
1383
1587
|
* @param expectedEffect - the effect the confirmation stated; blank skips
|
|
1384
1588
|
* the agreement check, which only the reversible
|
|
@@ -1387,9 +1591,13 @@ let GitWorkbenchService = (() => {
|
|
|
1387
1591
|
* @returns the operation result, with the effect actually carried out.
|
|
1388
1592
|
*/
|
|
1389
1593
|
async discardFile(worktreePath, path, expectedEffect, signal) {
|
|
1594
|
+
// The repository root, resolved ONCE for the plan and the steps alike:
|
|
1595
|
+
// every step's argv and delete path came out of a whole-tree porcelain
|
|
1596
|
+
// status, so they are repository-relative (repo-root.ts).
|
|
1597
|
+
const root = await this.rootedDirOf(worktreePath, signal);
|
|
1390
1598
|
let plan;
|
|
1391
1599
|
try {
|
|
1392
|
-
plan = await this.planDiscard(
|
|
1600
|
+
plan = await this.planDiscard(root, path, signal);
|
|
1393
1601
|
}
|
|
1394
1602
|
catch (error) {
|
|
1395
1603
|
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
|
|
@@ -1405,10 +1613,9 @@ let GitWorkbenchService = (() => {
|
|
|
1405
1613
|
error: `this file changed since you were asked (now: ${plan.effect}); nothing was done`,
|
|
1406
1614
|
};
|
|
1407
1615
|
}
|
|
1408
|
-
const cwd = this.cwdOf(worktreePath);
|
|
1409
1616
|
for (const step of plan.steps) {
|
|
1410
1617
|
if (step.kind === 'git') {
|
|
1411
|
-
const result = await this.git(
|
|
1618
|
+
const result = await this.git(root, step.argv, signal);
|
|
1412
1619
|
const failure = classifyFailure(result.exitCode, result.stderr, result.stdout);
|
|
1413
1620
|
if (failure !== null) {
|
|
1414
1621
|
return { ok: false, failure, error: (result.stderr || result.stdout).trim().slice(-1000) };
|
|
@@ -1416,7 +1623,7 @@ let GitWorkbenchService = (() => {
|
|
|
1416
1623
|
continue;
|
|
1417
1624
|
}
|
|
1418
1625
|
try {
|
|
1419
|
-
await removePathInside(
|
|
1626
|
+
await removePathInside(root, step.path);
|
|
1420
1627
|
}
|
|
1421
1628
|
catch (error) {
|
|
1422
1629
|
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
|
|
@@ -1432,12 +1639,14 @@ let GitWorkbenchService = (() => {
|
|
|
1432
1639
|
* reports `D` plus `??` instead — which plans as "restore one, DELETE the
|
|
1433
1640
|
* other" where the truth is "undo the rename".
|
|
1434
1641
|
*/
|
|
1435
|
-
async planDiscard(
|
|
1642
|
+
async planDiscard(root, path, signal) {
|
|
1436
1643
|
if (typeof path !== 'string' || !isSafePathArg(path)) {
|
|
1437
1644
|
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
|
|
1438
1645
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1646
|
+
// Runs at the repository root the caller resolved: the plan's paths
|
|
1647
|
+
// must agree with the directory its steps execute in, and with the
|
|
1648
|
+
// client's `path`, which the drawer lists repository-relative.
|
|
1649
|
+
const status = await this.git(root, ['status', '--porcelain=v1', '--untracked-files=all'], signal);
|
|
1441
1650
|
if (status.exitCode !== 0) {
|
|
1442
1651
|
throw new Error((status.stderr || status.stdout).trim().slice(-1000) || 'git status failed');
|
|
1443
1652
|
}
|
|
@@ -1451,7 +1660,8 @@ let GitWorkbenchService = (() => {
|
|
|
1451
1660
|
* @param signal - abort signal.
|
|
1452
1661
|
*/
|
|
1453
1662
|
async commit(worktreePath, message, amend, signal) {
|
|
1454
|
-
|
|
1663
|
+
// No pathspec in this argv — the session's own directory suffices.
|
|
1664
|
+
return this.writeOp(this.cwdOf(worktreePath), () => commitArgv(String(message ?? ''), amend === true), signal);
|
|
1455
1665
|
}
|
|
1456
1666
|
/**
|
|
1457
1667
|
* Update remote-tracking refs without touching the working tree.
|
|
@@ -1460,7 +1670,7 @@ let GitWorkbenchService = (() => {
|
|
|
1460
1670
|
* @returns the operation result, plus the divergence the fetch revealed.
|
|
1461
1671
|
*/
|
|
1462
1672
|
async fetch(worktreePath, signal) {
|
|
1463
|
-
const result = await this.writeOp(worktreePath, () => fetchArgv(), signal, NETWORK_GRACE_MS);
|
|
1673
|
+
const result = await this.writeOp(this.cwdOf(worktreePath), () => fetchArgv(), signal, NETWORK_GRACE_MS);
|
|
1464
1674
|
if (!result.ok)
|
|
1465
1675
|
return result;
|
|
1466
1676
|
// The point of fetching is the count it produces, so report it in the same
|
|
@@ -1477,7 +1687,7 @@ let GitWorkbenchService = (() => {
|
|
|
1477
1687
|
*/
|
|
1478
1688
|
async pull(worktreePath, mode, signal) {
|
|
1479
1689
|
const chosen = mode === 'rebase' || mode === 'merge' ? mode : 'ff-only';
|
|
1480
|
-
return this.writeOp(worktreePath, () => pullArgv(chosen), signal, NETWORK_GRACE_MS);
|
|
1690
|
+
return this.writeOp(this.cwdOf(worktreePath), () => pullArgv(chosen), signal, NETWORK_GRACE_MS);
|
|
1481
1691
|
}
|
|
1482
1692
|
/**
|
|
1483
1693
|
* Publish the current branch.
|
|
@@ -1496,10 +1706,16 @@ let GitWorkbenchService = (() => {
|
|
|
1496
1706
|
return { ok: false, failure: 'unknown', error: 'HEAD is detached; nothing to push' };
|
|
1497
1707
|
if (tracking.branch.length === 0)
|
|
1498
1708
|
return { ok: false, failure: 'unknown', error: 'no branch to push' };
|
|
1499
|
-
return this.writeOp(worktreePath, () => pushArgv(tracking.branch, tracking.upstream !== null), signal, NETWORK_GRACE_MS);
|
|
1709
|
+
return this.writeOp(this.cwdOf(worktreePath), () => pushArgv(tracking.branch, tracking.upstream !== null), signal, NETWORK_GRACE_MS);
|
|
1500
1710
|
}
|
|
1501
|
-
/** Shared shape for every write op: run it, classify what went wrong.
|
|
1502
|
-
|
|
1711
|
+
/** Shared shape for every write op: run it, classify what went wrong.
|
|
1712
|
+
*
|
|
1713
|
+
* Takes the DIRECTORY to run in, already resolved: callers carrying
|
|
1714
|
+
* repository-relative pathspecs pass the rooted directory
|
|
1715
|
+
* ({@link GitWorkbenchService.rootedDirOf}); pathspec-free operations may
|
|
1716
|
+
* pass the session's own directory.
|
|
1717
|
+
*/
|
|
1718
|
+
async writeOp(dir, build, signal, graceMs) {
|
|
1503
1719
|
let argv;
|
|
1504
1720
|
try {
|
|
1505
1721
|
argv = build();
|
|
@@ -1509,7 +1725,7 @@ let GitWorkbenchService = (() => {
|
|
|
1509
1725
|
// list or a blank commit message takes.
|
|
1510
1726
|
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
|
|
1511
1727
|
}
|
|
1512
|
-
const result = await this.git(
|
|
1728
|
+
const result = await this.git(dir, argv, signal, graceMs);
|
|
1513
1729
|
const failure = classifyFailure(result.exitCode, result.stderr, result.stdout);
|
|
1514
1730
|
if (failure === null)
|
|
1515
1731
|
return { ok: true, output: result.stdout.trim().slice(-1000) };
|
|
@@ -1567,11 +1783,24 @@ let GitWorkbenchService = (() => {
|
|
|
1567
1783
|
return run;
|
|
1568
1784
|
}
|
|
1569
1785
|
/** Resolve the repo root for a directory (null when not a git repo). Always forward slashes. */
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1786
|
+
repoRootOf(cwd, signal) {
|
|
1787
|
+
return resolveRepoRoot((dir, argv) => this.git(dir, argv, signal), cwd);
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* The directory to run git in and join paths against for a session's
|
|
1791
|
+
* `worktreePath`: the repository ROOT, falling back to the directory
|
|
1792
|
+
* itself outside a repository (the caller's own git run then fails the
|
|
1793
|
+
* way it always did, and that error is the honest one to show).
|
|
1794
|
+
*
|
|
1795
|
+
* The drawer's paths are repository-relative — porcelain status and
|
|
1796
|
+
* `--numstat` print them that way wherever they run — while pathspecs,
|
|
1797
|
+
* `:path` revisions, `hash-object` arguments and `join(dir, path)` all
|
|
1798
|
+
* resolve against the directory a command runs in. Those two halves only
|
|
1799
|
+
* agree at the root, so every method that carries a path runs there.
|
|
1800
|
+
* `repo-root.ts` tells the whole story, with the probes that caught it.
|
|
1801
|
+
*/
|
|
1802
|
+
rootedDirOf(worktreePath, signal) {
|
|
1803
|
+
return rootedDir((dir, argv) => this.git(dir, argv, signal), this.cwdOf(worktreePath));
|
|
1575
1804
|
}
|
|
1576
1805
|
/**
|
|
1577
1806
|
* Spawn `git <args>` in cwd with piped stdio; drains both streams and returns
|
|
@@ -1640,14 +1869,14 @@ async function mapPooled(items, limit, task) {
|
|
|
1640
1869
|
* count newlines is the expensive half of this pass, and most untracked files
|
|
1641
1870
|
* never reach the bundled diff. Never throws; an unreadable file reports zero
|
|
1642
1871
|
* lines and nothing to diff.
|
|
1643
|
-
* @param
|
|
1872
|
+
* @param root - repository root the path is relative to.
|
|
1644
1873
|
* @param path - repository-relative file path.
|
|
1645
1874
|
* @returns the file's line count, binary flag, and whether a diff may be built.
|
|
1646
1875
|
*/
|
|
1647
|
-
async function measureUntracked(
|
|
1876
|
+
async function measureUntracked(root, path) {
|
|
1648
1877
|
let bytes;
|
|
1649
1878
|
try {
|
|
1650
|
-
bytes = await readFile(
|
|
1879
|
+
bytes = await readFile(resolveInside(root, path));
|
|
1651
1880
|
}
|
|
1652
1881
|
catch {
|
|
1653
1882
|
return { lineCount: 0, binary: false, diffable: false };
|
|
@@ -1665,16 +1894,16 @@ async function measureUntracked(cwd, path) {
|
|
|
1665
1894
|
*
|
|
1666
1895
|
* `git diff --no-index /dev/null <f>` is NOT used: on Windows git resolves
|
|
1667
1896
|
* `/dev/null` as a repo-relative path. Never throws.
|
|
1668
|
-
* @param
|
|
1897
|
+
* @param root - repository root the path is relative to.
|
|
1669
1898
|
* @param path - repository-relative file path.
|
|
1670
1899
|
* @param byteCap - refuse files larger than this; defaults to the stats
|
|
1671
1900
|
* payload's budget, which `fileSides` raises to its own.
|
|
1672
1901
|
* @returns the segment, or null when the file is missing, binary, or oversized.
|
|
1673
1902
|
*/
|
|
1674
|
-
async function untrackedSegment(
|
|
1903
|
+
async function untrackedSegment(root, path, byteCap = UNTRACKED_FILE_BYTE_CAP) {
|
|
1675
1904
|
let bytes;
|
|
1676
1905
|
try {
|
|
1677
|
-
bytes = await readFile(
|
|
1906
|
+
bytes = await readFile(resolveInside(root, path));
|
|
1678
1907
|
}
|
|
1679
1908
|
catch {
|
|
1680
1909
|
return null;
|