@young1lin/dsh-ui-gitworkbench 0.1.4 → 0.1.6
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 +42 -0
- package/CHANGELOG_EN.md +42 -0
- package/lib/apply-blocks.js +159 -0
- package/lib/atomic-json.js +23 -5
- package/lib/blame.js +83 -0
- package/lib/client.js +34811 -11818
- package/lib/fs-remove.js +73 -0
- package/lib/git-ops.js +25 -0
- package/lib/image-sniff.js +197 -0
- package/lib/index.js +404 -32
- package/lib/patch-model.js +223 -0
- package/lib/side-guard.js +55 -0
- package/lib/write-checked.js +164 -0
- package/package.json +7 -1
- package/src/apply-blocks.ts +215 -0
- package/src/atomic-json.ts +29 -5
- package/src/blame.ts +94 -0
- package/src/client/CodeEditor.tsx +317 -0
- package/src/client/FileBrowser.tsx +657 -0
- package/src/client/GitWorkbenchPanel.module.css +453 -7
- package/src/client/GitWorkbenchPanel.tsx +1465 -166
- package/src/client/ImageView.tsx +120 -0
- package/src/client/blame-gutter.ts +108 -0
- package/src/client/blame-view.ts +104 -0
- package/src/client/cm-diff.ts +108 -0
- package/src/client/cm-tokens.ts +79 -0
- package/src/client/diff-nav.ts +198 -0
- package/src/client/discard-flow.ts +82 -0
- package/src/client/file-icon.ts +190 -0
- package/src/client/file-rows.ts +184 -0
- package/src/client/files-place.ts +178 -0
- package/src/client/glyphs.tsx +86 -0
- package/src/client/highlight.ts +25 -0
- package/src/client/idle-value.ts +53 -0
- package/src/client/image-view.ts +106 -0
- package/src/client/indent.ts +74 -0
- package/src/client/index.ts +76 -9
- package/src/client/locales.ts +171 -4
- package/src/client/pane-size.ts +71 -0
- package/src/client/side-edit.ts +244 -0
- package/src/client/side-rows.ts +258 -0
- package/src/client/stable-list.ts +31 -0
- package/src/client/use-change-nav.ts +83 -0
- package/src/client/worktree-view.ts +11 -1
- package/src/fs-remove.ts +76 -0
- package/src/git-ops.ts +36 -1
- package/src/image-sniff.ts +204 -0
- package/src/index.ts +450 -32
- package/src/patch-model.ts +267 -0
- package/src/side-guard.ts +58 -0
- package/src/write-checked.ts +223 -0
package/lib/index.js
CHANGED
|
@@ -76,15 +76,21 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
|
|
|
76
76
|
* @module @young1lin/dsh-ui-gitworkbench
|
|
77
77
|
*/
|
|
78
78
|
import { randomBytes } from 'node:crypto';
|
|
79
|
-
import { mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
80
|
-
import { homedir } from 'node:os';
|
|
81
|
-
import { join
|
|
79
|
+
import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
80
|
+
import { homedir, tmpdir } from 'node:os';
|
|
81
|
+
import { join } from 'node:path';
|
|
82
82
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
83
83
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
84
|
+
import { runApplyBlocks, sha1Hex } from './apply-blocks.js';
|
|
84
85
|
import { saveJsonAtomic } from './atomic-json.js';
|
|
86
|
+
import { runWriteChecked } from './write-checked.js';
|
|
85
87
|
import { CommitPayloadCache, cacheKey } from './commit-cache.js';
|
|
86
|
-
import { NETWORK_GRACE_MS, NON_INTERACTIVE_ENV, capBranches, classifyFailure, clipDiff, commitArgv, countBufferLines, fetchArgv, isBinaryPrefix, isNoMergeBaseError, isSafePathArg, parseNameStatus, parseNumstat, parseStatus, parseTracking, pullArgv, pushArgv, stageArgv, unstageArgv, } from './git-ops.js';
|
|
88
|
+
import { NETWORK_GRACE_MS, NON_INTERACTIVE_ENV, capBranches, classifyFailure, clipDiff, commitArgv, countBufferLines, decodesAsUtf8, fetchArgv, isBinaryPrefix, isNoMergeBaseError, isSafePathArg, parseNameStatus, parseNumstat, parseStatus, parseTracking, pullArgv, pushArgv, stageArgv, unstageArgv, } from './git-ops.js';
|
|
87
89
|
import { planFromStatus, } from './discard-ops.js';
|
|
90
|
+
import { parseBlame } from './blame.js';
|
|
91
|
+
import { removePathInside } from './fs-remove.js';
|
|
92
|
+
import { diffTooLarge, targetTooLarge, SIDE_BYTE_CAP, SIDE_LINE_CAP } from './side-guard.js';
|
|
93
|
+
import { IMAGE_BYTE_CAP, sniffImage } from './image-sniff.js';
|
|
88
94
|
import { LOG_FORMAT, parseLog } from './git-log.js';
|
|
89
95
|
import { emptyLogFilter, logFilterArgs } from './log-filter.js';
|
|
90
96
|
import { parseShortlog } from './shortlog.js';
|
|
@@ -98,6 +104,9 @@ const UNTRACKED_FILE_BYTE_CAP = 1_000_000;
|
|
|
98
104
|
const UNTRACKED_TOTAL_CHAR_CAP = 160_000;
|
|
99
105
|
/** Files with a NUL byte in the first 8k are treated as binary. */
|
|
100
106
|
const BINARY_SNIFF_BYTES = 8_000;
|
|
107
|
+
/** Context radius that makes `git diff` emit ONE hunk covering the whole file —
|
|
108
|
+
* the artifact the side-by-side view aligns its two columns on. */
|
|
109
|
+
const FULL_CONTEXT = 1_000_000;
|
|
101
110
|
/** Untracked files measured at once. Enough to keep the disk busy, few enough
|
|
102
111
|
* that a repository with thousands of them cannot exhaust the file table. */
|
|
103
112
|
const UNTRACKED_READ_CONCURRENCY = 16;
|
|
@@ -125,6 +134,10 @@ const COMMIT_CACHE_CAPACITY = 32;
|
|
|
125
134
|
const COMMIT_DIFF_CACHE_CAPACITY = 128;
|
|
126
135
|
/** Abbreviated or full object name — rejects anything that could read as a git option. */
|
|
127
136
|
const COMMIT_HASH = /^[0-9a-fA-F]{4,40}$/;
|
|
137
|
+
/** A `fileImage` answer that carries no picture, only why. */
|
|
138
|
+
function declined(reason, bytes) {
|
|
139
|
+
return { ok: false, mime: '', kind: '', base64: '', bytes, reason };
|
|
140
|
+
}
|
|
128
141
|
/**
|
|
129
142
|
* Narrow whatever the client sent to a list of path strings.
|
|
130
143
|
*
|
|
@@ -143,6 +156,11 @@ let GitWorkbenchService = (() => {
|
|
|
143
156
|
let _instanceExtraInitializers = [];
|
|
144
157
|
let _stats_decorators;
|
|
145
158
|
let _fileDiff_decorators;
|
|
159
|
+
let _fileSides_decorators;
|
|
160
|
+
let _applyBlocks_decorators;
|
|
161
|
+
let _writeChecked_decorators;
|
|
162
|
+
let _blame_decorators;
|
|
163
|
+
let _fileImage_decorators;
|
|
146
164
|
let _commitStats_decorators;
|
|
147
165
|
let _commits_decorators;
|
|
148
166
|
let _authors_decorators;
|
|
@@ -168,6 +186,11 @@ let GitWorkbenchService = (() => {
|
|
|
168
186
|
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
169
187
|
_stats_decorators = [Remote('stats')];
|
|
170
188
|
_fileDiff_decorators = [Remote('fileDiff')];
|
|
189
|
+
_fileSides_decorators = [Remote('fileSides')];
|
|
190
|
+
_applyBlocks_decorators = [Remote('applyBlocks')];
|
|
191
|
+
_writeChecked_decorators = [Remote('writeChecked')];
|
|
192
|
+
_blame_decorators = [Remote('blame')];
|
|
193
|
+
_fileImage_decorators = [Remote('fileImage')];
|
|
171
194
|
_commitStats_decorators = [Remote('commitStats')];
|
|
172
195
|
_commits_decorators = [Remote('commits')];
|
|
173
196
|
_authors_decorators = [Remote('authors')];
|
|
@@ -190,6 +213,11 @@ let GitWorkbenchService = (() => {
|
|
|
190
213
|
_push_decorators = [Remote('push')];
|
|
191
214
|
__esDecorate(this, null, _stats_decorators, { kind: "method", name: "stats", static: false, private: false, access: { has: obj => "stats" in obj, get: obj => obj.stats }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
192
215
|
__esDecorate(this, null, _fileDiff_decorators, { kind: "method", name: "fileDiff", static: false, private: false, access: { has: obj => "fileDiff" in obj, get: obj => obj.fileDiff }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
216
|
+
__esDecorate(this, null, _fileSides_decorators, { kind: "method", name: "fileSides", static: false, private: false, access: { has: obj => "fileSides" in obj, get: obj => obj.fileSides }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
217
|
+
__esDecorate(this, null, _applyBlocks_decorators, { kind: "method", name: "applyBlocks", static: false, private: false, access: { has: obj => "applyBlocks" in obj, get: obj => obj.applyBlocks }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
218
|
+
__esDecorate(this, null, _writeChecked_decorators, { kind: "method", name: "writeChecked", static: false, private: false, access: { has: obj => "writeChecked" in obj, get: obj => obj.writeChecked }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
219
|
+
__esDecorate(this, null, _blame_decorators, { kind: "method", name: "blame", static: false, private: false, access: { has: obj => "blame" in obj, get: obj => obj.blame }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
220
|
+
__esDecorate(this, null, _fileImage_decorators, { kind: "method", name: "fileImage", static: false, private: false, access: { has: obj => "fileImage" in obj, get: obj => obj.fileImage }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
193
221
|
__esDecorate(this, null, _commitStats_decorators, { kind: "method", name: "commitStats", static: false, private: false, access: { has: obj => "commitStats" in obj, get: obj => obj.commitStats }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
194
222
|
__esDecorate(this, null, _commits_decorators, { kind: "method", name: "commits", static: false, private: false, access: { has: obj => "commits" in obj, get: obj => obj.commits }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
195
223
|
__esDecorate(this, null, _authors_decorators, { kind: "method", name: "authors", static: false, private: false, access: { has: obj => "authors" in obj, get: obj => obj.authors }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
@@ -326,7 +354,7 @@ let GitWorkbenchService = (() => {
|
|
|
326
354
|
const session = exec.agent?.session;
|
|
327
355
|
if (session === undefined)
|
|
328
356
|
return { ok: false, error: 'worktree tools require a calling session' };
|
|
329
|
-
return this.worktreeEnter(session.id, session.header.cwd, args?.name, exec.signal);
|
|
357
|
+
return this.worktreeEnter(session.id, session.header.cwd ?? '', args?.name, exec.signal);
|
|
330
358
|
},
|
|
331
359
|
presentCall: () => ({ card: 'generic', title: 'Enter worktree', kind: 'other' }),
|
|
332
360
|
}));
|
|
@@ -355,7 +383,7 @@ let GitWorkbenchService = (() => {
|
|
|
355
383
|
const session = exec.agent?.session;
|
|
356
384
|
if (session === undefined)
|
|
357
385
|
return { ok: false, error: 'worktree tools require a calling session' };
|
|
358
|
-
return this.worktreeStatus(session.id, session.header.cwd, exec.signal);
|
|
386
|
+
return this.worktreeStatus(session.id, session.header.cwd ?? '', exec.signal);
|
|
359
387
|
},
|
|
360
388
|
presentCall: () => ({ card: 'generic', title: 'Worktree status', kind: 'read' }),
|
|
361
389
|
}));
|
|
@@ -473,6 +501,340 @@ let GitWorkbenchService = (() => {
|
|
|
473
501
|
return { diff: tracked.stdout };
|
|
474
502
|
return { diff: await untrackedSegment(cwd, path) ?? '' };
|
|
475
503
|
}
|
|
504
|
+
/**
|
|
505
|
+
* One layer of one file for the side-by-side diff pane: the layer's
|
|
506
|
+
* full-context diff, the right-hand text the editor starts from, and the
|
|
507
|
+
* shas later mutations check against (plain-identifier params; signal last).
|
|
508
|
+
*
|
|
509
|
+
* The two layers answer different questions about the same file — `unstaged`
|
|
510
|
+
* is index→worktree (the editable side), `staged` is HEAD→index (read-only:
|
|
511
|
+
* editing the index would mean writing a blob with no file behind it) — so
|
|
512
|
+
* the diff, the target text and the target sha each come from that layer's
|
|
513
|
+
* own sources. Untracked files have no index entry, so `git diff` reports
|
|
514
|
+
* nothing for them and the unstaged layer falls back to the synthesized
|
|
515
|
+
* new-file segment `fileDiff` already uses.
|
|
516
|
+
*
|
|
517
|
+
* @param worktreePath - directory to run in; empty falls back to the host cwd.
|
|
518
|
+
* @param path - repository-relative path, as the drawer lists it.
|
|
519
|
+
* @param layer - `unstaged` (index→worktree) or `staged` (HEAD→index).
|
|
520
|
+
* @param signal - abort signal.
|
|
521
|
+
*/
|
|
522
|
+
async fileSides(worktreePath, path, layer, signal) {
|
|
523
|
+
if (layer !== 'unstaged' && layer !== 'staged') {
|
|
524
|
+
throw new Error(`unknown layer "${String(layer)}"; expected 'unstaged' or 'staged'`);
|
|
525
|
+
}
|
|
526
|
+
if (typeof path !== 'string' || !isSafePathArg(path)) {
|
|
527
|
+
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
|
|
528
|
+
}
|
|
529
|
+
const cwd = this.cwdOf(worktreePath);
|
|
530
|
+
return layer === 'unstaged'
|
|
531
|
+
? await this.unstagedSides(cwd, path, signal)
|
|
532
|
+
: await this.stagedSides(cwd, path, signal);
|
|
533
|
+
}
|
|
534
|
+
/** The unstaged layer: diff index→worktree, target = the working-tree file. */
|
|
535
|
+
async unstagedSides(cwd, path, signal) {
|
|
536
|
+
// Size guard first, off the stat rather than a read: declining a file past
|
|
537
|
+
// the cap must not mean loading a pathological one whole first. Bytes are
|
|
538
|
+
// all a stat knows; the line half of the guard needs the read below.
|
|
539
|
+
try {
|
|
540
|
+
const info = await stat(join(cwd, path));
|
|
541
|
+
if (info.isFile() && targetTooLarge(info.size, 0))
|
|
542
|
+
return { ...emptySides(), tooLarge: true };
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
// Missing file: deleted in the working tree, which the diff below states.
|
|
546
|
+
}
|
|
547
|
+
let bytes = null;
|
|
548
|
+
try {
|
|
549
|
+
bytes = await readFile(join(cwd, path));
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
bytes = null;
|
|
553
|
+
}
|
|
554
|
+
if (bytes !== null && isBinaryPrefix(bytes, BINARY_SNIFF_BYTES)) {
|
|
555
|
+
return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(cwd, path, signal) };
|
|
556
|
+
}
|
|
557
|
+
if (bytes !== null && targetTooLarge(bytes.length, countBufferLines(bytes))) {
|
|
558
|
+
return { ...emptySides(), tooLarge: true };
|
|
559
|
+
}
|
|
560
|
+
const diff = await this.layerDiffText(cwd, path, 'unstaged', signal);
|
|
561
|
+
if (binaryDiffOutput(diff)) {
|
|
562
|
+
return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(cwd, path, signal) };
|
|
563
|
+
}
|
|
564
|
+
// The diff half of the guard, AFTER the artifact exists: the target-side
|
|
565
|
+
// checks above cannot see a worktree-deleted large file (no target to
|
|
566
|
+
// measure) or a huge left side behind a small target — but the patch text
|
|
567
|
+
// carries both, and it is the payload being bounded.
|
|
568
|
+
if (diffTooLarge(diff))
|
|
569
|
+
return { ...emptySides(), tooLarge: true };
|
|
570
|
+
return {
|
|
571
|
+
diff,
|
|
572
|
+
diffSha: sha1Hex(diff),
|
|
573
|
+
targetText: bytes === null ? '' : bytes.toString('utf8'),
|
|
574
|
+
targetSha: bytes === null ? '' : await this.worktreeBlobSha(cwd, path, signal),
|
|
575
|
+
binary: false,
|
|
576
|
+
tooLarge: false,
|
|
577
|
+
// Only the unstaged layer can report this, and only it needs to: the
|
|
578
|
+
// editor edits the working tree, and the staged layer is read-only.
|
|
579
|
+
lossyEncoding: bytes !== null && !decodesAsUtf8(bytes),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/** The staged layer: diff HEAD→index, target = the index blob. */
|
|
583
|
+
async stagedSides(cwd, path, signal) {
|
|
584
|
+
// `:path` resolves the stage-0 index entry: the target text when it exists,
|
|
585
|
+
// and a failed resolution (no entry) is the empty target, not an error.
|
|
586
|
+
const shown = await this.git(cwd, ['show', `:${path}`], signal);
|
|
587
|
+
const sha = (await this.git(cwd, ['rev-parse', '--verify', '--quiet', `:${path}`], signal)).stdout.trim();
|
|
588
|
+
const targetText = shown.exitCode === 0 ? shown.stdout : '';
|
|
589
|
+
const targetBytes = Buffer.from(targetText, 'utf8');
|
|
590
|
+
if (targetText.length > 0 && isBinaryPrefix(targetBytes, BINARY_SNIFF_BYTES)) {
|
|
591
|
+
return { ...emptySides(), binary: true, targetSha: sha };
|
|
592
|
+
}
|
|
593
|
+
if (targetTooLarge(targetBytes.length, countBufferLines(targetBytes))) {
|
|
594
|
+
return { ...emptySides(), tooLarge: true, targetSha: sha };
|
|
595
|
+
}
|
|
596
|
+
const diff = await this.layerDiffText(cwd, path, 'staged', signal);
|
|
597
|
+
if (binaryDiffOutput(diff)) {
|
|
598
|
+
return { ...emptySides(), binary: true, targetSha: sha };
|
|
599
|
+
}
|
|
600
|
+
// Same reasoning as the unstaged layer's post-diff check: a huge HEAD side
|
|
601
|
+
// behind a small index target passes the target guard while the patch
|
|
602
|
+
// still carries the whole old file.
|
|
603
|
+
if (diffTooLarge(diff)) {
|
|
604
|
+
return { ...emptySides(), tooLarge: true, targetSha: sha };
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
diff,
|
|
608
|
+
diffSha: sha1Hex(diff),
|
|
609
|
+
targetText,
|
|
610
|
+
targetSha: sha,
|
|
611
|
+
binary: false,
|
|
612
|
+
tooLarge: false,
|
|
613
|
+
lossyEncoding: false,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
/** Blob sha of the working-tree file, '' when git cannot hash it. */
|
|
617
|
+
async worktreeBlobSha(cwd, path, signal) {
|
|
618
|
+
const hashed = await this.git(cwd, ['hash-object', '--', path], signal);
|
|
619
|
+
return hashed.exitCode === 0 ? hashed.stdout.trim() : '';
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* The layer's full-context diff — the one artifact the side pane aligns its
|
|
623
|
+
* rows on and `applyBlocks` re-checks its sha against.
|
|
624
|
+
*
|
|
625
|
+
* Both callers go through here by design: `fileSides` stamps the text it
|
|
626
|
+
* returns with {@link sha1Hex} and `applyBlocks` re-derives the stamp over
|
|
627
|
+
* its own fresh fetch, so the two ends of the stale comparison are over the
|
|
628
|
+
* SAME text by construction, not by two fetch sites staying in step.
|
|
629
|
+
*/
|
|
630
|
+
async layerDiffText(cwd, path, layer, signal) {
|
|
631
|
+
if (layer === 'staged') {
|
|
632
|
+
return (await this.git(cwd, ['diff', '--cached', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout;
|
|
633
|
+
}
|
|
634
|
+
const diff = (await this.git(cwd, ['diff', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout;
|
|
635
|
+
// Untracked files have no index entry, so `git diff` reports nothing for
|
|
636
|
+
// them; the synthesized new-file segment is their unstaged diff. The
|
|
637
|
+
// trailing newline is added here because every diff git prints carries
|
|
638
|
+
// one: this text is what `applyBlocks` re-emits as a patch file, and a
|
|
639
|
+
// patch whose last line has no LF is "corrupt patch" to `git apply` —
|
|
640
|
+
// which is exactly what a real-git drive of the untracked path caught.
|
|
641
|
+
if (diff.length === 0 && await this.isUntracked(cwd, path, signal)) {
|
|
642
|
+
const segment = await untrackedSegment(cwd, path, SIDE_BYTE_CAP);
|
|
643
|
+
return segment === null ? '' : `${segment}\n`;
|
|
644
|
+
}
|
|
645
|
+
return diff;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Apply one change block of a side-by-side diff: stage it into the index,
|
|
649
|
+
* unstage it back out, or roll it back out of the working tree
|
|
650
|
+
* (plain-identifier params; signal last).
|
|
651
|
+
*
|
|
652
|
+
* The client sends a selection — `path`, `layer`, the `diffSha` of the diff
|
|
653
|
+
* the pane rendered, and the block's hunk-line indices — never patch text.
|
|
654
|
+
* The sequence itself (stale check, emission, `--check`, apply, tmpfile
|
|
655
|
+
* cleanup) lives in `apply-blocks.ts`, where vitest can drive it against a
|
|
656
|
+
* real git; this method only binds the host's git helper, the layer fetch
|
|
657
|
+
* shared with `fileSides`, and the tmpfile pair. Every failure comes back as
|
|
658
|
+
* a result — the method never throws across the RPC boundary.
|
|
659
|
+
*
|
|
660
|
+
* @param worktreePath - directory to run in; empty falls back to the host cwd.
|
|
661
|
+
* @param path - repository-relative path, as the drawer lists it.
|
|
662
|
+
* @param layer - the layer the block was selected on; the mode decides which
|
|
663
|
+
* one that may be.
|
|
664
|
+
* @param diffSha - sha of the diff the pane rendered, re-derived and compared.
|
|
665
|
+
* @param lines - hunk-line indices of the block, as `side-rows.blockLines`
|
|
666
|
+
* produced them client-side.
|
|
667
|
+
* @param mode - `stage` | `unstage` | `discard`.
|
|
668
|
+
* @param signal - abort signal.
|
|
669
|
+
*/
|
|
670
|
+
async applyBlocks(worktreePath, path, layer, diffSha, lines, mode, signal) {
|
|
671
|
+
const cwd = this.cwdOf(worktreePath);
|
|
672
|
+
const io = {
|
|
673
|
+
git: (dir, argv) => this.git(dir, argv, signal),
|
|
674
|
+
layerDiff: (file, which) => this.layerDiffText(cwd, file, which === 'staged' ? 'staged' : 'unstaged', signal),
|
|
675
|
+
writePatch: writeTmpPatch,
|
|
676
|
+
dropPatch: dropTmpPatch,
|
|
677
|
+
};
|
|
678
|
+
return runApplyBlocks(io, cwd, path, layer, String(diffSha ?? ''), lines, mode);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Save the side-by-side editor's buffer over the working-tree file it was
|
|
682
|
+
* opened from — the editable diff's one write (plain-identifier params;
|
|
683
|
+
* signal last).
|
|
684
|
+
*
|
|
685
|
+
* The buffer travels with the blob sha the editor opened with, and the host
|
|
686
|
+
* re-derives that sha from git at the moment of the write: a file that moved
|
|
687
|
+
* underneath the editor — an agent's write, another session's save — makes
|
|
688
|
+
* the save refuse with `failure: 'stale'` and NOTHING is written. The whole
|
|
689
|
+
* sequence (path lock, sha refusal, atomic temp+rename write, the fresh sha
|
|
690
|
+
* the next save checks against) lives in `write-checked.ts`, where vitest
|
|
691
|
+
* drives it against a real git; this method binds the host's git helper and
|
|
692
|
+
* the filesystem calls, plus the stat that keeps "file absent" from being
|
|
693
|
+
* read off a hash spawn's failure. Never throws across the RPC boundary.
|
|
694
|
+
*
|
|
695
|
+
* This is deliberately NOT a `writeFile(path, content)` primitive: the sha
|
|
696
|
+
* check and this method are one thing, and no unchecked write RPC exists or
|
|
697
|
+
* may be added in this plugin.
|
|
698
|
+
*
|
|
699
|
+
* @param worktreePath - directory to run in; empty falls back to the host cwd.
|
|
700
|
+
* @param path - repository-relative path, as the drawer lists it.
|
|
701
|
+
* @param text - the editor buffer, verbatim; written as bytes (LF as given).
|
|
702
|
+
* @param expectedSha - the `targetSha` the buffer was opened with ('' when
|
|
703
|
+
* the file did not exist then), or the sha a successful
|
|
704
|
+
* save last returned.
|
|
705
|
+
* @param signal - abort signal.
|
|
706
|
+
*/
|
|
707
|
+
async writeChecked(worktreePath, path, text, expectedSha, signal) {
|
|
708
|
+
const cwd = this.cwdOf(worktreePath);
|
|
709
|
+
const io = {
|
|
710
|
+
git: (dir, argv) => this.git(dir, argv, signal),
|
|
711
|
+
exists: async (p) => {
|
|
712
|
+
try {
|
|
713
|
+
await stat(p);
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
readBytes: p => readFile(p),
|
|
721
|
+
writeBytes: async (p, bytes) => { await writeFile(p, bytes); },
|
|
722
|
+
rename: async (from, to) => { await rename(from, to); },
|
|
723
|
+
remove: async (p) => { await rm(p, { force: true }); },
|
|
724
|
+
delay: ms => new Promise(resolve => { setTimeout(resolve, ms); }),
|
|
725
|
+
};
|
|
726
|
+
return runWriteChecked(io, cwd, path, typeof text === 'string' ? text : '', typeof expectedSha === 'string' ? expectedSha : '');
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* One file's provenance, line by line — the side pane's blame gutter
|
|
730
|
+
* (plain-identifier params; signal last).
|
|
731
|
+
*
|
|
732
|
+
* Blames the WORKING TREE file, which is what the reader is looking at and
|
|
733
|
+
* what IDEA annotates: lines the reader has not committed come back flagged
|
|
734
|
+
* rather than missing. Read-only, no index or worktree is touched, so this
|
|
735
|
+
* needs none of the confirmation machinery the write paths carry.
|
|
736
|
+
*
|
|
737
|
+
* @param worktreePath - directory to run in; empty falls back to the host cwd.
|
|
738
|
+
* @param path - repository-relative path, as the drawer lists it.
|
|
739
|
+
* @param signal - abort signal.
|
|
740
|
+
*/
|
|
741
|
+
async blame(worktreePath, path, signal) {
|
|
742
|
+
if (typeof path !== 'string' || !isSafePathArg(path)) {
|
|
743
|
+
return { lines: [], truncated: false, error: `unsafe path argument: ${JSON.stringify(path)}` };
|
|
744
|
+
}
|
|
745
|
+
const cwd = this.cwdOf(worktreePath);
|
|
746
|
+
// `--` keeps a path that looks like a revision from being read as one, as
|
|
747
|
+
// every other pathspec in this plugin does.
|
|
748
|
+
const run = await this.git(cwd, ['blame', '--line-porcelain', '--', path], signal);
|
|
749
|
+
if (run.exitCode !== 0) {
|
|
750
|
+
// An untracked file has no blame, and git says so; that message is the
|
|
751
|
+
// honest thing to show rather than an empty gutter.
|
|
752
|
+
return { lines: [], truncated: false, error: (run.stderr || run.stdout).trim().slice(-500) };
|
|
753
|
+
}
|
|
754
|
+
const all = parseBlame(run.stdout);
|
|
755
|
+
// The same line cap the side pane declines a file at: past it the gutter
|
|
756
|
+
// is a payload nobody reads to the end of.
|
|
757
|
+
if (all.length > SIDE_LINE_CAP)
|
|
758
|
+
return { lines: all.slice(0, SIDE_LINE_CAP), truncated: true };
|
|
759
|
+
return { lines: all, truncated: false };
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* One working-tree file's bytes, when those bytes really are an image.
|
|
763
|
+
*
|
|
764
|
+
* The Files tab's fallback for a picture used to be "binary file — no text
|
|
765
|
+
* diff", which is true and useless: a repository's icons and screenshots are
|
|
766
|
+
* content, and a browser is already the best image viewer on the machine.
|
|
767
|
+
*
|
|
768
|
+
* The EXTENSION does not decide. It cannot: a `.png` is a filename, and a
|
|
769
|
+
* view that trusted it would hand the browser whatever bytes happened to be
|
|
770
|
+
* under that name. {@link sniffImage} reads the signature the format's own
|
|
771
|
+
* specification mandates, and a file that fails it comes back `notImage` so
|
|
772
|
+
* the client falls back to the ordinary text path — a mislabelled file still
|
|
773
|
+
* opens, as itself.
|
|
774
|
+
*
|
|
775
|
+
* The size check runs off the stat rather than the read, the same way
|
|
776
|
+
* `fileSides` does it: declining an oversized file must not mean loading it
|
|
777
|
+
* whole first. Base64 rather than a binary frame because the RPC channel is
|
|
778
|
+
* JSON; the third it adds is why {@link IMAGE_BYTE_CAP} sits where it does.
|
|
779
|
+
*
|
|
780
|
+
* Read-only — nothing is spawned, nothing is written.
|
|
781
|
+
*
|
|
782
|
+
* @param worktreePath - directory to run in; empty falls back to the host cwd.
|
|
783
|
+
* @param path - repository-relative path, as the drawer lists it.
|
|
784
|
+
* @param signal - abort signal.
|
|
785
|
+
*/
|
|
786
|
+
async fileImage(worktreePath, path, signal) {
|
|
787
|
+
if (typeof path !== 'string' || !isSafePathArg(path)) {
|
|
788
|
+
throw new Error(`unsafe path argument: ${JSON.stringify(path)}`);
|
|
789
|
+
}
|
|
790
|
+
const full = join(this.cwdOf(worktreePath), path);
|
|
791
|
+
let size = 0;
|
|
792
|
+
try {
|
|
793
|
+
const info = await stat(full);
|
|
794
|
+
if (!info.isFile())
|
|
795
|
+
return declined('missing', 0);
|
|
796
|
+
size = info.size;
|
|
797
|
+
}
|
|
798
|
+
catch {
|
|
799
|
+
return declined('missing', 0);
|
|
800
|
+
}
|
|
801
|
+
if (size > IMAGE_BYTE_CAP)
|
|
802
|
+
return declined('tooLarge', size);
|
|
803
|
+
let bytes;
|
|
804
|
+
try {
|
|
805
|
+
bytes = await readFile(full);
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
return declined('missing', 0);
|
|
809
|
+
}
|
|
810
|
+
// Re-checked against the bytes actually read: the stat above is a separate
|
|
811
|
+
// syscall, and the file can have grown between the two.
|
|
812
|
+
if (bytes.length > IMAGE_BYTE_CAP)
|
|
813
|
+
return declined('tooLarge', bytes.length);
|
|
814
|
+
const found = sniffImage(bytes);
|
|
815
|
+
if (found === null)
|
|
816
|
+
return declined('notImage', bytes.length);
|
|
817
|
+
return {
|
|
818
|
+
ok: true,
|
|
819
|
+
mime: found.mime,
|
|
820
|
+
kind: found.kind,
|
|
821
|
+
base64: bytes.toString('base64'),
|
|
822
|
+
bytes: bytes.length,
|
|
823
|
+
reason: '',
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Whether git has never seen this path: no index entry and no HEAD entry.
|
|
828
|
+
* Those are the files whose diff has to be synthesized rather than asked of
|
|
829
|
+
* `git diff`, which reports nothing for them.
|
|
830
|
+
*/
|
|
831
|
+
async isUntracked(cwd, path, signal) {
|
|
832
|
+
const listed = await this.git(cwd, ['ls-files', '--', path], signal);
|
|
833
|
+
if (listed.exitCode !== 0 || listed.stdout.trim().length > 0)
|
|
834
|
+
return false;
|
|
835
|
+
const head = await this.git(cwd, ['rev-parse', '--verify', '--quiet', `HEAD:${path}`], signal);
|
|
836
|
+
return head.exitCode !== 0;
|
|
837
|
+
}
|
|
476
838
|
/**
|
|
477
839
|
* One commit's change set, in the SAME {@link WorkbenchStats} shape as the working-tree
|
|
478
840
|
* view so the drawer's tree and diff panes render it with no separate code path.
|
|
@@ -1034,7 +1396,7 @@ let GitWorkbenchService = (() => {
|
|
|
1034
1396
|
continue;
|
|
1035
1397
|
}
|
|
1036
1398
|
try {
|
|
1037
|
-
await
|
|
1399
|
+
await removePathInside(cwd, step.path);
|
|
1038
1400
|
}
|
|
1039
1401
|
catch (error) {
|
|
1040
1402
|
return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) };
|
|
@@ -1061,29 +1423,6 @@ let GitWorkbenchService = (() => {
|
|
|
1061
1423
|
}
|
|
1062
1424
|
return planFromStatus(status.stdout, path);
|
|
1063
1425
|
}
|
|
1064
|
-
/**
|
|
1065
|
-
* Delete one file, having proven it is inside the worktree.
|
|
1066
|
-
*
|
|
1067
|
-
* `isSafeRelativePath` already rejected traversal in the plan, so this is the
|
|
1068
|
-
* second lock rather than the only one: it re-checks the RESOLVED path,
|
|
1069
|
-
* which is the form the filesystem actually acts on. `force` makes an absent
|
|
1070
|
-
* file a success — the reader asked for it to be gone, and it is.
|
|
1071
|
-
*
|
|
1072
|
-
* A symlinked directory inside the worktree could still point outward; that
|
|
1073
|
-
* is a repository someone already has write access to, and resolving link
|
|
1074
|
-
* targets on every delete would cost a stat per segment for a case git
|
|
1075
|
-
* itself does not defend against.
|
|
1076
|
-
*/
|
|
1077
|
-
async removeInside(cwd, relative) {
|
|
1078
|
-
const root = resolve(cwd);
|
|
1079
|
-
const target = resolve(root, relative);
|
|
1080
|
-
if (target !== root && !target.startsWith(root + sep)) {
|
|
1081
|
-
throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`);
|
|
1082
|
-
}
|
|
1083
|
-
if (target === root)
|
|
1084
|
-
throw new Error('refusing to delete the worktree root');
|
|
1085
|
-
await rm(target, { force: true });
|
|
1086
|
-
}
|
|
1087
1426
|
/**
|
|
1088
1427
|
* Commit what is in the index.
|
|
1089
1428
|
* @param worktreePath - directory to run in.
|
|
@@ -1308,9 +1647,11 @@ async function measureUntracked(cwd, path) {
|
|
|
1308
1647
|
* `/dev/null` as a repo-relative path. Never throws.
|
|
1309
1648
|
* @param cwd - worktree the path is relative to.
|
|
1310
1649
|
* @param path - repository-relative file path.
|
|
1650
|
+
* @param byteCap - refuse files larger than this; defaults to the stats
|
|
1651
|
+
* payload's budget, which `fileSides` raises to its own.
|
|
1311
1652
|
* @returns the segment, or null when the file is missing, binary, or oversized.
|
|
1312
1653
|
*/
|
|
1313
|
-
async function untrackedSegment(cwd, path) {
|
|
1654
|
+
async function untrackedSegment(cwd, path, byteCap = UNTRACKED_FILE_BYTE_CAP) {
|
|
1314
1655
|
let bytes;
|
|
1315
1656
|
try {
|
|
1316
1657
|
bytes = await readFile(join(cwd, path));
|
|
@@ -1320,7 +1661,7 @@ async function untrackedSegment(cwd, path) {
|
|
|
1320
1661
|
}
|
|
1321
1662
|
if (isBinaryPrefix(bytes, BINARY_SNIFF_BYTES))
|
|
1322
1663
|
return null;
|
|
1323
|
-
if (bytes.length >
|
|
1664
|
+
if (bytes.length > byteCap)
|
|
1324
1665
|
return null;
|
|
1325
1666
|
const lines = countBufferLines(bytes);
|
|
1326
1667
|
const text = bytes.toString('utf8');
|
|
@@ -1347,6 +1688,37 @@ function randomHex(digits) {
|
|
|
1347
1688
|
const bytes = randomBytes(Math.ceil(digits / 2));
|
|
1348
1689
|
return bytes.toString('hex').slice(0, digits);
|
|
1349
1690
|
}
|
|
1691
|
+
/**
|
|
1692
|
+
* Park patch text where git can read it: a uniquely named file under the OS
|
|
1693
|
+
* temp dir, passed to `git apply` as its last argument.
|
|
1694
|
+
*
|
|
1695
|
+
* `git()` spawns with `stdin: 'ignore'`, so a tmpfile — not a pipe — is how a
|
|
1696
|
+
* patch reaches git without changing that helper, and the temp dir keeps patch
|
|
1697
|
+
* text (which can be a whole file's worth of context) out of the repository.
|
|
1698
|
+
* `apply-blocks.ts` deletes what this wrote from a `finally`, whichever way
|
|
1699
|
+
* the apply ended.
|
|
1700
|
+
*/
|
|
1701
|
+
async function writeTmpPatch(text) {
|
|
1702
|
+
const file = join(tmpdir(), `gw-apply-${process.pid}-${randomHex(8)}.patch`);
|
|
1703
|
+
await writeFile(file, text, 'utf8');
|
|
1704
|
+
return file;
|
|
1705
|
+
}
|
|
1706
|
+
/** Remove a tmpfile `writeTmpPatch` made; a file already gone is a success. */
|
|
1707
|
+
async function dropTmpPatch(file) {
|
|
1708
|
+
await rm(file, { force: true });
|
|
1709
|
+
}
|
|
1710
|
+
/** The `fileSides` payload for a file with nothing to show: no diff, no target. */
|
|
1711
|
+
function emptySides() {
|
|
1712
|
+
return { diff: '', diffSha: sha1Hex(''), targetText: '', targetSha: '', binary: false, tooLarge: false, lossyEncoding: false };
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* Whether a diff git printed says `Binary files … differ` instead of hunks.
|
|
1716
|
+
* The line starts at column 0 — inside a hunk every body line carries a
|
|
1717
|
+
* marker, so a text file that mentions "Binary files" cannot match.
|
|
1718
|
+
*/
|
|
1719
|
+
function binaryDiffOutput(diff) {
|
|
1720
|
+
return /^Binary files /m.test(diff);
|
|
1721
|
+
}
|
|
1350
1722
|
function emptyStats(worktreePath) {
|
|
1351
1723
|
return {
|
|
1352
1724
|
worktreePath, branch: '', ahead: 0, behind: 0, detached: false,
|