@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/src/index.ts CHANGED
@@ -67,6 +67,7 @@ import {
67
67
  } from './discard-ops.js'
68
68
  import { parseBlame, type BlameLine } from './blame.js'
69
69
  import { removePathInside } from './fs-remove.js'
70
+ import { resolveRepoRoot, rootedDir } from './repo-root.js'
70
71
  import { diffTooLarge, targetTooLarge, SIDE_BYTE_CAP, SIDE_LINE_CAP } from './side-guard.js'
71
72
  import { IMAGE_BYTE_CAP, sniffImage } from './image-sniff.js'
72
73
  import { LOG_FORMAT, parseLog, type GitCommit } from './git-log.js'
@@ -424,10 +425,20 @@ export class GitWorkbenchService extends TypertRemoteService {
424
425
  // the browser. The tree and the counters need only `status` and
425
426
  // `--numstat`, both of which stay around 110-140ms at that size, and the
426
427
  // pane already fetches the file it is actually showing through `fileDiff`.
427
- const [statusInfo, numstat, revInfo] = await Promise.all([
428
+ // The fourth read resolves where the rest of this method must run: the
429
+ // repository ROOT. The paths this method handles are repository-relative
430
+ // (that is what porcelain status prints wherever it runs), so the
431
+ // untracked files read below must be joined against the root — a session
432
+ // opened at `repo/server` would otherwise look for `repo/server/server/f`.
433
+ // The three git reads themselves are cwd-INSENSITIVE (their output names
434
+ // paths from the repository root whatever directory they run in), so they
435
+ // stay on the session's own directory and the root resolve rides along in
436
+ // the same batch: the polled call pays for it in wall time not at all.
437
+ const [statusInfo, numstat, revInfo, root] = await Promise.all([
428
438
  this.git(cwd, ['status', '--porcelain=v1', '--branch', '--untracked-files=all'], signal),
429
439
  this.git(cwd, ['diff', 'HEAD', '--numstat'], signal),
430
440
  this.git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], signal),
441
+ this.rootedDirOf(worktreePath, signal),
431
442
  ])
432
443
  if (statusInfo.exitCode !== 0) {
433
444
  const detail = statusInfo.stderr.length > 0 ? `: ${statusInfo.stderr}` : ''
@@ -443,7 +454,7 @@ export class GitWorkbenchService extends TypertRemoteService {
443
454
  // segments into the payload — is gone with the payload itself; `fileDiff`
444
455
  // synthesizes the one segment the reader has actually opened.
445
456
  const untracked = files.filter(file => file.status === 'untracked')
446
- const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(cwd, file.path))
457
+ const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(root, file.path))
447
458
 
448
459
  for (const [index, file] of untracked.entries()) {
449
460
  const measure = measured[index]
@@ -513,33 +524,38 @@ export class GitWorkbenchService extends TypertRemoteService {
513
524
  */
514
525
  @Remote('fileDiff')
515
526
  async fileDiff(worktreePath: string, path: string, commit: string | undefined, base: string | undefined, head: string | undefined, signal: AbortSignal): Promise<{ readonly diff: string }> {
516
- const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
517
527
  if (typeof path !== 'string' || path.length === 0) return { diff: '' }
528
+ // The repository root, not the session's directory: the pathspec below
529
+ // is repository-relative, and git resolves pathspecs against the cwd —
530
+ // from a subdirectory `diff HEAD -- server/f` looks for
531
+ // `server/server/f`, matches nothing, and reports an EMPTY diff with
532
+ // exit 0. (repo-root.ts holds the whole story.)
533
+ const root = await this.rootedDirOf(worktreePath, signal)
518
534
  if (typeof base === 'string' && base.length > 0 && typeof head === 'string' && head.length > 0) {
519
535
  if (!isRefName(base) || !isRefName(head)) return { diff: '' }
520
- const ranged = await this.git(cwd, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal)
536
+ const ranged = await this.git(root, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal)
521
537
  if (ranged.exitCode === 0) return { diff: ranged.stdout }
522
538
  // Unrelated histories have no merge base for `A...B` to diff from; the
523
539
  // two-tip diff still answers what differs, exactly as `compareRefs` does.
524
540
  if (!isNoMergeBaseError(ranged.stderr)) return { diff: '' }
525
- const tips = await this.git(cwd, ['diff', '--no-renames', base, head, '--', path], signal)
541
+ const tips = await this.git(root, ['diff', '--no-renames', base, head, '--', path], signal)
526
542
  return { diff: tips.exitCode === 0 ? tips.stdout : '' }
527
543
  }
528
544
  if (typeof commit === 'string' && commit.length > 0) {
529
545
  if (!COMMIT_HASH.test(commit)) return { diff: '' }
530
- const key = cacheKey(cwd, commit, path)
546
+ const key = cacheKey(root, commit, path)
531
547
  const cached = this.commitDiffCache.get(key)
532
548
  if (cached !== undefined) return { diff: cached }
533
549
  // `--first-parent` for the same reason as in `commitStats`: without it a
534
550
  // merge commit has no diff to show and the pane opens empty.
535
- const shown = await this.git(cwd, ['show', commit, '--first-parent', '--format=', '--no-renames', '--', path], signal)
551
+ const shown = await this.git(root, ['show', commit, '--first-parent', '--format=', '--no-renames', '--', path], signal)
536
552
  if (shown.exitCode !== 0) return { diff: '' }
537
553
  this.commitDiffCache.set(key, shown.stdout)
538
554
  return { diff: shown.stdout }
539
555
  }
540
- const tracked = await this.git(cwd, ['diff', 'HEAD', '--', path], signal)
556
+ const tracked = await this.git(root, ['diff', 'HEAD', '--', path], signal)
541
557
  if (tracked.stdout.trim().length > 0) return { diff: tracked.stdout }
542
- return { diff: await untrackedSegment(cwd, path) ?? '' }
558
+ return { diff: await untrackedSegment(root, path) ?? '' }
543
559
  }
544
560
 
545
561
  /**
@@ -555,7 +571,8 @@ export class GitWorkbenchService extends TypertRemoteService {
555
571
  * nothing for them and the unstaged layer falls back to the synthesized
556
572
  * new-file segment `fileDiff` already uses.
557
573
  *
558
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
574
+ * @param worktreePath - directory the session opened; git runs at its
575
+ * repository root. Empty falls back to the host cwd.
559
576
  * @param path - repository-relative path, as the drawer lists it.
560
577
  * @param layer - `unstaged` (index→worktree) or `staged` (HEAD→index).
561
578
  * @param signal - abort signal.
@@ -568,38 +585,41 @@ export class GitWorkbenchService extends TypertRemoteService {
568
585
  if (typeof path !== 'string' || !isSafePathArg(path)) {
569
586
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
570
587
  }
571
- const cwd = this.cwdOf(worktreePath)
588
+ // Repository root, not the session's directory: every path below is
589
+ // repository-relative (pathspecs resolve against the cwd, and so does
590
+ // the file read for the editor's target). See repo-root.ts.
591
+ const root = await this.rootedDirOf(worktreePath, signal)
572
592
  return layer === 'unstaged'
573
- ? await this.unstagedSides(cwd, path, signal)
574
- : await this.stagedSides(cwd, path, signal)
593
+ ? await this.unstagedSides(root, path, signal)
594
+ : await this.stagedSides(root, path, signal)
575
595
  }
576
596
 
577
597
  /** The unstaged layer: diff index→worktree, target = the working-tree file. */
578
- private async unstagedSides(cwd: string, path: string, signal: AbortSignal): Promise<FileSides> {
598
+ private async unstagedSides(root: string, path: string, signal: AbortSignal): Promise<FileSides> {
579
599
  // Size guard first, off the stat rather than a read: declining a file past
580
600
  // the cap must not mean loading a pathological one whole first. Bytes are
581
601
  // all a stat knows; the line half of the guard needs the read below.
582
602
  try {
583
- const info = await stat(join(cwd, path))
603
+ const info = await stat(join(root, path))
584
604
  if (info.isFile() && targetTooLarge(info.size, 0)) return { ...emptySides(), tooLarge: true }
585
605
  } catch {
586
606
  // Missing file: deleted in the working tree, which the diff below states.
587
607
  }
588
608
  let bytes: Buffer | null = null
589
609
  try {
590
- bytes = await readFile(join(cwd, path))
610
+ bytes = await readFile(join(root, path))
591
611
  } catch {
592
612
  bytes = null
593
613
  }
594
614
  if (bytes !== null && isBinaryPrefix(bytes, BINARY_SNIFF_BYTES)) {
595
- return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(cwd, path, signal) }
615
+ return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(root, path, signal) }
596
616
  }
597
617
  if (bytes !== null && targetTooLarge(bytes.length, countBufferLines(bytes))) {
598
618
  return { ...emptySides(), tooLarge: true }
599
619
  }
600
- const diff = await this.layerDiffText(cwd, path, 'unstaged', signal)
620
+ const diff = await this.layerDiffText(root, path, 'unstaged', signal)
601
621
  if (binaryDiffOutput(diff)) {
602
- return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(cwd, path, signal) }
622
+ return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(root, path, signal) }
603
623
  }
604
624
  // The diff half of the guard, AFTER the artifact exists: the target-side
605
625
  // checks above cannot see a worktree-deleted large file (no target to
@@ -610,7 +630,7 @@ export class GitWorkbenchService extends TypertRemoteService {
610
630
  diff,
611
631
  diffSha: sha1Hex(diff),
612
632
  targetText: bytes === null ? '' : bytes.toString('utf8'),
613
- targetSha: bytes === null ? '' : await this.worktreeBlobSha(cwd, path, signal),
633
+ targetSha: bytes === null ? '' : await this.worktreeBlobSha(root, path, signal),
614
634
  binary: false,
615
635
  tooLarge: false,
616
636
  // Only the unstaged layer can report this, and only it needs to: the
@@ -620,11 +640,11 @@ export class GitWorkbenchService extends TypertRemoteService {
620
640
  }
621
641
 
622
642
  /** The staged layer: diff HEAD→index, target = the index blob. */
623
- private async stagedSides(cwd: string, path: string, signal: AbortSignal): Promise<FileSides> {
643
+ private async stagedSides(root: string, path: string, signal: AbortSignal): Promise<FileSides> {
624
644
  // `:path` resolves the stage-0 index entry: the target text when it exists,
625
645
  // and a failed resolution (no entry) is the empty target, not an error.
626
- const shown = await this.git(cwd, ['show', `:${path}`], signal)
627
- const sha = (await this.git(cwd, ['rev-parse', '--verify', '--quiet', `:${path}`], signal)).stdout.trim()
646
+ const shown = await this.git(root, ['show', `:${path}`], signal)
647
+ const sha = (await this.git(root, ['rev-parse', '--verify', '--quiet', `:${path}`], signal)).stdout.trim()
628
648
  const targetText = shown.exitCode === 0 ? shown.stdout : ''
629
649
  const targetBytes = Buffer.from(targetText, 'utf8')
630
650
  if (targetText.length > 0 && isBinaryPrefix(targetBytes, BINARY_SNIFF_BYTES)) {
@@ -633,7 +653,7 @@ export class GitWorkbenchService extends TypertRemoteService {
633
653
  if (targetTooLarge(targetBytes.length, countBufferLines(targetBytes))) {
634
654
  return { ...emptySides(), tooLarge: true, targetSha: sha }
635
655
  }
636
- const diff = await this.layerDiffText(cwd, path, 'staged', signal)
656
+ const diff = await this.layerDiffText(root, path, 'staged', signal)
637
657
  if (binaryDiffOutput(diff)) {
638
658
  return { ...emptySides(), binary: true, targetSha: sha }
639
659
  }
@@ -655,8 +675,8 @@ export class GitWorkbenchService extends TypertRemoteService {
655
675
  }
656
676
 
657
677
  /** Blob sha of the working-tree file, '' when git cannot hash it. */
658
- private async worktreeBlobSha(cwd: string, path: string, signal: AbortSignal): Promise<string> {
659
- const hashed = await this.git(cwd, ['hash-object', '--', path], signal)
678
+ private async worktreeBlobSha(root: string, path: string, signal: AbortSignal): Promise<string> {
679
+ const hashed = await this.git(root, ['hash-object', '--', path], signal)
660
680
  return hashed.exitCode === 0 ? hashed.stdout.trim() : ''
661
681
  }
662
682
 
@@ -669,19 +689,19 @@ export class GitWorkbenchService extends TypertRemoteService {
669
689
  * its own fresh fetch, so the two ends of the stale comparison are over the
670
690
  * SAME text by construction, not by two fetch sites staying in step.
671
691
  */
672
- private async layerDiffText(cwd: string, path: string, layer: 'unstaged' | 'staged', signal: AbortSignal): Promise<string> {
692
+ private async layerDiffText(root: string, path: string, layer: 'unstaged' | 'staged', signal: AbortSignal): Promise<string> {
673
693
  if (layer === 'staged') {
674
- return (await this.git(cwd, ['diff', '--cached', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout
694
+ return (await this.git(root, ['diff', '--cached', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout
675
695
  }
676
- const diff = (await this.git(cwd, ['diff', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout
696
+ const diff = (await this.git(root, ['diff', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout
677
697
  // Untracked files have no index entry, so `git diff` reports nothing for
678
698
  // them; the synthesized new-file segment is their unstaged diff. The
679
699
  // trailing newline is added here because every diff git prints carries
680
700
  // one: this text is what `applyBlocks` re-emits as a patch file, and a
681
701
  // patch whose last line has no LF is "corrupt patch" to `git apply` —
682
702
  // which is exactly what a real-git drive of the untracked path caught.
683
- if (diff.length === 0 && await this.isUntracked(cwd, path, signal)) {
684
- const segment = await untrackedSegment(cwd, path, SIDE_BYTE_CAP)
703
+ if (diff.length === 0 && await this.isUntracked(root, path, signal)) {
704
+ const segment = await untrackedSegment(root, path, SIDE_BYTE_CAP)
685
705
  return segment === null ? '' : `${segment}\n`
686
706
  }
687
707
  return diff
@@ -700,7 +720,7 @@ export class GitWorkbenchService extends TypertRemoteService {
700
720
  * shared with `fileSides`, and the tmpfile pair. Every failure comes back as
701
721
  * a result — the method never throws across the RPC boundary.
702
722
  *
703
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
723
+ * @param worktreePath - directory the session opened; git runs at its repository root.
704
724
  * @param path - repository-relative path, as the drawer lists it.
705
725
  * @param layer - the layer the block was selected on; the mode decides which
706
726
  * one that may be.
@@ -712,14 +732,16 @@ export class GitWorkbenchService extends TypertRemoteService {
712
732
  */
713
733
  @Remote('applyBlocks')
714
734
  async applyBlocks(worktreePath: string, path: string, layer: string, diffSha: string, lines: readonly number[], mode: string, signal: AbortSignal): Promise<GitOpResult> {
715
- const cwd = this.cwdOf(worktreePath)
735
+ // The repository root — the patch's pathspecs are repository-relative
736
+ // and `git apply` resolves them against the cwd (repo-root.ts).
737
+ const root = await this.rootedDirOf(worktreePath, signal)
716
738
  const io: ApplyBlocksIo = {
717
739
  git: (dir, argv) => this.git(dir, argv, signal),
718
- layerDiff: (file, which) => this.layerDiffText(cwd, file, which === 'staged' ? 'staged' : 'unstaged', signal),
740
+ layerDiff: (file, which) => this.layerDiffText(root, file, which === 'staged' ? 'staged' : 'unstaged', signal),
719
741
  writePatch: writeTmpPatch,
720
742
  dropPatch: dropTmpPatch,
721
743
  }
722
- return runApplyBlocks(io, cwd, path, layer, String(diffSha ?? ''), lines, mode)
744
+ return runApplyBlocks(io, root, path, layer, String(diffSha ?? ''), lines, mode)
723
745
  }
724
746
 
725
747
  /**
@@ -741,7 +763,7 @@ export class GitWorkbenchService extends TypertRemoteService {
741
763
  * check and this method are one thing, and no unchecked write RPC exists or
742
764
  * may be added in this plugin.
743
765
  *
744
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
766
+ * @param worktreePath - directory the session opened; git runs at its repository root.
745
767
  * @param path - repository-relative path, as the drawer lists it.
746
768
  * @param text - the editor buffer, verbatim; written as bytes (LF as given).
747
769
  * @param expectedSha - the `targetSha` the buffer was opened with ('' when
@@ -751,7 +773,10 @@ export class GitWorkbenchService extends TypertRemoteService {
751
773
  */
752
774
  @Remote('writeChecked')
753
775
  async writeChecked(worktreePath: string, path: string, text: string, expectedSha: string, signal: AbortSignal): Promise<WriteResult> {
754
- const cwd = this.cwdOf(worktreePath)
776
+ // The repository root — the save target joins the same base every
777
+ // other path here is relative to, and that base is the root, not the
778
+ // directory the session opened (repo-root.ts).
779
+ const root = await this.rootedDirOf(worktreePath, signal)
755
780
  const io: WriteCheckedIo = {
756
781
  git: (dir, argv) => this.git(dir, argv, signal),
757
782
  exists: async p => {
@@ -768,7 +793,7 @@ export class GitWorkbenchService extends TypertRemoteService {
768
793
  remove: async p => { await rm(p, { force: true }) },
769
794
  delay: ms => new Promise(resolve => { setTimeout(resolve, ms) }),
770
795
  }
771
- return runWriteChecked(io, cwd, path, typeof text === 'string' ? text : '', typeof expectedSha === 'string' ? expectedSha : '')
796
+ return runWriteChecked(io, root, path, typeof text === 'string' ? text : '', typeof expectedSha === 'string' ? expectedSha : '')
772
797
  }
773
798
 
774
799
  /**
@@ -780,7 +805,7 @@ export class GitWorkbenchService extends TypertRemoteService {
780
805
  * rather than missing. Read-only, no index or worktree is touched, so this
781
806
  * needs none of the confirmation machinery the write paths carry.
782
807
  *
783
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
808
+ * @param worktreePath - directory the session opened; git runs at its repository root.
784
809
  * @param path - repository-relative path, as the drawer lists it.
785
810
  * @param signal - abort signal.
786
811
  */
@@ -789,10 +814,13 @@ export class GitWorkbenchService extends TypertRemoteService {
789
814
  if (typeof path !== 'string' || !isSafePathArg(path)) {
790
815
  return { lines: [], truncated: false, error: `unsafe path argument: ${JSON.stringify(path)}` }
791
816
  }
792
- const cwd = this.cwdOf(worktreePath)
817
+ // The repository root: blame's pathspec resolves against the cwd, and
818
+ // from a subdirectory the repository-relative path doubles up
819
+ // (`server/server/f`) into a fatal "no such path" (repo-root.ts).
820
+ const root = await this.rootedDirOf(worktreePath, signal)
793
821
  // `--` keeps a path that looks like a revision from being read as one, as
794
822
  // every other pathspec in this plugin does.
795
- const run = await this.git(cwd, ['blame', '--line-porcelain', '--', path], signal)
823
+ const run = await this.git(root, ['blame', '--line-porcelain', '--', path], signal)
796
824
  if (run.exitCode !== 0) {
797
825
  // An untracked file has no blame, and git says so; that message is the
798
826
  // honest thing to show rather than an empty gutter.
@@ -826,7 +854,7 @@ export class GitWorkbenchService extends TypertRemoteService {
826
854
  *
827
855
  * Read-only — nothing is spawned, nothing is written.
828
856
  *
829
- * @param worktreePath - directory to run in; empty falls back to the host cwd.
857
+ * @param worktreePath - directory the session opened; git runs at its repository root.
830
858
  * @param path - repository-relative path, as the drawer lists it.
831
859
  * @param signal - abort signal.
832
860
  */
@@ -835,7 +863,9 @@ export class GitWorkbenchService extends TypertRemoteService {
835
863
  if (typeof path !== 'string' || !isSafePathArg(path)) {
836
864
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
837
865
  }
838
- const full = join(this.cwdOf(worktreePath), path)
866
+ // The repository root — the image is read from disk at the same base
867
+ // every other path in this plugin is relative to (repo-root.ts).
868
+ const full = join(await this.rootedDirOf(worktreePath, signal), path)
839
869
  let size = 0
840
870
  try {
841
871
  const info = await stat(full)
@@ -871,10 +901,10 @@ export class GitWorkbenchService extends TypertRemoteService {
871
901
  * Those are the files whose diff has to be synthesized rather than asked of
872
902
  * `git diff`, which reports nothing for them.
873
903
  */
874
- private async isUntracked(cwd: string, path: string, signal: AbortSignal): Promise<boolean> {
875
- const listed = await this.git(cwd, ['ls-files', '--', path], signal)
904
+ private async isUntracked(root: string, path: string, signal: AbortSignal): Promise<boolean> {
905
+ const listed = await this.git(root, ['ls-files', '--', path], signal)
876
906
  if (listed.exitCode !== 0 || listed.stdout.trim().length > 0) return false
877
- const head = await this.git(cwd, ['rev-parse', '--verify', '--quiet', `HEAD:${path}`], signal)
907
+ const head = await this.git(root, ['rev-parse', '--verify', '--quiet', `HEAD:${path}`], signal)
878
908
  return head.exitCode !== 0
879
909
  }
880
910
 
@@ -909,11 +939,17 @@ export class GitWorkbenchService extends TypertRemoteService {
909
939
  // empty pane. Against the first parent the answer is well defined and is the
910
940
  // useful one: what this merge brought into the branch it landed on. On a
911
941
  // single-parent commit the flag is a no-op, byte for byte.
942
+ // The repository root: no pathspec here today, but the cache key below
943
+ // must describe the same repository to a later caller, and running from
944
+ // the root is the one rule every read in this plugin follows
945
+ // (repo-root.ts). Resolved after the cache probe so a hit spawns
946
+ // nothing.
947
+ const root = await this.rootedDirOf(worktreePath, signal)
912
948
  const [meta, numstat, nameStatus, patch] = await Promise.all([
913
- this.git(cwd, ['show', hash, '--no-patch', `--format=${LOG_FORMAT}`], signal),
914
- this.git(cwd, ['show', hash, '--first-parent', '--numstat', '--format=', '--no-renames'], signal),
915
- this.git(cwd, ['show', hash, '--first-parent', '--name-status', '--format=', '--no-renames'], signal),
916
- this.git(cwd, ['show', hash, '--first-parent', '--format=', '--no-renames'], signal),
949
+ this.git(root, ['show', hash, '--no-patch', `--format=${LOG_FORMAT}`], signal),
950
+ this.git(root, ['show', hash, '--first-parent', '--numstat', '--format=', '--no-renames'], signal),
951
+ this.git(root, ['show', hash, '--first-parent', '--name-status', '--format=', '--no-renames'], signal),
952
+ this.git(root, ['show', hash, '--first-parent', '--format=', '--no-renames'], signal),
917
953
  ])
918
954
  if (meta.exitCode !== 0) {
919
955
  const detail = meta.stderr.length > 0 ? `: ${meta.stderr}` : ''
@@ -979,6 +1015,10 @@ export class GitWorkbenchService extends TypertRemoteService {
979
1015
  const from = Number.isInteger(skip) && skip >= 0 ? skip : 0
980
1016
  const size = Number.isInteger(limit) && limit > 0 && limit <= HISTORY_PAGE_MAX ? limit : HISTORY_PAGE
981
1017
  const effective = filter ?? emptyLogFilter()
1018
+ // The repository root: a filter's pathspec is repository-relative, and
1019
+ // git resolves pathspecs against the cwd — from a subdirectory a
1020
+ // filtered history silently comes back EMPTY with exit 0 (repo-root.ts).
1021
+ const root = await this.rootedDirOf(worktreePath, signal)
982
1022
  // Reading one row beyond the page answers "is there more" without a second
983
1023
  // traversal of the log.
984
1024
  //
@@ -992,7 +1032,7 @@ export class GitWorkbenchService extends TypertRemoteService {
992
1032
  // Filter args go LAST: their segment ends with `--` + pathspecs, and
993
1033
  // nothing after that separator may be parsed as a flag.
994
1034
  const log = await this.git(
995
- cwd,
1035
+ root,
996
1036
  ['log', target, '--topo-order', `--skip=${from}`, `-${size + 1}`, `--pretty=format:${LOG_FORMAT}`, ...logFilterArgs(effective)],
997
1037
  signal,
998
1038
  )
@@ -1042,8 +1082,12 @@ export class GitWorkbenchService extends TypertRemoteService {
1042
1082
  */
1043
1083
  @Remote('repoTree')
1044
1084
  async repoTree(worktreePath: string, signal: AbortSignal): Promise<{ paths: string[]; truncated: boolean }> {
1045
- const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
1046
- const res = await this.git(cwd, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal)
1085
+ // The repository root: unlike status and numstat, `ls-tree` prints
1086
+ // cwd-RELATIVE paths from a subdirectory every entry would lose the
1087
+ // `server/` prefix and the picker would feed the log filter pathspecs
1088
+ // that match nothing (repo-root.ts).
1089
+ const root = await this.rootedDirOf(worktreePath, signal)
1090
+ const res = await this.git(root, ['ls-tree', '-r', '-z', '--name-only', 'HEAD'], signal)
1047
1091
  const all = res.stdout.split('\0').filter(path => path.length > 0)
1048
1092
  const truncated = all.length > TREE_PATH_CAP
1049
1093
  return { paths: truncated ? all.slice(0, TREE_PATH_CAP) : all, truncated }
@@ -1358,25 +1402,31 @@ export class GitWorkbenchService extends TypertRemoteService {
1358
1402
 
1359
1403
  /**
1360
1404
  * Add paths to the index.
1361
- * @param worktreePath - directory to run in.
1405
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1362
1406
  * @param paths - repository-relative paths; an empty list is refused rather
1363
1407
  * than turned into a whole-tree `git add`.
1364
1408
  * @param signal - abort signal.
1365
1409
  */
1366
1410
  @Remote('stage')
1367
1411
  async stage(worktreePath: string, paths: readonly string[], signal: AbortSignal): Promise<GitOpResult> {
1368
- return this.writeOp(worktreePath, () => stageArgv(asPathList(paths)), signal)
1412
+ // The repository root: `git add` resolves its pathspecs against the cwd,
1413
+ // and from a subdirectory a repository-relative path dies with
1414
+ // "pathspec did not match any files" (repo-root.ts).
1415
+ const root = await this.rootedDirOf(worktreePath, signal)
1416
+ return this.writeOp(root, () => stageArgv(asPathList(paths)), signal)
1369
1417
  }
1370
1418
 
1371
1419
  /**
1372
1420
  * Remove paths from the index, leaving the working tree untouched.
1373
- * @param worktreePath - directory to run in.
1421
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1374
1422
  * @param paths - repository-relative paths.
1375
1423
  * @param signal - abort signal.
1376
1424
  */
1377
1425
  @Remote('unstage')
1378
1426
  async unstage(worktreePath: string, paths: readonly string[], signal: AbortSignal): Promise<GitOpResult> {
1379
- return this.writeOp(worktreePath, () => unstageArgv(asPathList(paths)), signal)
1427
+ // Same rule as `stage` — the pathspecs are repository-relative.
1428
+ const root = await this.rootedDirOf(worktreePath, signal)
1429
+ return this.writeOp(root, () => unstageArgv(asPathList(paths)), signal)
1380
1430
  }
1381
1431
 
1382
1432
  /**
@@ -1388,7 +1438,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1388
1438
  * cannot come back" is exactly the difference the reader is being asked
1389
1439
  * about. So the dialog is built from this, read fresh, rather than from the
1390
1440
  * row that was clicked.
1391
- * @param worktreePath - directory to run in.
1441
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1392
1442
  * @param path - repository-relative path, as the drawer lists it.
1393
1443
  * @param signal - abort signal.
1394
1444
  * @returns the effect and whether it is irreversible; `effect` is absent when
@@ -1401,9 +1451,12 @@ export class GitWorkbenchService extends TypertRemoteService {
1401
1451
  previousPath?: string
1402
1452
  error?: string
1403
1453
  }> {
1454
+ // The repository root — the plan's argv and delete paths are
1455
+ // repository-relative (repo-root.ts).
1456
+ const root = await this.rootedDirOf(worktreePath, signal)
1404
1457
  let plan: DiscardPlan | null
1405
1458
  try {
1406
- plan = await this.planDiscard(worktreePath, path, signal)
1459
+ plan = await this.planDiscard(root, path, signal)
1407
1460
  } catch (error) {
1408
1461
  return { error: error instanceof Error ? error.message : String(error) }
1409
1462
  }
@@ -1427,7 +1480,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1427
1480
  * it. `expectedEffect` is what the reader was shown and agreed to: if the
1428
1481
  * file changed underneath the dialog — staged, edited, reverted by someone
1429
1482
  * else — the freshly derived effect no longer matches and nothing is done.
1430
- * @param worktreePath - directory to run in.
1483
+ * @param worktreePath - directory the session opened; git runs at its repository root.
1431
1484
  * @param path - repository-relative path, as the drawer lists it.
1432
1485
  * @param expectedEffect - the effect the confirmation stated; blank skips
1433
1486
  * the agreement check, which only the reversible
@@ -1437,9 +1490,13 @@ export class GitWorkbenchService extends TypertRemoteService {
1437
1490
  */
1438
1491
  @Remote('discardFile')
1439
1492
  async discardFile(worktreePath: string, path: string, expectedEffect: string | undefined, signal: AbortSignal): Promise<GitOpResult & { effect?: DiscardEffect }> {
1493
+ // The repository root, resolved ONCE for the plan and the steps alike:
1494
+ // every step's argv and delete path came out of a whole-tree porcelain
1495
+ // status, so they are repository-relative (repo-root.ts).
1496
+ const root = await this.rootedDirOf(worktreePath, signal)
1440
1497
  let plan: DiscardPlan | null
1441
1498
  try {
1442
- plan = await this.planDiscard(worktreePath, path, signal)
1499
+ plan = await this.planDiscard(root, path, signal)
1443
1500
  } catch (error) {
1444
1501
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
1445
1502
  }
@@ -1454,10 +1511,9 @@ export class GitWorkbenchService extends TypertRemoteService {
1454
1511
  }
1455
1512
  }
1456
1513
 
1457
- const cwd = this.cwdOf(worktreePath)
1458
1514
  for (const step of plan.steps) {
1459
1515
  if (step.kind === 'git') {
1460
- const result = await this.git(cwd, step.argv, signal)
1516
+ const result = await this.git(root, step.argv, signal)
1461
1517
  const failure = classifyFailure(result.exitCode, result.stderr, result.stdout)
1462
1518
  if (failure !== null) {
1463
1519
  return { ok: false, failure, error: (result.stderr || result.stdout).trim().slice(-1000) }
@@ -1465,7 +1521,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1465
1521
  continue
1466
1522
  }
1467
1523
  try {
1468
- await removePathInside(cwd, step.path)
1524
+ await removePathInside(root, step.path)
1469
1525
  } catch (error) {
1470
1526
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
1471
1527
  }
@@ -1481,12 +1537,14 @@ export class GitWorkbenchService extends TypertRemoteService {
1481
1537
  * reports `D` plus `??` instead — which plans as "restore one, DELETE the
1482
1538
  * other" where the truth is "undo the rename".
1483
1539
  */
1484
- private async planDiscard(worktreePath: string, path: string, signal: AbortSignal): Promise<DiscardPlan | null> {
1540
+ private async planDiscard(root: string, path: string, signal: AbortSignal): Promise<DiscardPlan | null> {
1485
1541
  if (typeof path !== 'string' || !isSafePathArg(path)) {
1486
1542
  throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
1487
1543
  }
1488
- const cwd = this.cwdOf(worktreePath)
1489
- const status = await this.git(cwd, ['status', '--porcelain=v1', '--untracked-files=all'], signal)
1544
+ // Runs at the repository root the caller resolved: the plan's paths
1545
+ // must agree with the directory its steps execute in, and with the
1546
+ // client's `path`, which the drawer lists repository-relative.
1547
+ const status = await this.git(root, ['status', '--porcelain=v1', '--untracked-files=all'], signal)
1490
1548
  if (status.exitCode !== 0) {
1491
1549
  throw new Error((status.stderr || status.stdout).trim().slice(-1000) || 'git status failed')
1492
1550
  }
@@ -1502,7 +1560,8 @@ export class GitWorkbenchService extends TypertRemoteService {
1502
1560
  */
1503
1561
  @Remote('commit')
1504
1562
  async commit(worktreePath: string, message: string, amend: boolean | undefined, signal: AbortSignal): Promise<GitOpResult> {
1505
- return this.writeOp(worktreePath, () => commitArgv(String(message ?? ''), amend === true), signal)
1563
+ // No pathspec in this argv the session's own directory suffices.
1564
+ return this.writeOp(this.cwdOf(worktreePath), () => commitArgv(String(message ?? ''), amend === true), signal)
1506
1565
  }
1507
1566
 
1508
1567
  /**
@@ -1513,7 +1572,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1513
1572
  */
1514
1573
  @Remote('fetch')
1515
1574
  async fetch(worktreePath: string, signal: AbortSignal): Promise<GitOpResult & { tracking?: Tracking }> {
1516
- const result = await this.writeOp(worktreePath, () => fetchArgv(), signal, NETWORK_GRACE_MS)
1575
+ const result = await this.writeOp(this.cwdOf(worktreePath), () => fetchArgv(), signal, NETWORK_GRACE_MS)
1517
1576
  if (!result.ok) return result
1518
1577
  // The point of fetching is the count it produces, so report it in the same
1519
1578
  // round trip rather than making the client ask again.
@@ -1531,7 +1590,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1531
1590
  @Remote('pull')
1532
1591
  async pull(worktreePath: string, mode: string | undefined, signal: AbortSignal): Promise<GitOpResult> {
1533
1592
  const chosen: PullMode = mode === 'rebase' || mode === 'merge' ? mode : 'ff-only'
1534
- return this.writeOp(worktreePath, () => pullArgv(chosen), signal, NETWORK_GRACE_MS)
1593
+ return this.writeOp(this.cwdOf(worktreePath), () => pullArgv(chosen), signal, NETWORK_GRACE_MS)
1535
1594
  }
1536
1595
 
1537
1596
  /**
@@ -1550,12 +1609,18 @@ export class GitWorkbenchService extends TypertRemoteService {
1550
1609
  const tracking = parseTracking(status.stdout)
1551
1610
  if (tracking.detached) return { ok: false, failure: 'unknown', error: 'HEAD is detached; nothing to push' }
1552
1611
  if (tracking.branch.length === 0) return { ok: false, failure: 'unknown', error: 'no branch to push' }
1553
- return this.writeOp(worktreePath, () => pushArgv(tracking.branch, tracking.upstream !== null), signal, NETWORK_GRACE_MS)
1612
+ return this.writeOp(this.cwdOf(worktreePath), () => pushArgv(tracking.branch, tracking.upstream !== null), signal, NETWORK_GRACE_MS)
1554
1613
  }
1555
1614
 
1556
- /** Shared shape for every write op: run it, classify what went wrong. */
1615
+ /** Shared shape for every write op: run it, classify what went wrong.
1616
+ *
1617
+ * Takes the DIRECTORY to run in, already resolved: callers carrying
1618
+ * repository-relative pathspecs pass the rooted directory
1619
+ * ({@link GitWorkbenchService.rootedDirOf}); pathspec-free operations may
1620
+ * pass the session's own directory.
1621
+ */
1557
1622
  private async writeOp(
1558
- worktreePath: string,
1623
+ dir: string,
1559
1624
  build: () => readonly string[],
1560
1625
  signal: AbortSignal,
1561
1626
  graceMs?: number,
@@ -1568,7 +1633,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1568
1633
  // list or a blank commit message takes.
1569
1634
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
1570
1635
  }
1571
- const result = await this.git(this.cwdOf(worktreePath), argv, signal, graceMs)
1636
+ const result = await this.git(dir, argv, signal, graceMs)
1572
1637
  const failure = classifyFailure(result.exitCode, result.stderr, result.stdout)
1573
1638
  if (failure === null) return { ok: true, output: result.stdout.trim().slice(-1000) }
1574
1639
  // The classification is a hint; the real text rides along beside it, because
@@ -1637,10 +1702,25 @@ export class GitWorkbenchService extends TypertRemoteService {
1637
1702
  }
1638
1703
 
1639
1704
  /** Resolve the repo root for a directory (null when not a git repo). Always forward slashes. */
1640
- private async repoRootOf(cwd: string, signal: AbortSignal): Promise<string | null> {
1641
- const out = await this.git(cwd, ['rev-parse', '--show-toplevel'], signal)
1642
- if (out.exitCode !== 0) return null
1643
- return out.stdout.trim().replace(/\\/g, '/') || null
1705
+ private repoRootOf(cwd: string, signal: AbortSignal): Promise<string | null> {
1706
+ return resolveRepoRoot((dir, argv) => this.git(dir, argv, signal), cwd)
1707
+ }
1708
+
1709
+ /**
1710
+ * The directory to run git in and join paths against for a session's
1711
+ * `worktreePath`: the repository ROOT, falling back to the directory
1712
+ * itself outside a repository (the caller's own git run then fails the
1713
+ * way it always did, and that error is the honest one to show).
1714
+ *
1715
+ * The drawer's paths are repository-relative — porcelain status and
1716
+ * `--numstat` print them that way wherever they run — while pathspecs,
1717
+ * `:path` revisions, `hash-object` arguments and `join(dir, path)` all
1718
+ * resolve against the directory a command runs in. Those two halves only
1719
+ * agree at the root, so every method that carries a path runs there.
1720
+ * `repo-root.ts` tells the whole story, with the probes that caught it.
1721
+ */
1722
+ private rootedDirOf(worktreePath: string | undefined, signal: AbortSignal): Promise<string> {
1723
+ return rootedDir((dir, argv) => this.git(dir, argv, signal), this.cwdOf(worktreePath))
1644
1724
  }
1645
1725
 
1646
1726
  /**
@@ -1718,14 +1798,14 @@ interface UntrackedMeasure {
1718
1798
  * count newlines is the expensive half of this pass, and most untracked files
1719
1799
  * never reach the bundled diff. Never throws; an unreadable file reports zero
1720
1800
  * lines and nothing to diff.
1721
- * @param cwd - worktree the path is relative to.
1801
+ * @param root - repository root the path is relative to.
1722
1802
  * @param path - repository-relative file path.
1723
1803
  * @returns the file's line count, binary flag, and whether a diff may be built.
1724
1804
  */
1725
- async function measureUntracked(cwd: string, path: string): Promise<UntrackedMeasure> {
1805
+ async function measureUntracked(root: string, path: string): Promise<UntrackedMeasure> {
1726
1806
  let bytes: Buffer
1727
1807
  try {
1728
- bytes = await readFile(join(cwd, path))
1808
+ bytes = await readFile(join(root, path))
1729
1809
  } catch {
1730
1810
  return { lineCount: 0, binary: false, diffable: false }
1731
1811
  }
@@ -1742,16 +1822,16 @@ async function measureUntracked(cwd: string, path: string): Promise<UntrackedMea
1742
1822
  *
1743
1823
  * `git diff --no-index /dev/null <f>` is NOT used: on Windows git resolves
1744
1824
  * `/dev/null` as a repo-relative path. Never throws.
1745
- * @param cwd - worktree the path is relative to.
1825
+ * @param root - repository root the path is relative to.
1746
1826
  * @param path - repository-relative file path.
1747
1827
  * @param byteCap - refuse files larger than this; defaults to the stats
1748
1828
  * payload's budget, which `fileSides` raises to its own.
1749
1829
  * @returns the segment, or null when the file is missing, binary, or oversized.
1750
1830
  */
1751
- async function untrackedSegment(cwd: string, path: string, byteCap: number = UNTRACKED_FILE_BYTE_CAP): Promise<string | null> {
1831
+ async function untrackedSegment(root: string, path: string, byteCap: number = UNTRACKED_FILE_BYTE_CAP): Promise<string | null> {
1752
1832
  let bytes: Buffer
1753
1833
  try {
1754
- bytes = await readFile(join(cwd, path))
1834
+ bytes = await readFile(join(root, path))
1755
1835
  } catch {
1756
1836
  return null
1757
1837
  }