@openvcs/git-plugin 0.3.2-beta.117 → 0.3.2-beta.127
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/ARCHITECTURE.md +6 -0
- package/bin/git.js +203 -7
- package/bin/plugin-request-handler.js +6 -1
- package/package.json +2 -2
- package/src/git.ts +218 -9
- package/src/plugin-request-handler.ts +11 -2
- package/src/plugin-types.ts +2 -0
package/ARCHITECTURE.md
CHANGED
|
@@ -32,8 +32,14 @@ through `@openvcs/sdk/runtime` delegates and exposes a single VCS backend id:
|
|
|
32
32
|
- Unmerged porcelain states such as `UU`, `AA`, and `DD` are normalized to `U`
|
|
33
33
|
in status payloads so the client opens merge-conflict UI instead of a normal
|
|
34
34
|
diff view.
|
|
35
|
+
- Status payload entries now attach optional `binary` metadata derived from the
|
|
36
|
+
current worktree bytes when the path exists, so the client can avoid reading
|
|
37
|
+
obvious binary files as text during normal file selection.
|
|
35
38
|
- File diffs first read worktree changes and fall back to `git diff --cached`
|
|
36
39
|
for staged-only files so selected staged changes still render textual hunks.
|
|
40
|
+
- `vcs.diff_file` returns a structured `{ lines, binary }` payload. The plugin
|
|
41
|
+
marks binary explicitly when Git emits binary patch markers, and otherwise
|
|
42
|
+
falls back to a lightweight worktree byte sniff when diff output is empty.
|
|
37
43
|
- Commit history uses the current `HEAD` or requested revision instead of
|
|
38
44
|
`git log --all`, so internal refs such as `refs/stash` are not shown as normal
|
|
39
45
|
history entries.
|
package/bin/git.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Copyright © 2025-2026 OpenVCS Contributors
|
|
2
2
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { rmSync } from 'node:fs';
|
|
4
|
+
import { readFileSync, rmSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { pluginError } from '@openvcs/sdk/runtime';
|
|
7
7
|
import { applySubmoduleStatusHints, asString, buildFetchArgs, buildPullArgs, buildPushArgs, buildSubmoduleUpdateArgs, parseCommits, parseStatusOutput, } from './plugin-helpers.js';
|
|
@@ -63,7 +63,17 @@ export class GitCommand {
|
|
|
63
63
|
status() {
|
|
64
64
|
const result = this.run(['status', '--porcelain=1', '--branch', '-z', '-uall']);
|
|
65
65
|
const parsed = applySubmoduleStatusHints(parseStatusOutput(result.stdout), this.listSubmodulePaths());
|
|
66
|
-
return {
|
|
66
|
+
return {
|
|
67
|
+
...parsed,
|
|
68
|
+
payload: {
|
|
69
|
+
...parsed.payload,
|
|
70
|
+
files: parsed.payload.files.map((file) => ({
|
|
71
|
+
...file,
|
|
72
|
+
binary: this.detectStatusFileBinary(file),
|
|
73
|
+
})),
|
|
74
|
+
},
|
|
75
|
+
exitCode: result.status,
|
|
76
|
+
};
|
|
67
77
|
}
|
|
68
78
|
currentBranch() {
|
|
69
79
|
const result = this.runChecked(['rev-parse', '--abbrev-ref', 'HEAD'], 'git-branch-failed');
|
|
@@ -280,6 +290,55 @@ export class GitCommand {
|
|
|
280
290
|
listSubmodulePaths() {
|
|
281
291
|
return new Set(this.readSubmoduleConfig().byPath.keys());
|
|
282
292
|
}
|
|
293
|
+
/** Splits diff stdout without manufacturing a blank line for empty output. */
|
|
294
|
+
splitDiffLines(output) {
|
|
295
|
+
const normalized = output.trimEnd();
|
|
296
|
+
return normalized.length > 0 ? normalized.split('\n') : [];
|
|
297
|
+
}
|
|
298
|
+
/** Returns true when Git already reported the diff target as binary. */
|
|
299
|
+
isBinaryDiffOutput(output) {
|
|
300
|
+
const lines = this.splitDiffLines(output);
|
|
301
|
+
if (lines.length === 0) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
return lines.some((line) => {
|
|
305
|
+
const candidate = String(line || '');
|
|
306
|
+
return /^binary files /i.test(candidate)
|
|
307
|
+
|| /^git binary patch/i.test(candidate)
|
|
308
|
+
|| /^literal /i.test(candidate);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
/** Returns binary classification for a status entry when worktree bytes are available. */
|
|
312
|
+
detectStatusFileBinary(file) {
|
|
313
|
+
return this.readWorktreeBinaryFlag(file.path);
|
|
314
|
+
}
|
|
315
|
+
/** Reads worktree bytes and classifies likely binary content, or `null` when unavailable. */
|
|
316
|
+
readWorktreeBinaryFlag(path) {
|
|
317
|
+
const trimmedPath = String(path || '').trim();
|
|
318
|
+
if (!trimmedPath) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
const contents = readFileSync(join(this.cwd, trimmedPath));
|
|
323
|
+
return this.isBinaryBuffer(contents);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/** Applies a lightweight byte sniff that keeps UTF-16 BOM text out of binary placeholders. */
|
|
330
|
+
isBinaryBuffer(contents) {
|
|
331
|
+
if (contents.length === 0) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
const hasUtf16LeBom = contents.length >= 2 && contents[0] === 0xff && contents[1] === 0xfe;
|
|
335
|
+
const hasUtf16BeBom = contents.length >= 2 && contents[0] === 0xfe && contents[1] === 0xff;
|
|
336
|
+
if (hasUtf16LeBom || hasUtf16BeBom) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
const sample = contents.subarray(0, Math.min(contents.length, 8192));
|
|
340
|
+
return sample.includes(0);
|
|
341
|
+
}
|
|
283
342
|
listSubmodules() {
|
|
284
343
|
const { byPath: configByPath } = this.readSubmoduleConfig();
|
|
285
344
|
const statusResult = this.run(['submodule', 'status', '--recursive']);
|
|
@@ -363,7 +422,15 @@ export class GitCommand {
|
|
|
363
422
|
.stdout;
|
|
364
423
|
const worktreeDiff = this.runChecked(['diff', '--no-ext-diff', '--', path], 'git-diff-failed')
|
|
365
424
|
.stdout;
|
|
366
|
-
|
|
425
|
+
const lines = this.splitDiffLines(cachedDiff + worktreeDiff);
|
|
426
|
+
const binaryFromOutput = this.isBinaryDiffOutput(cachedDiff) || this.isBinaryDiffOutput(worktreeDiff);
|
|
427
|
+
const binary = binaryFromOutput
|
|
428
|
+
? true
|
|
429
|
+
: (lines.length > 0 ? false : this.readWorktreeBinaryFlag(path));
|
|
430
|
+
return {
|
|
431
|
+
lines,
|
|
432
|
+
binary,
|
|
433
|
+
};
|
|
367
434
|
}
|
|
368
435
|
diffCommit(commit) {
|
|
369
436
|
const parentCheck = this.run(['rev-parse', '--verify', `${commit}^`]);
|
|
@@ -377,19 +444,18 @@ export class GitCommand {
|
|
|
377
444
|
const theirs = this.run(['show', `:3:${path}`]);
|
|
378
445
|
const base = this.run(['show', `:1:${path}`]);
|
|
379
446
|
if (ours.status !== 0 || theirs.status !== 0) {
|
|
380
|
-
return { path, ours: null, theirs: null, base: null, binary: false
|
|
447
|
+
return { path, ours: null, theirs: null, base: null, binary: false };
|
|
381
448
|
}
|
|
382
449
|
const oursContent = ours.stdout;
|
|
383
|
-
const
|
|
450
|
+
const lfsPointer = ours.stdout.includes('version https://git-lfs.github.com/spec/v1') ||
|
|
384
451
|
theirs.stdout.includes('version https://git-lfs.github.com/spec/v1');
|
|
385
|
-
const binary = !
|
|
452
|
+
const binary = !lfsPointer && (oursContent.startsWith('Binary\0') || oursContent.includes('\0'));
|
|
386
453
|
return {
|
|
387
454
|
path,
|
|
388
455
|
ours: ours.stdout,
|
|
389
456
|
theirs: theirs.stdout,
|
|
390
457
|
base: base.status === 0 ? base.stdout : null,
|
|
391
458
|
binary,
|
|
392
|
-
lfs_pointer,
|
|
393
459
|
};
|
|
394
460
|
}
|
|
395
461
|
checkoutConflictSide(path, side) {
|
|
@@ -451,6 +517,136 @@ export class GitCommand {
|
|
|
451
517
|
stdin: patch,
|
|
452
518
|
});
|
|
453
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Stages structured hunk/line selections (VCS-agnostic).
|
|
522
|
+
*
|
|
523
|
+
* For each file, fetches the worktree diff, extracts the selected hunks/lines,
|
|
524
|
+
* builds a combined sub-patch, and applies it to the index atomically.
|
|
525
|
+
*/
|
|
526
|
+
stageSelections(selections) {
|
|
527
|
+
const filePatches = [];
|
|
528
|
+
for (const sel of selections) {
|
|
529
|
+
// Fetch only the worktree diff (not --cached) — cached changes are
|
|
530
|
+
// already staged and must not be re-applied.
|
|
531
|
+
const raw = this.runChecked(['diff', '--no-ext-diff', '--no-color', '--', sel.path], 'git-diff-failed').stdout;
|
|
532
|
+
if (!raw.trim())
|
|
533
|
+
continue;
|
|
534
|
+
const lines = this.splitDiffLines(raw);
|
|
535
|
+
if (lines.length === 0)
|
|
536
|
+
continue;
|
|
537
|
+
const normPath = sel.path.replace(/\\/g, '/');
|
|
538
|
+
// Find the first @@ line to separate prelude from hunks
|
|
539
|
+
const firstHunk = lines.findIndex(l => l.startsWith('@@'));
|
|
540
|
+
if (firstHunk < 0)
|
|
541
|
+
continue;
|
|
542
|
+
const prelude = lines.slice(0, firstHunk);
|
|
543
|
+
const rest = lines.slice(firstHunk);
|
|
544
|
+
// Build headerExtras: metadata lines that aren't diff --git, ---, or +++
|
|
545
|
+
const headerExtras = prelude.filter(l => !!l &&
|
|
546
|
+
!l.startsWith('diff --git') &&
|
|
547
|
+
!l.startsWith('--- ') &&
|
|
548
|
+
!l.startsWith('+++ '));
|
|
549
|
+
// Locate all hunk @@ positions within rest
|
|
550
|
+
const starts = [];
|
|
551
|
+
for (let i = 0; i < rest.length; i++) {
|
|
552
|
+
if (rest[i].startsWith('@@'))
|
|
553
|
+
starts.push(i);
|
|
554
|
+
}
|
|
555
|
+
if (starts.length === 0)
|
|
556
|
+
continue;
|
|
557
|
+
starts.push(rest.length);
|
|
558
|
+
const isAdd = prelude.some(l => l.startsWith('--- /dev/null'));
|
|
559
|
+
const isDel = prelude.some(l => l.startsWith('+++ /dev/null'));
|
|
560
|
+
const wantWhole = new Set(sel.whole_hunks.filter(n => Number.isFinite(n)));
|
|
561
|
+
let out = `diff --git a/${normPath} b/${normPath}\n`;
|
|
562
|
+
if (headerExtras.length)
|
|
563
|
+
out += headerExtras.join('\n') + '\n';
|
|
564
|
+
if (isAdd)
|
|
565
|
+
out += `--- /dev/null\n+++ b/${normPath}\n`;
|
|
566
|
+
else if (isDel)
|
|
567
|
+
out += `--- a/${normPath}\n+++ /dev/null\n`;
|
|
568
|
+
else
|
|
569
|
+
out += `--- a/${normPath}\n+++ b/${normPath}\n`;
|
|
570
|
+
for (let h = 0; h < starts.length - 1; h++) {
|
|
571
|
+
const s = starts[h];
|
|
572
|
+
const e = starts[h + 1];
|
|
573
|
+
const block = rest.slice(s, e);
|
|
574
|
+
const header = block[0] || '';
|
|
575
|
+
const m = /@@\s*-([0-9]+),?([0-9]*)\s*\+([0-9]+),?([0-9]*)\s*@@/.exec(header);
|
|
576
|
+
if (!m)
|
|
577
|
+
continue;
|
|
578
|
+
const aStart = parseInt(m[1] || '0', 10) || 0;
|
|
579
|
+
const cStart = parseInt(m[3] || '0', 10) || 0;
|
|
580
|
+
const content = block.slice(1);
|
|
581
|
+
if (wantWhole.has(h)) {
|
|
582
|
+
out += header + '\n' + content.join('\n') + '\n';
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
const picksRaw = (sel.partial_hunks && Array.isArray(sel.partial_hunks[h]))
|
|
586
|
+
? sel.partial_hunks[h]
|
|
587
|
+
: (sel.partial_hunks && sel.partial_hunks[h] ? sel.partial_hunks[h] : []);
|
|
588
|
+
const picksAdj = Array.isArray(picksRaw)
|
|
589
|
+
? picksRaw.map((i) => i - 1).filter((i) => i >= 0 && i < content.length)
|
|
590
|
+
: [];
|
|
591
|
+
const pickSet = new Set(picksAdj);
|
|
592
|
+
if (pickSet.size === 0)
|
|
593
|
+
continue;
|
|
594
|
+
// prefix counts for old/new positions
|
|
595
|
+
const prefOld = new Array(content.length + 1).fill(0);
|
|
596
|
+
const prefNew = new Array(content.length + 1).fill(0);
|
|
597
|
+
for (let i = 0; i < content.length; i++) {
|
|
598
|
+
const ch = (content[i] || '')[0] || ' ';
|
|
599
|
+
const isMeta = ch === '\\';
|
|
600
|
+
prefOld[i + 1] = prefOld[i] + (isMeta ? 0 : (ch === '+' ? 0 : 1));
|
|
601
|
+
prefNew[i + 1] = prefNew[i] + (isMeta ? 0 : (ch === '-' ? 0 : 1));
|
|
602
|
+
}
|
|
603
|
+
// Group consecutive selected lines into mini-hunks
|
|
604
|
+
const sorted = Array.from(pickSet).sort((x, y) => x - y);
|
|
605
|
+
let group = [];
|
|
606
|
+
const flush = () => {
|
|
607
|
+
if (group.length === 0)
|
|
608
|
+
return;
|
|
609
|
+
const i0 = group[0];
|
|
610
|
+
const old_start = aStart + prefOld[i0];
|
|
611
|
+
const new_start = cStart + prefNew[i0];
|
|
612
|
+
const slice = group.map(i => content[i]);
|
|
613
|
+
const contentLines = slice.filter(l => (l || '')[0] !== '\\');
|
|
614
|
+
const metaLines = slice.filter(l => (l || '')[0] === '\\');
|
|
615
|
+
const old_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '+'; }).length;
|
|
616
|
+
const new_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '-'; }).length;
|
|
617
|
+
if (old_count === 0 && new_count === 0) {
|
|
618
|
+
group = [];
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
out += `@@ -${old_start},${old_count} +${new_start},${new_count} @@\n`;
|
|
622
|
+
out += contentLines.join('\n') + '\n';
|
|
623
|
+
if (metaLines.length)
|
|
624
|
+
out += metaLines.join('\n') + '\n';
|
|
625
|
+
group = [];
|
|
626
|
+
};
|
|
627
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
628
|
+
if (group.length === 0) {
|
|
629
|
+
group.push(sorted[i]);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (sorted[i] === group[group.length - 1] + 1)
|
|
633
|
+
group.push(sorted[i]);
|
|
634
|
+
else {
|
|
635
|
+
flush();
|
|
636
|
+
group.push(sorted[i]);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
flush();
|
|
640
|
+
}
|
|
641
|
+
filePatches.push(out.trimEnd());
|
|
642
|
+
}
|
|
643
|
+
if (filePatches.length === 0)
|
|
644
|
+
return;
|
|
645
|
+
const combinedPatch = filePatches.join('\n') + '\n';
|
|
646
|
+
this.runChecked(['apply', '--cached', '--unidiff-zero'], 'git-stage-patch-failed', {
|
|
647
|
+
stdin: combinedPatch,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
454
650
|
applyReversePatch(patch) {
|
|
455
651
|
this.runChecked(['apply', '-R', '--unidiff-zero'], 'git-apply-reverse-failed', {
|
|
456
652
|
stdin: patch,
|
|
@@ -265,7 +265,7 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
265
265
|
}
|
|
266
266
|
diffFile(params, _context) {
|
|
267
267
|
const git = this.requireGit(params.session_id);
|
|
268
|
-
return
|
|
268
|
+
return git.diffFile(asTrimmedString(params.path));
|
|
269
269
|
}
|
|
270
270
|
diffCommit(params, _context) {
|
|
271
271
|
const git = this.requireGit(params.session_id);
|
|
@@ -295,6 +295,11 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
295
295
|
git.stagePatch(asString(params.patch));
|
|
296
296
|
return null;
|
|
297
297
|
}
|
|
298
|
+
stageSelections(params, _context) {
|
|
299
|
+
const git = this.requireGit(params.session_id);
|
|
300
|
+
git.stageSelections(params.selections);
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
298
303
|
stagePaths(params, _context) {
|
|
299
304
|
const git = this.requireGit(params.session_id);
|
|
300
305
|
git.stagePaths(asStringArray(params.paths));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openvcs/git-plugin",
|
|
3
|
-
"version": "0.3.2-beta.
|
|
3
|
+
"version": "0.3.2-beta.127",
|
|
4
4
|
"description": "Git VCS backend plugin for OpenVCS",
|
|
5
5
|
"author": "OpenVCS Contributors",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
}
|
|
36
36
|
]
|
|
37
37
|
},
|
|
38
|
-
"version": "0.3.2-beta.
|
|
38
|
+
"version": "0.3.2-beta.127"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
41
|
"lint": "tsc -p tsconfig.json --noEmit",
|
package/src/git.ts
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
3
|
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
-
import { rmSync } from 'node:fs';
|
|
5
|
+
import { readFileSync, rmSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
|
|
8
8
|
import { pluginError } from '@openvcs/sdk/runtime';
|
|
9
9
|
import type {
|
|
10
10
|
CommitEntry,
|
|
11
|
+
StatusFileEntry,
|
|
11
12
|
StatusParseResult,
|
|
13
|
+
VcsDiffResult,
|
|
12
14
|
} from '@openvcs/sdk/types';
|
|
13
15
|
import type { GitCommandResult, RunGitOptions } from './plugin-types.js';
|
|
14
16
|
import {
|
|
@@ -65,7 +67,6 @@ export interface ConflictDetails {
|
|
|
65
67
|
theirs: string | null;
|
|
66
68
|
base: string | null;
|
|
67
69
|
binary: boolean;
|
|
68
|
-
lfs_pointer: boolean;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
export interface StashEntry {
|
|
@@ -151,7 +152,17 @@ export class GitCommand {
|
|
|
151
152
|
status(): StatusParseResult & { exitCode: number } {
|
|
152
153
|
const result = this.run(['status', '--porcelain=1', '--branch', '-z', '-uall']);
|
|
153
154
|
const parsed = applySubmoduleStatusHints(parseStatusOutput(result.stdout), this.listSubmodulePaths());
|
|
154
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
...parsed,
|
|
157
|
+
payload: {
|
|
158
|
+
...parsed.payload,
|
|
159
|
+
files: parsed.payload.files.map((file) => ({
|
|
160
|
+
...file,
|
|
161
|
+
binary: this.detectStatusFileBinary(file),
|
|
162
|
+
})),
|
|
163
|
+
},
|
|
164
|
+
exitCode: result.status,
|
|
165
|
+
};
|
|
155
166
|
}
|
|
156
167
|
|
|
157
168
|
currentBranch(): string {
|
|
@@ -416,6 +427,63 @@ export class GitCommand {
|
|
|
416
427
|
return new Set(this.readSubmoduleConfig().byPath.keys());
|
|
417
428
|
}
|
|
418
429
|
|
|
430
|
+
/** Splits diff stdout without manufacturing a blank line for empty output. */
|
|
431
|
+
private splitDiffLines(output: string): string[] {
|
|
432
|
+
const normalized = output.trimEnd();
|
|
433
|
+
return normalized.length > 0 ? normalized.split('\n') : [];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Returns true when Git already reported the diff target as binary. */
|
|
437
|
+
private isBinaryDiffOutput(output: string): boolean {
|
|
438
|
+
const lines = this.splitDiffLines(output);
|
|
439
|
+
if (lines.length === 0) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return lines.some((line) => {
|
|
444
|
+
const candidate = String(line || '');
|
|
445
|
+
return /^binary files /i.test(candidate)
|
|
446
|
+
|| /^git binary patch/i.test(candidate)
|
|
447
|
+
|| /^literal /i.test(candidate);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Returns binary classification for a status entry when worktree bytes are available. */
|
|
452
|
+
private detectStatusFileBinary(file: StatusFileEntry): boolean | null {
|
|
453
|
+
return this.readWorktreeBinaryFlag(file.path);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Reads worktree bytes and classifies likely binary content, or `null` when unavailable. */
|
|
457
|
+
private readWorktreeBinaryFlag(path: string): boolean | null {
|
|
458
|
+
const trimmedPath = String(path || '').trim();
|
|
459
|
+
if (!trimmedPath) {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
const contents = readFileSync(join(this.cwd, trimmedPath));
|
|
465
|
+
return this.isBinaryBuffer(contents);
|
|
466
|
+
} catch {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Applies a lightweight byte sniff that keeps UTF-16 BOM text out of binary placeholders. */
|
|
472
|
+
private isBinaryBuffer(contents: Uint8Array): boolean {
|
|
473
|
+
if (contents.length === 0) {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const hasUtf16LeBom = contents.length >= 2 && contents[0] === 0xff && contents[1] === 0xfe;
|
|
478
|
+
const hasUtf16BeBom = contents.length >= 2 && contents[0] === 0xfe && contents[1] === 0xff;
|
|
479
|
+
if (hasUtf16LeBom || hasUtf16BeBom) {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const sample = contents.subarray(0, Math.min(contents.length, 8192));
|
|
484
|
+
return sample.includes(0);
|
|
485
|
+
}
|
|
486
|
+
|
|
419
487
|
listSubmodules(): SubmoduleEntry[] {
|
|
420
488
|
const { byPath: configByPath } = this.readSubmoduleConfig();
|
|
421
489
|
|
|
@@ -502,12 +570,21 @@ export class GitCommand {
|
|
|
502
570
|
}
|
|
503
571
|
}
|
|
504
572
|
|
|
505
|
-
diffFile(path: string):
|
|
573
|
+
diffFile(path: string): VcsDiffResult {
|
|
506
574
|
const cachedDiff = this.runChecked(['diff', '--cached', '--no-ext-diff', '--', path], 'git-diff-failed')
|
|
507
575
|
.stdout;
|
|
508
576
|
const worktreeDiff = this.runChecked(['diff', '--no-ext-diff', '--', path], 'git-diff-failed')
|
|
509
577
|
.stdout;
|
|
510
|
-
|
|
578
|
+
const lines = this.splitDiffLines(cachedDiff + worktreeDiff);
|
|
579
|
+
const binaryFromOutput = this.isBinaryDiffOutput(cachedDiff) || this.isBinaryDiffOutput(worktreeDiff);
|
|
580
|
+
const binary = binaryFromOutput
|
|
581
|
+
? true
|
|
582
|
+
: (lines.length > 0 ? false : this.readWorktreeBinaryFlag(path));
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
lines,
|
|
586
|
+
binary,
|
|
587
|
+
};
|
|
511
588
|
}
|
|
512
589
|
|
|
513
590
|
diffCommit(commit: string): string {
|
|
@@ -528,15 +605,15 @@ export class GitCommand {
|
|
|
528
605
|
const base = this.run(['show', `:1:${path}`]);
|
|
529
606
|
|
|
530
607
|
if (ours.status !== 0 || theirs.status !== 0) {
|
|
531
|
-
return { path, ours: null, theirs: null, base: null, binary: false
|
|
608
|
+
return { path, ours: null, theirs: null, base: null, binary: false };
|
|
532
609
|
}
|
|
533
610
|
|
|
534
611
|
const oursContent = ours.stdout;
|
|
535
|
-
const
|
|
612
|
+
const lfsPointer =
|
|
536
613
|
ours.stdout.includes('version https://git-lfs.github.com/spec/v1') ||
|
|
537
614
|
theirs.stdout.includes('version https://git-lfs.github.com/spec/v1');
|
|
538
615
|
|
|
539
|
-
const binary = !
|
|
616
|
+
const binary = !lfsPointer && (oursContent.startsWith('Binary\0') || oursContent.includes('\0'));
|
|
540
617
|
|
|
541
618
|
return {
|
|
542
619
|
path,
|
|
@@ -544,7 +621,6 @@ export class GitCommand {
|
|
|
544
621
|
theirs: theirs.stdout,
|
|
545
622
|
base: base.status === 0 ? base.stdout : null,
|
|
546
623
|
binary,
|
|
547
|
-
lfs_pointer,
|
|
548
624
|
};
|
|
549
625
|
}
|
|
550
626
|
|
|
@@ -622,6 +698,139 @@ export class GitCommand {
|
|
|
622
698
|
});
|
|
623
699
|
}
|
|
624
700
|
|
|
701
|
+
/**
|
|
702
|
+
* Stages structured hunk/line selections (VCS-agnostic).
|
|
703
|
+
*
|
|
704
|
+
* For each file, fetches the worktree diff, extracts the selected hunks/lines,
|
|
705
|
+
* builds a combined sub-patch, and applies it to the index atomically.
|
|
706
|
+
*/
|
|
707
|
+
stageSelections(
|
|
708
|
+
selections: Array<{ path: string; whole_hunks: number[]; partial_hunks: Record<number, number[]> }>,
|
|
709
|
+
): void {
|
|
710
|
+
const filePatches: string[] = [];
|
|
711
|
+
|
|
712
|
+
for (const sel of selections) {
|
|
713
|
+
// Fetch only the worktree diff (not --cached) — cached changes are
|
|
714
|
+
// already staged and must not be re-applied.
|
|
715
|
+
const raw = this.runChecked(
|
|
716
|
+
['diff', '--no-ext-diff', '--no-color', '--', sel.path],
|
|
717
|
+
'git-diff-failed',
|
|
718
|
+
).stdout;
|
|
719
|
+
if (!raw.trim()) continue;
|
|
720
|
+
|
|
721
|
+
const lines = this.splitDiffLines(raw);
|
|
722
|
+
if (lines.length === 0) continue;
|
|
723
|
+
|
|
724
|
+
const normPath = sel.path.replace(/\\/g, '/');
|
|
725
|
+
|
|
726
|
+
// Find the first @@ line to separate prelude from hunks
|
|
727
|
+
const firstHunk = lines.findIndex(l => l.startsWith('@@'));
|
|
728
|
+
if (firstHunk < 0) continue;
|
|
729
|
+
|
|
730
|
+
const prelude = lines.slice(0, firstHunk);
|
|
731
|
+
const rest = lines.slice(firstHunk);
|
|
732
|
+
|
|
733
|
+
// Build headerExtras: metadata lines that aren't diff --git, ---, or +++
|
|
734
|
+
const headerExtras = prelude.filter(l =>
|
|
735
|
+
!!l &&
|
|
736
|
+
!l.startsWith('diff --git') &&
|
|
737
|
+
!l.startsWith('--- ') &&
|
|
738
|
+
!l.startsWith('+++ ')
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
// Locate all hunk @@ positions within rest
|
|
742
|
+
const starts: number[] = [];
|
|
743
|
+
for (let i = 0; i < rest.length; i++) {
|
|
744
|
+
if (rest[i].startsWith('@@')) starts.push(i);
|
|
745
|
+
}
|
|
746
|
+
if (starts.length === 0) continue;
|
|
747
|
+
starts.push(rest.length);
|
|
748
|
+
|
|
749
|
+
const isAdd = prelude.some(l => l.startsWith('--- /dev/null'));
|
|
750
|
+
const isDel = prelude.some(l => l.startsWith('+++ /dev/null'));
|
|
751
|
+
const wantWhole = new Set(sel.whole_hunks.filter(n => Number.isFinite(n)));
|
|
752
|
+
|
|
753
|
+
let out = `diff --git a/${normPath} b/${normPath}\n`;
|
|
754
|
+
if (headerExtras.length) out += headerExtras.join('\n') + '\n';
|
|
755
|
+
if (isAdd) out += `--- /dev/null\n+++ b/${normPath}\n`;
|
|
756
|
+
else if (isDel) out += `--- a/${normPath}\n+++ /dev/null\n`;
|
|
757
|
+
else out += `--- a/${normPath}\n+++ b/${normPath}\n`;
|
|
758
|
+
|
|
759
|
+
for (let h = 0; h < starts.length - 1; h++) {
|
|
760
|
+
const s = starts[h];
|
|
761
|
+
const e = starts[h + 1];
|
|
762
|
+
const block = rest.slice(s, e);
|
|
763
|
+
const header = block[0] || '';
|
|
764
|
+
const m = /@@\s*-([0-9]+),?([0-9]*)\s*\+([0-9]+),?([0-9]*)\s*@@/.exec(header);
|
|
765
|
+
if (!m) continue;
|
|
766
|
+
const aStart = parseInt(m[1] || '0', 10) || 0;
|
|
767
|
+
const cStart = parseInt(m[3] || '0', 10) || 0;
|
|
768
|
+
const content = block.slice(1);
|
|
769
|
+
|
|
770
|
+
if (wantWhole.has(h)) {
|
|
771
|
+
out += header + '\n' + content.join('\n') + '\n';
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const picksRaw = (sel.partial_hunks && Array.isArray(sel.partial_hunks[h]))
|
|
776
|
+
? sel.partial_hunks[h]
|
|
777
|
+
: (sel.partial_hunks && sel.partial_hunks[h] ? sel.partial_hunks[h] : []);
|
|
778
|
+
const picksAdj = Array.isArray(picksRaw)
|
|
779
|
+
? picksRaw.map((i: number) => i - 1).filter((i: number) => i >= 0 && i < content.length)
|
|
780
|
+
: [];
|
|
781
|
+
const pickSet = new Set<number>(picksAdj);
|
|
782
|
+
if (pickSet.size === 0) continue;
|
|
783
|
+
|
|
784
|
+
// prefix counts for old/new positions
|
|
785
|
+
const prefOld: number[] = new Array(content.length + 1).fill(0);
|
|
786
|
+
const prefNew: number[] = new Array(content.length + 1).fill(0);
|
|
787
|
+
for (let i = 0; i < content.length; i++) {
|
|
788
|
+
const ch = (content[i] || '')[0] || ' ';
|
|
789
|
+
const isMeta = ch === '\\';
|
|
790
|
+
prefOld[i + 1] = prefOld[i] + (isMeta ? 0 : (ch === '+' ? 0 : 1));
|
|
791
|
+
prefNew[i + 1] = prefNew[i] + (isMeta ? 0 : (ch === '-' ? 0 : 1));
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Group consecutive selected lines into mini-hunks
|
|
795
|
+
const sorted = Array.from(pickSet).sort((x, y) => x - y);
|
|
796
|
+
let group: number[] = [];
|
|
797
|
+
|
|
798
|
+
const flush = (): void => {
|
|
799
|
+
if (group.length === 0) return;
|
|
800
|
+
const i0 = group[0];
|
|
801
|
+
const old_start = aStart + prefOld[i0];
|
|
802
|
+
const new_start = cStart + prefNew[i0];
|
|
803
|
+
const slice = group.map(i => content[i]);
|
|
804
|
+
const contentLines = slice.filter(l => (l || '')[0] !== '\\');
|
|
805
|
+
const metaLines = slice.filter(l => (l || '')[0] === '\\');
|
|
806
|
+
const old_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '+'; }).length;
|
|
807
|
+
const new_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '-'; }).length;
|
|
808
|
+
if (old_count === 0 && new_count === 0) { group = []; return; }
|
|
809
|
+
out += `@@ -${old_start},${old_count} +${new_start},${new_count} @@\n`;
|
|
810
|
+
out += contentLines.join('\n') + '\n';
|
|
811
|
+
if (metaLines.length) out += metaLines.join('\n') + '\n';
|
|
812
|
+
group = [];
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
816
|
+
if (group.length === 0) { group.push(sorted[i]); continue; }
|
|
817
|
+
if (sorted[i] === group[group.length - 1] + 1) group.push(sorted[i]);
|
|
818
|
+
else { flush(); group.push(sorted[i]); }
|
|
819
|
+
}
|
|
820
|
+
flush();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
filePatches.push(out.trimEnd());
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (filePatches.length === 0) return;
|
|
827
|
+
|
|
828
|
+
const combinedPatch = filePatches.join('\n') + '\n';
|
|
829
|
+
this.runChecked(['apply', '--cached', '--unidiff-zero'], 'git-stage-patch-failed', {
|
|
830
|
+
stdin: combinedPatch,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
625
834
|
applyReversePatch(patch: string): void {
|
|
626
835
|
this.runChecked(['apply', '-R', '--unidiff-zero'], 'git-apply-reverse-failed', {
|
|
627
836
|
stdin: patch,
|
|
@@ -428,9 +428,9 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
428
428
|
override diffFile(
|
|
429
429
|
params: OpenVcs.VcsDiffFileParams,
|
|
430
430
|
_context: PluginRuntimeContext,
|
|
431
|
-
):
|
|
431
|
+
): OpenVcs.VcsDiffFileResponse {
|
|
432
432
|
const git = this.requireGit(params.session_id);
|
|
433
|
-
return
|
|
433
|
+
return git.diffFile(asTrimmedString(params.path));
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
override diffCommit(
|
|
@@ -484,6 +484,15 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
484
484
|
return null;
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
override stageSelections(
|
|
488
|
+
params: OpenVcs.VcsStageSelectionsParams,
|
|
489
|
+
_context: PluginRuntimeContext,
|
|
490
|
+
): null {
|
|
491
|
+
const git = this.requireGit(params.session_id);
|
|
492
|
+
git.stageSelections(params.selections);
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
487
496
|
override stagePaths(
|
|
488
497
|
params: OpenVcs.VcsStagePathsParams,
|
|
489
498
|
_context: PluginRuntimeContext,
|