@young1lin/dsh-ui-gitworkbench 0.1.13 → 0.1.15

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/lib/index.js CHANGED
@@ -89,6 +89,7 @@ import { NETWORK_GRACE_MS, NON_INTERACTIVE_ENV, capBranches, classifyFailure, cl
89
89
  import { planFromStatus, } from './discard-ops.js';
90
90
  import { parseBlame } from './blame.js';
91
91
  import { removePathInside } from './fs-remove.js';
92
+ import { resolveRepoRoot, rootedDir } from './repo-root.js';
92
93
  import { diffTooLarge, targetTooLarge, SIDE_BYTE_CAP, SIDE_LINE_CAP } from './side-guard.js';
93
94
  import { IMAGE_BYTE_CAP, sniffImage } from './image-sniff.js';
94
95
  import { LOG_FORMAT, parseLog } from './git-log.js';
@@ -401,10 +402,20 @@ let GitWorkbenchService = (() => {
401
402
  // the browser. The tree and the counters need only `status` and
402
403
  // `--numstat`, both of which stay around 110-140ms at that size, and the
403
404
  // pane already fetches the file it is actually showing through `fileDiff`.
404
- const [statusInfo, numstat, revInfo] = await Promise.all([
405
+ // The fourth read resolves where the rest of this method must run: the
406
+ // repository ROOT. The paths this method handles are repository-relative
407
+ // (that is what porcelain status prints wherever it runs), so the
408
+ // untracked files read below must be joined against the root — a session
409
+ // opened at `repo/server` would otherwise look for `repo/server/server/f`.
410
+ // The three git reads themselves are cwd-INSENSITIVE (their output names
411
+ // paths from the repository root whatever directory they run in), so they
412
+ // stay on the session's own directory and the root resolve rides along in
413
+ // the same batch: the polled call pays for it in wall time not at all.
414
+ const [statusInfo, numstat, revInfo, root] = await Promise.all([
405
415
  this.git(cwd, ['status', '--porcelain=v1', '--branch', '--untracked-files=all'], signal),
406
416
  this.git(cwd, ['diff', 'HEAD', '--numstat'], signal),
407
417
  this.git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], signal),
418
+ this.rootedDirOf(worktreePath, signal),
408
419
  ]);
409
420
  if (statusInfo.exitCode !== 0) {
410
421
  const detail = statusInfo.stderr.length > 0 ? `: ${statusInfo.stderr}` : '';
@@ -418,7 +429,7 @@ let GitWorkbenchService = (() => {
418
429
  // segments into the payload — is gone with the payload itself; `fileDiff`
419
430
  // synthesizes the one segment the reader has actually opened.
420
431
  const untracked = files.filter(file => file.status === 'untracked');
421
- const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(cwd, file.path));
432
+ const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(root, file.path));
422
433
  for (const [index, file] of untracked.entries()) {
423
434
  const measure = measured[index];
424
435
  file.addedLines = measure.lineCount;
@@ -485,41 +496,46 @@ let GitWorkbenchService = (() => {
485
496
  * Plain-identifier params, signal last.
486
497
  */
487
498
  async fileDiff(worktreePath, path, commit, base, head, signal) {
488
- const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd();
489
499
  if (typeof path !== 'string' || path.length === 0)
490
500
  return { diff: '' };
501
+ // The repository root, not the session's directory: the pathspec below
502
+ // is repository-relative, and git resolves pathspecs against the cwd —
503
+ // from a subdirectory `diff HEAD -- server/f` looks for
504
+ // `server/server/f`, matches nothing, and reports an EMPTY diff with
505
+ // exit 0. (repo-root.ts holds the whole story.)
506
+ const root = await this.rootedDirOf(worktreePath, signal);
491
507
  if (typeof base === 'string' && base.length > 0 && typeof head === 'string' && head.length > 0) {
492
508
  if (!isRefName(base) || !isRefName(head))
493
509
  return { diff: '' };
494
- const ranged = await this.git(cwd, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal);
510
+ const ranged = await this.git(root, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal);
495
511
  if (ranged.exitCode === 0)
496
512
  return { diff: ranged.stdout };
497
513
  // Unrelated histories have no merge base for `A...B` to diff from; the
498
514
  // two-tip diff still answers what differs, exactly as `compareRefs` does.
499
515
  if (!isNoMergeBaseError(ranged.stderr))
500
516
  return { diff: '' };
501
- const tips = await this.git(cwd, ['diff', '--no-renames', base, head, '--', path], signal);
517
+ const tips = await this.git(root, ['diff', '--no-renames', base, head, '--', path], signal);
502
518
  return { diff: tips.exitCode === 0 ? tips.stdout : '' };
503
519
  }
504
520
  if (typeof commit === 'string' && commit.length > 0) {
505
521
  if (!COMMIT_HASH.test(commit))
506
522
  return { diff: '' };
507
- const key = cacheKey(cwd, commit, path);
523
+ const key = cacheKey(root, commit, path);
508
524
  const cached = this.commitDiffCache.get(key);
509
525
  if (cached !== undefined)
510
526
  return { diff: cached };
511
527
  // `--first-parent` for the same reason as in `commitStats`: without it a
512
528
  // merge commit has no diff to show and the pane opens empty.
513
- const shown = await this.git(cwd, ['show', commit, '--first-parent', '--format=', '--no-renames', '--', path], signal);
529
+ const shown = await this.git(root, ['show', commit, '--first-parent', '--format=', '--no-renames', '--', path], signal);
514
530
  if (shown.exitCode !== 0)
515
531
  return { diff: '' };
516
532
  this.commitDiffCache.set(key, shown.stdout);
517
533
  return { diff: shown.stdout };
518
534
  }
519
- const tracked = await this.git(cwd, ['diff', 'HEAD', '--', path], signal);
535
+ const tracked = await this.git(root, ['diff', 'HEAD', '--', path], signal);
520
536
  if (tracked.stdout.trim().length > 0)
521
537
  return { diff: tracked.stdout };
522
- return { diff: await untrackedSegment(cwd, path) ?? '' };
538
+ return { diff: await untrackedSegment(root, path) ?? '' };
523
539
  }
524
540
  /**
525
541
  * One layer of one file for the side-by-side diff pane: the layer's
@@ -534,7 +550,8 @@ let GitWorkbenchService = (() => {
534
550
  * nothing for them and the unstaged layer falls back to the synthesized
535
551
  * new-file segment `fileDiff` already uses.
536
552
  *
537
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
553
+ * @param worktreePath - directory the session opened; git runs at its
554
+ * repository root. Empty falls back to the host cwd.
538
555
  * @param path - repository-relative path, as the drawer lists it.
539
556
  * @param layer - `unstaged` (index→worktree) or `staged` (HEAD→index).
540
557
  * @param signal - abort signal.
@@ -546,18 +563,21 @@ let GitWorkbenchService = (() => {
546
563
  if (typeof path !== 'string' || !isSafePathArg(path)) {
547
564
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
548
565
  }
549
- const cwd = this.cwdOf(worktreePath);
566
+ // Repository root, not the session's directory: every path below is
567
+ // repository-relative (pathspecs resolve against the cwd, and so does
568
+ // the file read for the editor's target). See repo-root.ts.
569
+ const root = await this.rootedDirOf(worktreePath, signal);
550
570
  return layer === 'unstaged'
551
- ? await this.unstagedSides(cwd, path, signal)
552
- : await this.stagedSides(cwd, path, signal);
571
+ ? await this.unstagedSides(root, path, signal)
572
+ : await this.stagedSides(root, path, signal);
553
573
  }
554
574
  /** The unstaged layer: diff index→worktree, target = the working-tree file. */
555
- async unstagedSides(cwd, path, signal) {
575
+ async unstagedSides(root, path, signal) {
556
576
  // Size guard first, off the stat rather than a read: declining a file past
557
577
  // the cap must not mean loading a pathological one whole first. Bytes are
558
578
  // all a stat knows; the line half of the guard needs the read below.
559
579
  try {
560
- const info = await stat(join(cwd, path));
580
+ const info = await stat(join(root, path));
561
581
  if (info.isFile() && targetTooLarge(info.size, 0))
562
582
  return { ...emptySides(), tooLarge: true };
563
583
  }
@@ -566,20 +586,20 @@ let GitWorkbenchService = (() => {
566
586
  }
567
587
  let bytes = null;
568
588
  try {
569
- bytes = await readFile(join(cwd, path));
589
+ bytes = await readFile(join(root, path));
570
590
  }
571
591
  catch {
572
592
  bytes = null;
573
593
  }
574
594
  if (bytes !== null && isBinaryPrefix(bytes, BINARY_SNIFF_BYTES)) {
575
- return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(cwd, path, signal) };
595
+ return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(root, path, signal) };
576
596
  }
577
597
  if (bytes !== null && targetTooLarge(bytes.length, countBufferLines(bytes))) {
578
598
  return { ...emptySides(), tooLarge: true };
579
599
  }
580
- const diff = await this.layerDiffText(cwd, path, 'unstaged', signal);
600
+ const diff = await this.layerDiffText(root, path, 'unstaged', signal);
581
601
  if (binaryDiffOutput(diff)) {
582
- return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(cwd, path, signal) };
602
+ return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(root, path, signal) };
583
603
  }
584
604
  // The diff half of the guard, AFTER the artifact exists: the target-side
585
605
  // checks above cannot see a worktree-deleted large file (no target to
@@ -591,7 +611,7 @@ let GitWorkbenchService = (() => {
591
611
  diff,
592
612
  diffSha: sha1Hex(diff),
593
613
  targetText: bytes === null ? '' : bytes.toString('utf8'),
594
- targetSha: bytes === null ? '' : await this.worktreeBlobSha(cwd, path, signal),
614
+ targetSha: bytes === null ? '' : await this.worktreeBlobSha(root, path, signal),
595
615
  binary: false,
596
616
  tooLarge: false,
597
617
  // Only the unstaged layer can report this, and only it needs to: the
@@ -600,11 +620,11 @@ let GitWorkbenchService = (() => {
600
620
  };
601
621
  }
602
622
  /** The staged layer: diff HEAD→index, target = the index blob. */
603
- async stagedSides(cwd, path, signal) {
623
+ async stagedSides(root, path, signal) {
604
624
  // `:path` resolves the stage-0 index entry: the target text when it exists,
605
625
  // and a failed resolution (no entry) is the empty target, not an error.
606
- const shown = await this.git(cwd, ['show', `:${path}`], signal);
607
- const sha = (await this.git(cwd, ['rev-parse', '--verify', '--quiet', `:${path}`], signal)).stdout.trim();
626
+ const shown = await this.git(root, ['show', `:${path}`], signal);
627
+ const sha = (await this.git(root, ['rev-parse', '--verify', '--quiet', `:${path}`], signal)).stdout.trim();
608
628
  const targetText = shown.exitCode === 0 ? shown.stdout : '';
609
629
  const targetBytes = Buffer.from(targetText, 'utf8');
610
630
  if (targetText.length > 0 && isBinaryPrefix(targetBytes, BINARY_SNIFF_BYTES)) {
@@ -613,7 +633,7 @@ let GitWorkbenchService = (() => {
613
633
  if (targetTooLarge(targetBytes.length, countBufferLines(targetBytes))) {
614
634
  return { ...emptySides(), tooLarge: true, targetSha: sha };
615
635
  }
616
- const diff = await this.layerDiffText(cwd, path, 'staged', signal);
636
+ const diff = await this.layerDiffText(root, path, 'staged', signal);
617
637
  if (binaryDiffOutput(diff)) {
618
638
  return { ...emptySides(), binary: true, targetSha: sha };
619
639
  }
@@ -634,8 +654,8 @@ let GitWorkbenchService = (() => {
634
654
  };
635
655
  }
636
656
  /** Blob sha of the working-tree file, '' when git cannot hash it. */
637
- async worktreeBlobSha(cwd, path, signal) {
638
- const hashed = await this.git(cwd, ['hash-object', '--', path], signal);
657
+ async worktreeBlobSha(root, path, signal) {
658
+ const hashed = await this.git(root, ['hash-object', '--', path], signal);
639
659
  return hashed.exitCode === 0 ? hashed.stdout.trim() : '';
640
660
  }
641
661
  /**
@@ -647,19 +667,19 @@ let GitWorkbenchService = (() => {
647
667
  * its own fresh fetch, so the two ends of the stale comparison are over the
648
668
  * SAME text by construction, not by two fetch sites staying in step.
649
669
  */
650
- async layerDiffText(cwd, path, layer, signal) {
670
+ async layerDiffText(root, path, layer, signal) {
651
671
  if (layer === 'staged') {
652
- return (await this.git(cwd, ['diff', '--cached', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout;
672
+ return (await this.git(root, ['diff', '--cached', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout;
653
673
  }
654
- const diff = (await this.git(cwd, ['diff', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout;
674
+ const diff = (await this.git(root, ['diff', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout;
655
675
  // Untracked files have no index entry, so `git diff` reports nothing for
656
676
  // them; the synthesized new-file segment is their unstaged diff. The
657
677
  // trailing newline is added here because every diff git prints carries
658
678
  // one: this text is what `applyBlocks` re-emits as a patch file, and a
659
679
  // patch whose last line has no LF is "corrupt patch" to `git apply` —
660
680
  // which is exactly what a real-git drive of the untracked path caught.
661
- if (diff.length === 0 && await this.isUntracked(cwd, path, signal)) {
662
- const segment = await untrackedSegment(cwd, path, SIDE_BYTE_CAP);
681
+ if (diff.length === 0 && await this.isUntracked(root, path, signal)) {
682
+ const segment = await untrackedSegment(root, path, SIDE_BYTE_CAP);
663
683
  return segment === null ? '' : `${segment}\n`;
664
684
  }
665
685
  return diff;
@@ -677,7 +697,7 @@ let GitWorkbenchService = (() => {
677
697
  * shared with `fileSides`, and the tmpfile pair. Every failure comes back as
678
698
  * a result — the method never throws across the RPC boundary.
679
699
  *
680
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
700
+ * @param worktreePath - directory the session opened; git runs at its repository root.
681
701
  * @param path - repository-relative path, as the drawer lists it.
682
702
  * @param layer - the layer the block was selected on; the mode decides which
683
703
  * one that may be.
@@ -688,14 +708,16 @@ let GitWorkbenchService = (() => {
688
708
  * @param signal - abort signal.
689
709
  */
690
710
  async applyBlocks(worktreePath, path, layer, diffSha, lines, mode, signal) {
691
- const cwd = this.cwdOf(worktreePath);
711
+ // The repository root — the patch's pathspecs are repository-relative
712
+ // and `git apply` resolves them against the cwd (repo-root.ts).
713
+ const root = await this.rootedDirOf(worktreePath, signal);
692
714
  const io = {
693
715
  git: (dir, argv) => this.git(dir, argv, signal),
694
- layerDiff: (file, which) => this.layerDiffText(cwd, file, which === 'staged' ? 'staged' : 'unstaged', signal),
716
+ layerDiff: (file, which) => this.layerDiffText(root, file, which === 'staged' ? 'staged' : 'unstaged', signal),
695
717
  writePatch: writeTmpPatch,
696
718
  dropPatch: dropTmpPatch,
697
719
  };
698
- return runApplyBlocks(io, cwd, path, layer, String(diffSha ?? ''), lines, mode);
720
+ return runApplyBlocks(io, root, path, layer, String(diffSha ?? ''), lines, mode);
699
721
  }
700
722
  /**
701
723
  * Save the side-by-side editor's buffer over the working-tree file it was
@@ -716,7 +738,7 @@ let GitWorkbenchService = (() => {
716
738
  * check and this method are one thing, and no unchecked write RPC exists or
717
739
  * may be added in this plugin.
718
740
  *
719
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
741
+ * @param worktreePath - directory the session opened; git runs at its repository root.
720
742
  * @param path - repository-relative path, as the drawer lists it.
721
743
  * @param text - the editor buffer, verbatim; written as bytes (LF as given).
722
744
  * @param expectedSha - the `targetSha` the buffer was opened with ('' when
@@ -725,7 +747,10 @@ let GitWorkbenchService = (() => {
725
747
  * @param signal - abort signal.
726
748
  */
727
749
  async writeChecked(worktreePath, path, text, expectedSha, signal) {
728
- const cwd = this.cwdOf(worktreePath);
750
+ // The repository root — the save target joins the same base every
751
+ // other path here is relative to, and that base is the root, not the
752
+ // directory the session opened (repo-root.ts).
753
+ const root = await this.rootedDirOf(worktreePath, signal);
729
754
  const io = {
730
755
  git: (dir, argv) => this.git(dir, argv, signal),
731
756
  exists: async (p) => {
@@ -743,7 +768,7 @@ let GitWorkbenchService = (() => {
743
768
  remove: async (p) => { await rm(p, { force: true }); },
744
769
  delay: ms => new Promise(resolve => { setTimeout(resolve, ms); }),
745
770
  };
746
- return runWriteChecked(io, cwd, path, typeof text === 'string' ? text : '', typeof expectedSha === 'string' ? expectedSha : '');
771
+ return runWriteChecked(io, root, path, typeof text === 'string' ? text : '', typeof expectedSha === 'string' ? expectedSha : '');
747
772
  }
748
773
  /**
749
774
  * One file's provenance, line by line — the side pane's blame gutter
@@ -754,7 +779,7 @@ let GitWorkbenchService = (() => {
754
779
  * rather than missing. Read-only, no index or worktree is touched, so this
755
780
  * needs none of the confirmation machinery the write paths carry.
756
781
  *
757
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
782
+ * @param worktreePath - directory the session opened; git runs at its repository root.
758
783
  * @param path - repository-relative path, as the drawer lists it.
759
784
  * @param signal - abort signal.
760
785
  */
@@ -762,10 +787,13 @@ let GitWorkbenchService = (() => {
762
787
  if (typeof path !== 'string' || !isSafePathArg(path)) {
763
788
  return { lines: [], truncated: false, error: `unsafe path argument: ${JSON.stringify(path)}` };
764
789
  }
765
- const cwd = this.cwdOf(worktreePath);
790
+ // The repository root: blame's pathspec resolves against the cwd, and
791
+ // from a subdirectory the repository-relative path doubles up
792
+ // (`server/server/f`) into a fatal "no such path" (repo-root.ts).
793
+ const root = await this.rootedDirOf(worktreePath, signal);
766
794
  // `--` keeps a path that looks like a revision from being read as one, as
767
795
  // every other pathspec in this plugin does.
768
- const run = await this.git(cwd, ['blame', '--line-porcelain', '--', path], signal);
796
+ const run = await this.git(root, ['blame', '--line-porcelain', '--', path], signal);
769
797
  if (run.exitCode !== 0) {
770
798
  // An untracked file has no blame, and git says so; that message is the
771
799
  // honest thing to show rather than an empty gutter.
@@ -799,7 +827,7 @@ let GitWorkbenchService = (() => {
799
827
  *
800
828
  * Read-only — nothing is spawned, nothing is written.
801
829
  *
802
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
830
+ * @param worktreePath - directory the session opened; git runs at its repository root.
803
831
  * @param path - repository-relative path, as the drawer lists it.
804
832
  * @param signal - abort signal.
805
833
  */
@@ -807,7 +835,9 @@ let GitWorkbenchService = (() => {
807
835
  if (typeof path !== 'string' || !isSafePathArg(path)) {
808
836
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
809
837
  }
810
- const full = join(this.cwdOf(worktreePath), path);
838
+ // The repository root — the image is read from disk at the same base
839
+ // every other path in this plugin is relative to (repo-root.ts).
840
+ const full = join(await this.rootedDirOf(worktreePath, signal), path);
811
841
  let size = 0;
812
842
  try {
813
843
  const info = await stat(full);
@@ -848,11 +878,11 @@ let GitWorkbenchService = (() => {
848
878
  * Those are the files whose diff has to be synthesized rather than asked of
849
879
  * `git diff`, which reports nothing for them.
850
880
  */
851
- async isUntracked(cwd, path, signal) {
852
- const listed = await this.git(cwd, ['ls-files', '--', path], signal);
881
+ async isUntracked(root, path, signal) {
882
+ const listed = await this.git(root, ['ls-files', '--', path], signal);
853
883
  if (listed.exitCode !== 0 || listed.stdout.trim().length > 0)
854
884
  return false;
855
- const head = await this.git(cwd, ['rev-parse', '--verify', '--quiet', `HEAD:${path}`], signal);
885
+ const head = await this.git(root, ['rev-parse', '--verify', '--quiet', `HEAD:${path}`], signal);
856
886
  return head.exitCode !== 0;
857
887
  }
858
888
  /**
@@ -885,11 +915,17 @@ let GitWorkbenchService = (() => {
885
915
  // empty pane. Against the first parent the answer is well defined and is the
886
916
  // useful one: what this merge brought into the branch it landed on. On a
887
917
  // single-parent commit the flag is a no-op, byte for byte.
918
+ // The repository root: no pathspec here today, but the cache key below
919
+ // must describe the same repository to a later caller, and running from
920
+ // the root is the one rule every read in this plugin follows
921
+ // (repo-root.ts). Resolved after the cache probe so a hit spawns
922
+ // nothing.
923
+ const root = await this.rootedDirOf(worktreePath, signal);
888
924
  const [meta, numstat, nameStatus, patch] = await Promise.all([
889
- this.git(cwd, ['show', hash, '--no-patch', `--format=${LOG_FORMAT}`], signal),
890
- this.git(cwd, ['show', hash, '--first-parent', '--numstat', '--format=', '--no-renames'], signal),
891
- this.git(cwd, ['show', hash, '--first-parent', '--name-status', '--format=', '--no-renames'], signal),
892
- this.git(cwd, ['show', hash, '--first-parent', '--format=', '--no-renames'], signal),
925
+ this.git(root, ['show', hash, '--no-patch', `--format=${LOG_FORMAT}`], signal),
926
+ this.git(root, ['show', hash, '--first-parent', '--numstat', '--format=', '--no-renames'], signal),
927
+ this.git(root, ['show', hash, '--first-parent', '--name-status', '--format=', '--no-renames'], signal),
928
+ this.git(root, ['show', hash, '--first-parent', '--format=', '--no-renames'], signal),
893
929
  ]);
894
930
  if (meta.exitCode !== 0) {
895
931
  const detail = meta.stderr.length > 0 ? `: ${meta.stderr}` : '';
@@ -954,6 +990,10 @@ let GitWorkbenchService = (() => {
954
990
  const from = Number.isInteger(skip) && skip >= 0 ? skip : 0;
955
991
  const size = Number.isInteger(limit) && limit > 0 && limit <= HISTORY_PAGE_MAX ? limit : HISTORY_PAGE;
956
992
  const effective = filter ?? emptyLogFilter();
993
+ // The repository root: a filter's pathspec is repository-relative, and
994
+ // git resolves pathspecs against the cwd — from a subdirectory a
995
+ // filtered history silently comes back EMPTY with exit 0 (repo-root.ts).
996
+ const root = await this.rootedDirOf(worktreePath, signal);
957
997
  // Reading one row beyond the page answers "is there more" without a second
958
998
  // traversal of the log.
959
999
  //
@@ -966,7 +1006,7 @@ let GitWorkbenchService = (() => {
966
1006
  //
967
1007
  // Filter args go LAST: their segment ends with `--` + pathspecs, and
968
1008
  // nothing after that separator may be parsed as a flag.
969
- const log = await this.git(cwd, ['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`, ...logFilterArgs(effective)], signal);
1009
+ const log = await this.git(root, ['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`, ...logFilterArgs(effective)], signal);
970
1010
  // A bad filter (unparsable regex, invalid date) dies here, and an empty
971
1011
  // page is indistinguishable from "no match" unless the failure speaks —
972
1012
  // §6.13: the exit code + stderr tail is the only honest answer.
@@ -1010,8 +1050,12 @@ let GitWorkbenchService = (() => {
1010
1050
  * @param signal - abort signal.
1011
1051
  */
1012
1052
  async repoTree(worktreePath, signal) {
1013
- const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd();
1014
- const res = await this.git(cwd, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal);
1053
+ // The repository root: unlike status and numstat, `ls-tree` prints
1054
+ // cwd-RELATIVE paths from a subdirectory every entry would lose the
1055
+ // `server/` prefix and the picker would feed the log filter pathspecs
1056
+ // that match nothing (repo-root.ts).
1057
+ const root = await this.rootedDirOf(worktreePath, signal);
1058
+ const res = await this.git(root, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal);
1015
1059
  const all = res.stdout.split('\0').filter(path => path.length > 0);
1016
1060
  const truncated = all.length > TREE_PATH_CAP;
1017
1061
  return { paths: truncated ? all.slice(0, TREE_PATH_CAP) : all, truncated };
@@ -1318,22 +1362,28 @@ let GitWorkbenchService = (() => {
1318
1362
  }
1319
1363
  /**
1320
1364
  * Add paths to the index.
1321
- * @param worktreePath - directory to run in.
1365
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1322
1366
  * @param paths - repository-relative paths; an empty list is refused rather
1323
1367
  * than turned into a whole-tree `git add`.
1324
1368
  * @param signal - abort signal.
1325
1369
  */
1326
1370
  async stage(worktreePath, paths, signal) {
1327
- return this.writeOp(worktreePath, () => stageArgv(asPathList(paths)), signal);
1371
+ // The repository root: `git add` resolves its pathspecs against the cwd,
1372
+ // and from a subdirectory a repository-relative path dies with
1373
+ // "pathspec did not match any files" (repo-root.ts).
1374
+ const root = await this.rootedDirOf(worktreePath, signal);
1375
+ return this.writeOp(root, () => stageArgv(asPathList(paths)), signal);
1328
1376
  }
1329
1377
  /**
1330
1378
  * Remove paths from the index, leaving the working tree untouched.
1331
- * @param worktreePath - directory to run in.
1379
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1332
1380
  * @param paths - repository-relative paths.
1333
1381
  * @param signal - abort signal.
1334
1382
  */
1335
1383
  async unstage(worktreePath, paths, signal) {
1336
- return this.writeOp(worktreePath, () => unstageArgv(asPathList(paths)), signal);
1384
+ // Same rule as `stage` — the pathspecs are repository-relative.
1385
+ const root = await this.rootedDirOf(worktreePath, signal);
1386
+ return this.writeOp(root, () => unstageArgv(asPathList(paths)), signal);
1337
1387
  }
1338
1388
  /**
1339
1389
  * What discarding this file WOULD do, without doing it.
@@ -1344,16 +1394,19 @@ let GitWorkbenchService = (() => {
1344
1394
  * cannot come back" is exactly the difference the reader is being asked
1345
1395
  * about. So the dialog is built from this, read fresh, rather than from the
1346
1396
  * row that was clicked.
1347
- * @param worktreePath - directory to run in.
1397
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1348
1398
  * @param path - repository-relative path, as the drawer lists it.
1349
1399
  * @param signal - abort signal.
1350
1400
  * @returns the effect and whether it is irreversible; `effect` is absent when
1351
1401
  * git reports nothing to discard for that path.
1352
1402
  */
1353
1403
  async discardPlan(worktreePath, path, signal) {
1404
+ // The repository root — the plan's argv and delete paths are
1405
+ // repository-relative (repo-root.ts).
1406
+ const root = await this.rootedDirOf(worktreePath, signal);
1354
1407
  let plan;
1355
1408
  try {
1356
- plan = await this.planDiscard(worktreePath, path, signal);
1409
+ plan = await this.planDiscard(root, path, signal);
1357
1410
  }
1358
1411
  catch (error) {
1359
1412
  return { error: error instanceof Error ? error.message : String(error) };
@@ -1378,7 +1431,7 @@ let GitWorkbenchService = (() => {
1378
1431
  * it. `expectedEffect` is what the reader was shown and agreed to: if the
1379
1432
  * file changed underneath the dialog — staged, edited, reverted by someone
1380
1433
  * else — the freshly derived effect no longer matches and nothing is done.
1381
- * @param worktreePath - directory to run in.
1434
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1382
1435
  * @param path - repository-relative path, as the drawer lists it.
1383
1436
  * @param expectedEffect - the effect the confirmation stated; blank skips
1384
1437
  * the agreement check, which only the reversible
@@ -1387,9 +1440,13 @@ let GitWorkbenchService = (() => {
1387
1440
  * @returns the operation result, with the effect actually carried out.
1388
1441
  */
1389
1442
  async discardFile(worktreePath, path, expectedEffect, signal) {
1443
+ // The repository root, resolved ONCE for the plan and the steps alike:
1444
+ // every step's argv and delete path came out of a whole-tree porcelain
1445
+ // status, so they are repository-relative (repo-root.ts).
1446
+ const root = await this.rootedDirOf(worktreePath, signal);
1390
1447
  let plan;
1391
1448
  try {
1392
- plan = await this.planDiscard(worktreePath, path, signal);
1449
+ plan = await this.planDiscard(root, path, signal);
1393
1450
  }
1394
1451
  catch (error) {
1395
1452
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
@@ -1405,10 +1462,9 @@ let GitWorkbenchService = (() => {
1405
1462
  error: `this file changed since you were asked (now: ${plan.effect}); nothing was done`,
1406
1463
  };
1407
1464
  }
1408
- const cwd = this.cwdOf(worktreePath);
1409
1465
  for (const step of plan.steps) {
1410
1466
  if (step.kind === 'git') {
1411
- const result = await this.git(cwd, step.argv, signal);
1467
+ const result = await this.git(root, step.argv, signal);
1412
1468
  const failure = classifyFailure(result.exitCode, result.stderr, result.stdout);
1413
1469
  if (failure !== null) {
1414
1470
  return { ok: false, failure, error: (result.stderr || result.stdout).trim().slice(-1000) };
@@ -1416,7 +1472,7 @@ let GitWorkbenchService = (() => {
1416
1472
  continue;
1417
1473
  }
1418
1474
  try {
1419
- await removePathInside(cwd, step.path);
1475
+ await removePathInside(root, step.path);
1420
1476
  }
1421
1477
  catch (error) {
1422
1478
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
@@ -1432,12 +1488,14 @@ let GitWorkbenchService = (() => {
1432
1488
  * reports `D` plus `??` instead — which plans as "restore one, DELETE the
1433
1489
  * other" where the truth is "undo the rename".
1434
1490
  */
1435
- async planDiscard(worktreePath, path, signal) {
1491
+ async planDiscard(root, path, signal) {
1436
1492
  if (typeof path !== 'string' || !isSafePathArg(path)) {
1437
1493
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
1438
1494
  }
1439
- const cwd = this.cwdOf(worktreePath);
1440
- const status = await this.git(cwd, ['status', '--porcelain=v1', '--untracked-files=all'], signal);
1495
+ // Runs at the repository root the caller resolved: the plan's paths
1496
+ // must agree with the directory its steps execute in, and with the
1497
+ // client's `path`, which the drawer lists repository-relative.
1498
+ const status = await this.git(root, ['status', '--porcelain=v1', '--untracked-files=all'], signal);
1441
1499
  if (status.exitCode !== 0) {
1442
1500
  throw new Error((status.stderr || status.stdout).trim().slice(-1000) || 'git status failed');
1443
1501
  }
@@ -1451,7 +1509,8 @@ let GitWorkbenchService = (() => {
1451
1509
  * @param signal - abort signal.
1452
1510
  */
1453
1511
  async commit(worktreePath, message, amend, signal) {
1454
- return this.writeOp(worktreePath, () => commitArgv(String(message ?? ''), amend === true), signal);
1512
+ // No pathspec in this argv the session's own directory suffices.
1513
+ return this.writeOp(this.cwdOf(worktreePath), () => commitArgv(String(message ?? ''), amend === true), signal);
1455
1514
  }
1456
1515
  /**
1457
1516
  * Update remote-tracking refs without touching the working tree.
@@ -1460,7 +1519,7 @@ let GitWorkbenchService = (() => {
1460
1519
  * @returns the operation result, plus the divergence the fetch revealed.
1461
1520
  */
1462
1521
  async fetch(worktreePath, signal) {
1463
- const result = await this.writeOp(worktreePath, () => fetchArgv(), signal, NETWORK_GRACE_MS);
1522
+ const result = await this.writeOp(this.cwdOf(worktreePath), () => fetchArgv(), signal, NETWORK_GRACE_MS);
1464
1523
  if (!result.ok)
1465
1524
  return result;
1466
1525
  // The point of fetching is the count it produces, so report it in the same
@@ -1477,7 +1536,7 @@ let GitWorkbenchService = (() => {
1477
1536
  */
1478
1537
  async pull(worktreePath, mode, signal) {
1479
1538
  const chosen = mode === 'rebase' || mode === 'merge' ? mode : 'ff-only';
1480
- return this.writeOp(worktreePath, () => pullArgv(chosen), signal, NETWORK_GRACE_MS);
1539
+ return this.writeOp(this.cwdOf(worktreePath), () => pullArgv(chosen), signal, NETWORK_GRACE_MS);
1481
1540
  }
1482
1541
  /**
1483
1542
  * Publish the current branch.
@@ -1496,10 +1555,16 @@ let GitWorkbenchService = (() => {
1496
1555
  return { ok: false, failure: 'unknown', error: 'HEAD is detached; nothing to push' };
1497
1556
  if (tracking.branch.length === 0)
1498
1557
  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);
1558
+ return this.writeOp(this.cwdOf(worktreePath), () => pushArgv(tracking.branch, tracking.upstream !== null), signal, NETWORK_GRACE_MS);
1500
1559
  }
1501
- /** Shared shape for every write op: run it, classify what went wrong. */
1502
- async writeOp(worktreePath, build, signal, graceMs) {
1560
+ /** Shared shape for every write op: run it, classify what went wrong.
1561
+ *
1562
+ * Takes the DIRECTORY to run in, already resolved: callers carrying
1563
+ * repository-relative pathspecs pass the rooted directory
1564
+ * ({@link GitWorkbenchService.rootedDirOf}); pathspec-free operations may
1565
+ * pass the session's own directory.
1566
+ */
1567
+ async writeOp(dir, build, signal, graceMs) {
1503
1568
  let argv;
1504
1569
  try {
1505
1570
  argv = build();
@@ -1509,7 +1574,7 @@ let GitWorkbenchService = (() => {
1509
1574
  // list or a blank commit message takes.
1510
1575
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
1511
1576
  }
1512
- const result = await this.git(this.cwdOf(worktreePath), argv, signal, graceMs);
1577
+ const result = await this.git(dir, argv, signal, graceMs);
1513
1578
  const failure = classifyFailure(result.exitCode, result.stderr, result.stdout);
1514
1579
  if (failure === null)
1515
1580
  return { ok: true, output: result.stdout.trim().slice(-1000) };
@@ -1567,11 +1632,24 @@ let GitWorkbenchService = (() => {
1567
1632
  return run;
1568
1633
  }
1569
1634
  /** Resolve the repo root for a directory (null when not a git repo). Always forward slashes. */
1570
- async repoRootOf(cwd, signal) {
1571
- const out = await this.git(cwd, ['rev-parse', '--show-toplevel'], signal);
1572
- if (out.exitCode !== 0)
1573
- return null;
1574
- return out.stdout.trim().replace(/\\/g, '/') || null;
1635
+ repoRootOf(cwd, signal) {
1636
+ return resolveRepoRoot((dir, argv) => this.git(dir, argv, signal), cwd);
1637
+ }
1638
+ /**
1639
+ * The directory to run git in and join paths against for a session's
1640
+ * `worktreePath`: the repository ROOT, falling back to the directory
1641
+ * itself outside a repository (the caller's own git run then fails the
1642
+ * way it always did, and that error is the honest one to show).
1643
+ *
1644
+ * The drawer's paths are repository-relative — porcelain status and
1645
+ * `--numstat` print them that way wherever they run — while pathspecs,
1646
+ * `:path` revisions, `hash-object` arguments and `join(dir, path)` all
1647
+ * resolve against the directory a command runs in. Those two halves only
1648
+ * agree at the root, so every method that carries a path runs there.
1649
+ * `repo-root.ts` tells the whole story, with the probes that caught it.
1650
+ */
1651
+ rootedDirOf(worktreePath, signal) {
1652
+ return rootedDir((dir, argv) => this.git(dir, argv, signal), this.cwdOf(worktreePath));
1575
1653
  }
1576
1654
  /**
1577
1655
  * Spawn `git <args>` in cwd with piped stdio; drains both streams and returns
@@ -1640,14 +1718,14 @@ async function mapPooled(items, limit, task) {
1640
1718
  * count newlines is the expensive half of this pass, and most untracked files
1641
1719
  * never reach the bundled diff. Never throws; an unreadable file reports zero
1642
1720
  * lines and nothing to diff.
1643
- * @param cwd - worktree the path is relative to.
1721
+ * @param root - repository root the path is relative to.
1644
1722
  * @param path - repository-relative file path.
1645
1723
  * @returns the file's line count, binary flag, and whether a diff may be built.
1646
1724
  */
1647
- async function measureUntracked(cwd, path) {
1725
+ async function measureUntracked(root, path) {
1648
1726
  let bytes;
1649
1727
  try {
1650
- bytes = await readFile(join(cwd, path));
1728
+ bytes = await readFile(join(root, path));
1651
1729
  }
1652
1730
  catch {
1653
1731
  return { lineCount: 0, binary: false, diffable: false };
@@ -1665,16 +1743,16 @@ async function measureUntracked(cwd, path) {
1665
1743
  *
1666
1744
  * `git diff --no-index /dev/null <f>` is NOT used: on Windows git resolves
1667
1745
  * `/dev/null` as a repo-relative path. Never throws.
1668
- * @param cwd - worktree the path is relative to.
1746
+ * @param root - repository root the path is relative to.
1669
1747
  * @param path - repository-relative file path.
1670
1748
  * @param byteCap - refuse files larger than this; defaults to the stats
1671
1749
  * payload's budget, which `fileSides` raises to its own.
1672
1750
  * @returns the segment, or null when the file is missing, binary, or oversized.
1673
1751
  */
1674
- async function untrackedSegment(cwd, path, byteCap = UNTRACKED_FILE_BYTE_CAP) {
1752
+ async function untrackedSegment(root, path, byteCap = UNTRACKED_FILE_BYTE_CAP) {
1675
1753
  let bytes;
1676
1754
  try {
1677
- bytes = await readFile(join(cwd, path));
1755
+ bytes = await readFile(join(root, path));
1678
1756
  }
1679
1757
  catch {
1680
1758
  return null;