@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/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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
552
|
-
: await this.stagedSides(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
600
|
+
const diff = await this.layerDiffText(root, path, 'unstaged', signal);
|
|
581
601
|
if (binaryDiffOutput(diff)) {
|
|
582
|
-
return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(
|
|
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(
|
|
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(
|
|
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(
|
|
607
|
-
const sha = (await this.git(
|
|
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(
|
|
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(
|
|
638
|
-
const hashed = await this.git(
|
|
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(
|
|
670
|
+
async layerDiffText(root, path, layer, signal) {
|
|
651
671
|
if (layer === 'staged') {
|
|
652
|
-
return (await this.git(
|
|
672
|
+
return (await this.git(root, ['diff', '--cached', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout;
|
|
653
673
|
}
|
|
654
|
-
const diff = (await this.git(
|
|
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(
|
|
662
|
-
const segment = await untrackedSegment(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
852
|
-
const listed = await this.git(
|
|
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(
|
|
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(
|
|
890
|
-
this.git(
|
|
891
|
-
this.git(
|
|
892
|
-
this.git(
|
|
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(
|
|
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
|
-
|
|
1014
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
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
|
|
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(
|
|
1725
|
+
async function measureUntracked(root, path) {
|
|
1648
1726
|
let bytes;
|
|
1649
1727
|
try {
|
|
1650
|
-
bytes = await readFile(join(
|
|
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
|
|
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(
|
|
1752
|
+
async function untrackedSegment(root, path, byteCap = UNTRACKED_FILE_BYTE_CAP) {
|
|
1675
1753
|
let bytes;
|
|
1676
1754
|
try {
|
|
1677
|
-
bytes = await readFile(join(
|
|
1755
|
+
bytes = await readFile(join(root, path));
|
|
1678
1756
|
}
|
|
1679
1757
|
catch {
|
|
1680
1758
|
return null;
|