@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/CHANGELOG.md +13 -0
- package/CHANGELOG_EN.md +13 -0
- package/README.md +51 -0
- package/lib/client.js +346 -308
- package/lib/index.js +163 -85
- package/lib/repo-root.js +60 -0
- package/package.json +5 -5
- package/src/client/DiffViews.tsx +41 -12
- package/src/client/cr-mark.ts +39 -0
- package/src/client/locales.ts +4 -4
- package/src/client/styles/changes.css +8 -0
- package/src/index.ts +164 -84
- package/src/repo-root.ts +66 -0
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
574
|
-
: await this.stagedSides(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
620
|
+
const diff = await this.layerDiffText(root, path, 'unstaged', signal)
|
|
601
621
|
if (binaryDiffOutput(diff)) {
|
|
602
|
-
return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(
|
|
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(
|
|
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(
|
|
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(
|
|
627
|
-
const sha = (await this.git(
|
|
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(
|
|
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(
|
|
659
|
-
const hashed = await this.git(
|
|
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(
|
|
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(
|
|
694
|
+
return (await this.git(root, ['diff', '--cached', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout
|
|
675
695
|
}
|
|
676
|
-
const diff = (await this.git(
|
|
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(
|
|
684
|
-
const segment = await untrackedSegment(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
875
|
-
const listed = await this.git(
|
|
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(
|
|
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(
|
|
914
|
-
this.git(
|
|
915
|
-
this.git(
|
|
916
|
-
this.git(
|
|
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
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1489
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
|
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(
|
|
1805
|
+
async function measureUntracked(root: string, path: string): Promise<UntrackedMeasure> {
|
|
1726
1806
|
let bytes: Buffer
|
|
1727
1807
|
try {
|
|
1728
|
-
bytes = await readFile(join(
|
|
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
|
|
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(
|
|
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(
|
|
1834
|
+
bytes = await readFile(join(root, path))
|
|
1755
1835
|
} catch {
|
|
1756
1836
|
return null
|
|
1757
1837
|
}
|