@openvcs/git-plugin 0.3.2-nightly.20260601.121 → 0.3.2-nightly.20260604.132
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 +207 -6
- package/bin/plugin-request-handler.js +13 -1
- package/package.json +3 -3
- package/src/git.ts +222 -7
- package/src/plugin-request-handler.ts +18 -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');
|
|
@@ -78,7 +88,7 @@ export class GitCommand {
|
|
|
78
88
|
const baseLine = isCurrent ? line.slice(0, -1) : line;
|
|
79
89
|
const name = baseLine.trim();
|
|
80
90
|
if (name) {
|
|
81
|
-
branches.push({ name, current: name === current
|
|
91
|
+
branches.push({ name, current: name === current });
|
|
82
92
|
}
|
|
83
93
|
}
|
|
84
94
|
return { current, branches };
|
|
@@ -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']);
|
|
@@ -300,7 +359,7 @@ export class GitCommand {
|
|
|
300
359
|
const line = rawLine.trim();
|
|
301
360
|
if (!line)
|
|
302
361
|
continue;
|
|
303
|
-
const marker = line[0]
|
|
362
|
+
const marker = line[0];
|
|
304
363
|
const rest = line.slice(1).trim();
|
|
305
364
|
const [commit = '', path = ''] = rest.split(/\s+/);
|
|
306
365
|
if (!path)
|
|
@@ -350,7 +409,7 @@ export class GitCommand {
|
|
|
350
409
|
this.runChecked(['submodule', 'deinit', '-f', '--', path], 'git-submodule-remove-failed');
|
|
351
410
|
this.runChecked(['rm', '-f', '--', path], 'git-submodule-remove-failed');
|
|
352
411
|
const modulesPath = join(this.cwd, '.git', 'modules', path);
|
|
353
|
-
/* c8 ignore next
|
|
412
|
+
/* c8 ignore next 5 */
|
|
354
413
|
try {
|
|
355
414
|
rmSync(modulesPath, { recursive: true, force: true });
|
|
356
415
|
}
|
|
@@ -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}^`]);
|
|
@@ -450,6 +517,140 @@ export class GitCommand {
|
|
|
450
517
|
stdin: patch,
|
|
451
518
|
});
|
|
452
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
|
+
/* c8 ignore next 3 */
|
|
536
|
+
if (lines.length === 0)
|
|
537
|
+
continue;
|
|
538
|
+
const normPath = sel.path.replace(/\\/g, '/');
|
|
539
|
+
// Find the first @@ line to separate prelude from hunks
|
|
540
|
+
const firstHunk = lines.findIndex(l => l.startsWith('@@'));
|
|
541
|
+
if (firstHunk < 0)
|
|
542
|
+
continue;
|
|
543
|
+
const prelude = lines.slice(0, firstHunk);
|
|
544
|
+
const rest = lines.slice(firstHunk);
|
|
545
|
+
// Build headerExtras: metadata lines that aren't diff --git, ---, or +++
|
|
546
|
+
const headerExtras = prelude.filter(l => !!l &&
|
|
547
|
+
!l.startsWith('diff --git') &&
|
|
548
|
+
!l.startsWith('--- ') &&
|
|
549
|
+
!l.startsWith('+++ '));
|
|
550
|
+
// Locate all hunk @@ positions within rest
|
|
551
|
+
const starts = [];
|
|
552
|
+
for (let i = 0; i < rest.length; i++) {
|
|
553
|
+
if (rest[i].startsWith('@@'))
|
|
554
|
+
starts.push(i);
|
|
555
|
+
}
|
|
556
|
+
/* c8 ignore next 3 */
|
|
557
|
+
if (starts.length === 0)
|
|
558
|
+
continue;
|
|
559
|
+
starts.push(rest.length);
|
|
560
|
+
const isAdd = prelude.some(l => l.startsWith('--- /dev/null'));
|
|
561
|
+
const isDel = prelude.some(l => l.startsWith('+++ /dev/null'));
|
|
562
|
+
const wantWhole = new Set(sel.whole_hunks.filter(n => Number.isFinite(n)));
|
|
563
|
+
let out = `diff --git a/${normPath} b/${normPath}\n`;
|
|
564
|
+
if (headerExtras.length)
|
|
565
|
+
out += headerExtras.join('\n') + '\n';
|
|
566
|
+
if (isAdd)
|
|
567
|
+
out += `--- /dev/null\n+++ b/${normPath}\n`;
|
|
568
|
+
else if (isDel)
|
|
569
|
+
out += `--- a/${normPath}\n+++ /dev/null\n`;
|
|
570
|
+
else
|
|
571
|
+
out += `--- a/${normPath}\n+++ b/${normPath}\n`;
|
|
572
|
+
for (let h = 0; h < starts.length - 1; h++) {
|
|
573
|
+
const s = starts[h];
|
|
574
|
+
const e = starts[h + 1];
|
|
575
|
+
const block = rest.slice(s, e);
|
|
576
|
+
/* c8 ignore next 7 */
|
|
577
|
+
const header = block[0] || '';
|
|
578
|
+
const m = /@@\s*-([0-9]+),?([0-9]*)\s*\+([0-9]+),?([0-9]*)\s*@@/.exec(header);
|
|
579
|
+
if (!m)
|
|
580
|
+
continue;
|
|
581
|
+
const aStart = parseInt(m[1] || '0', 10) || 0;
|
|
582
|
+
const cStart = parseInt(m[3] || '0', 10) || 0;
|
|
583
|
+
const content = block.slice(1);
|
|
584
|
+
if (wantWhole.has(h)) {
|
|
585
|
+
out += header + '\n' + content.join('\n') + '\n';
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const picksRaw = (sel.partial_hunks && Array.isArray(sel.partial_hunks[h]))
|
|
589
|
+
? sel.partial_hunks[h]
|
|
590
|
+
: (sel.partial_hunks && sel.partial_hunks[h] ? sel.partial_hunks[h] : []);
|
|
591
|
+
const picksAdj = Array.isArray(picksRaw)
|
|
592
|
+
? picksRaw.map((i) => i - 1).filter((i) => i >= 0 && i < content.length)
|
|
593
|
+
: [];
|
|
594
|
+
const pickSet = new Set(picksAdj);
|
|
595
|
+
if (pickSet.size === 0)
|
|
596
|
+
continue;
|
|
597
|
+
// prefix counts for old/new positions
|
|
598
|
+
const prefOld = new Array(content.length + 1).fill(0);
|
|
599
|
+
const prefNew = new Array(content.length + 1).fill(0);
|
|
600
|
+
for (let i = 0; i < content.length; i++) {
|
|
601
|
+
const ch = (content[i] || '')[0] || ' ';
|
|
602
|
+
const isMeta = ch === '\\';
|
|
603
|
+
prefOld[i + 1] = prefOld[i] + (isMeta ? 0 : (ch === '+' ? 0 : 1));
|
|
604
|
+
prefNew[i + 1] = prefNew[i] + (isMeta ? 0 : (ch === '-' ? 0 : 1));
|
|
605
|
+
}
|
|
606
|
+
// Group consecutive selected lines into mini-hunks
|
|
607
|
+
const sorted = Array.from(pickSet).sort((x, y) => x - y);
|
|
608
|
+
let group = [];
|
|
609
|
+
const flush = () => {
|
|
610
|
+
/* c8 ignore next 3 */
|
|
611
|
+
if (group.length === 0)
|
|
612
|
+
return;
|
|
613
|
+
const i0 = group[0];
|
|
614
|
+
const old_start = aStart + prefOld[i0];
|
|
615
|
+
const new_start = cStart + prefNew[i0];
|
|
616
|
+
const slice = group.map(i => content[i]);
|
|
617
|
+
const contentLines = slice.filter(l => (l || '')[0] !== '\\');
|
|
618
|
+
const metaLines = slice.filter(l => (l || '')[0] === '\\');
|
|
619
|
+
const old_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '+'; }).length;
|
|
620
|
+
const new_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '-'; }).length;
|
|
621
|
+
if (old_count === 0 && new_count === 0) {
|
|
622
|
+
group = [];
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
out += `@@ -${old_start},${old_count} +${new_start},${new_count} @@\n`;
|
|
626
|
+
out += contentLines.join('\n') + '\n';
|
|
627
|
+
if (metaLines.length)
|
|
628
|
+
out += metaLines.join('\n') + '\n';
|
|
629
|
+
group = [];
|
|
630
|
+
};
|
|
631
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
632
|
+
if (group.length === 0) {
|
|
633
|
+
group.push(sorted[i]);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (sorted[i] === group[group.length - 1] + 1)
|
|
637
|
+
group.push(sorted[i]);
|
|
638
|
+
else {
|
|
639
|
+
flush();
|
|
640
|
+
group.push(sorted[i]);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
flush();
|
|
644
|
+
}
|
|
645
|
+
filePatches.push(out.trimEnd());
|
|
646
|
+
}
|
|
647
|
+
if (filePatches.length === 0)
|
|
648
|
+
return;
|
|
649
|
+
const combinedPatch = filePatches.join('\n') + '\n';
|
|
650
|
+
this.runChecked(['apply', '--cached', '--unidiff-zero'], 'git-stage-patch-failed', {
|
|
651
|
+
stdin: combinedPatch,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
453
654
|
applyReversePatch(patch) {
|
|
454
655
|
this.runChecked(['apply', '-R', '--unidiff-zero'], 'git-apply-reverse-failed', {
|
|
455
656
|
stdin: patch,
|
|
@@ -15,6 +15,8 @@ function splitDiffLines(output) {
|
|
|
15
15
|
/** Reduces a file status string to the primary status code needed for discard routing. */
|
|
16
16
|
function getPrimaryDiscardStatus(status) {
|
|
17
17
|
const normalized = asTrimmedString(status);
|
|
18
|
+
/* c8 ignore next 3 */
|
|
19
|
+
// Unreachable: parseStatusOutput always produces non-empty status (falls back to 'M')
|
|
18
20
|
if (!normalized) {
|
|
19
21
|
return 'M';
|
|
20
22
|
}
|
|
@@ -23,6 +25,8 @@ function getPrimaryDiscardStatus(status) {
|
|
|
23
25
|
return candidate;
|
|
24
26
|
}
|
|
25
27
|
}
|
|
28
|
+
/* c8 ignore next 3 */
|
|
29
|
+
// Unreachable: normalized[0] is always defined on non-empty string
|
|
26
30
|
return normalized[0] ?? 'M';
|
|
27
31
|
}
|
|
28
32
|
/** Builds a discard plan that handles tracked, added, copied, and renamed paths. */
|
|
@@ -142,6 +146,9 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
142
146
|
.map((line) => {
|
|
143
147
|
const [name = '', fullRef = '', headMark = ''] = line.split('\t');
|
|
144
148
|
const isRemote = fullRef.startsWith('refs/remotes/');
|
|
149
|
+
// isRemote is true → name = 'origin/main'. split('/')[0] = 'origin' (always defined).
|
|
150
|
+
// ?? null is unreachable, kept for type-safety.
|
|
151
|
+
/* c8 ignore next */
|
|
145
152
|
const remote = isRemote ? name.split('/')[0] ?? null : null;
|
|
146
153
|
const kind = isRemote
|
|
147
154
|
? { type: 'Remote', remote }
|
|
@@ -265,7 +272,7 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
265
272
|
}
|
|
266
273
|
diffFile(params, _context) {
|
|
267
274
|
const git = this.requireGit(params.session_id);
|
|
268
|
-
return
|
|
275
|
+
return git.diffFile(asTrimmedString(params.path));
|
|
269
276
|
}
|
|
270
277
|
diffCommit(params, _context) {
|
|
271
278
|
const git = this.requireGit(params.session_id);
|
|
@@ -295,6 +302,11 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
295
302
|
git.stagePatch(asString(params.patch));
|
|
296
303
|
return null;
|
|
297
304
|
}
|
|
305
|
+
stageSelections(params, _context) {
|
|
306
|
+
const git = this.requireGit(params.session_id);
|
|
307
|
+
git.stageSelections(params.selections);
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
298
310
|
stagePaths(params, _context) {
|
|
299
311
|
const git = this.requireGit(params.session_id);
|
|
300
312
|
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-nightly.
|
|
3
|
+
"version": "0.3.2-nightly.20260604.132",
|
|
4
4
|
"description": "Git VCS backend plugin for OpenVCS",
|
|
5
5
|
"author": "OpenVCS Contributors",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
}
|
|
36
36
|
]
|
|
37
37
|
},
|
|
38
|
-
"version": "0.3.2-nightly.
|
|
38
|
+
"version": "0.3.2-nightly.20260604.132"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
41
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
42
42
|
"test": "tsx --test test/*.test.ts",
|
|
43
|
-
"coverage": "c8 --include src --exclude 'src/plugin-types.ts' --reporter text --reporter lcov --all --check-coverage --lines
|
|
43
|
+
"coverage": "c8 --include src --exclude 'src/plugin-types.ts' --reporter text --reporter lcov --all --check-coverage --lines 100 --functions 100 --branches 100 --statements 100 --per-file tsx --test test/*.test.ts",
|
|
44
44
|
"prepack": "npm run build",
|
|
45
45
|
"build:plugin": "tsc -p tsconfig.json",
|
|
46
46
|
"build": "node ./node_modules/@openvcs/sdk/bin/openvcs.js build"
|
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 {
|
|
@@ -150,7 +152,17 @@ export class GitCommand {
|
|
|
150
152
|
status(): StatusParseResult & { exitCode: number } {
|
|
151
153
|
const result = this.run(['status', '--porcelain=1', '--branch', '-z', '-uall']);
|
|
152
154
|
const parsed = applySubmoduleStatusHints(parseStatusOutput(result.stdout), this.listSubmodulePaths());
|
|
153
|
-
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
|
+
};
|
|
154
166
|
}
|
|
155
167
|
|
|
156
168
|
currentBranch(): string {
|
|
@@ -168,7 +180,7 @@ export class GitCommand {
|
|
|
168
180
|
const baseLine = isCurrent ? line.slice(0, -1) : line;
|
|
169
181
|
const name = baseLine.trim();
|
|
170
182
|
if (name) {
|
|
171
|
-
branches.push({ name, current: name === current
|
|
183
|
+
branches.push({ name, current: name === current });
|
|
172
184
|
}
|
|
173
185
|
}
|
|
174
186
|
|
|
@@ -415,6 +427,63 @@ export class GitCommand {
|
|
|
415
427
|
return new Set(this.readSubmoduleConfig().byPath.keys());
|
|
416
428
|
}
|
|
417
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
|
+
|
|
418
487
|
listSubmodules(): SubmoduleEntry[] {
|
|
419
488
|
const { byPath: configByPath } = this.readSubmoduleConfig();
|
|
420
489
|
|
|
@@ -435,7 +504,7 @@ export class GitCommand {
|
|
|
435
504
|
const line = rawLine.trim();
|
|
436
505
|
if (!line) continue;
|
|
437
506
|
|
|
438
|
-
const marker = line[0]
|
|
507
|
+
const marker = line[0];
|
|
439
508
|
const rest = line.slice(1).trim();
|
|
440
509
|
const [commit = '', path = ''] = rest.split(/\s+/);
|
|
441
510
|
if (!path) continue;
|
|
@@ -493,7 +562,7 @@ export class GitCommand {
|
|
|
493
562
|
this.runChecked(['rm', '-f', '--', path], 'git-submodule-remove-failed');
|
|
494
563
|
|
|
495
564
|
const modulesPath = join(this.cwd, '.git', 'modules', path);
|
|
496
|
-
/* c8 ignore next
|
|
565
|
+
/* c8 ignore next 5 */
|
|
497
566
|
try {
|
|
498
567
|
rmSync(modulesPath, { recursive: true, force: true });
|
|
499
568
|
} catch {
|
|
@@ -501,12 +570,21 @@ export class GitCommand {
|
|
|
501
570
|
}
|
|
502
571
|
}
|
|
503
572
|
|
|
504
|
-
diffFile(path: string):
|
|
573
|
+
diffFile(path: string): VcsDiffResult {
|
|
505
574
|
const cachedDiff = this.runChecked(['diff', '--cached', '--no-ext-diff', '--', path], 'git-diff-failed')
|
|
506
575
|
.stdout;
|
|
507
576
|
const worktreeDiff = this.runChecked(['diff', '--no-ext-diff', '--', path], 'git-diff-failed')
|
|
508
577
|
.stdout;
|
|
509
|
-
|
|
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
|
+
};
|
|
510
588
|
}
|
|
511
589
|
|
|
512
590
|
diffCommit(commit: string): string {
|
|
@@ -620,6 +698,143 @@ export class GitCommand {
|
|
|
620
698
|
});
|
|
621
699
|
}
|
|
622
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
|
+
/* c8 ignore next 3 */
|
|
723
|
+
if (lines.length === 0) continue;
|
|
724
|
+
|
|
725
|
+
const normPath = sel.path.replace(/\\/g, '/');
|
|
726
|
+
|
|
727
|
+
// Find the first @@ line to separate prelude from hunks
|
|
728
|
+
const firstHunk = lines.findIndex(l => l.startsWith('@@'));
|
|
729
|
+
if (firstHunk < 0) continue;
|
|
730
|
+
|
|
731
|
+
const prelude = lines.slice(0, firstHunk);
|
|
732
|
+
const rest = lines.slice(firstHunk);
|
|
733
|
+
|
|
734
|
+
// Build headerExtras: metadata lines that aren't diff --git, ---, or +++
|
|
735
|
+
const headerExtras = prelude.filter(l =>
|
|
736
|
+
!!l &&
|
|
737
|
+
!l.startsWith('diff --git') &&
|
|
738
|
+
!l.startsWith('--- ') &&
|
|
739
|
+
!l.startsWith('+++ ')
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
// Locate all hunk @@ positions within rest
|
|
743
|
+
const starts: number[] = [];
|
|
744
|
+
for (let i = 0; i < rest.length; i++) {
|
|
745
|
+
if (rest[i].startsWith('@@')) starts.push(i);
|
|
746
|
+
}
|
|
747
|
+
/* c8 ignore next 3 */
|
|
748
|
+
if (starts.length === 0) continue;
|
|
749
|
+
starts.push(rest.length);
|
|
750
|
+
|
|
751
|
+
const isAdd = prelude.some(l => l.startsWith('--- /dev/null'));
|
|
752
|
+
const isDel = prelude.some(l => l.startsWith('+++ /dev/null'));
|
|
753
|
+
const wantWhole = new Set(sel.whole_hunks.filter(n => Number.isFinite(n)));
|
|
754
|
+
|
|
755
|
+
let out = `diff --git a/${normPath} b/${normPath}\n`;
|
|
756
|
+
if (headerExtras.length) out += headerExtras.join('\n') + '\n';
|
|
757
|
+
if (isAdd) out += `--- /dev/null\n+++ b/${normPath}\n`;
|
|
758
|
+
else if (isDel) out += `--- a/${normPath}\n+++ /dev/null\n`;
|
|
759
|
+
else out += `--- a/${normPath}\n+++ b/${normPath}\n`;
|
|
760
|
+
|
|
761
|
+
for (let h = 0; h < starts.length - 1; h++) {
|
|
762
|
+
const s = starts[h];
|
|
763
|
+
const e = starts[h + 1];
|
|
764
|
+
const block = rest.slice(s, e);
|
|
765
|
+
/* c8 ignore next 7 */
|
|
766
|
+
const header = block[0] || '';
|
|
767
|
+
const m = /@@\s*-([0-9]+),?([0-9]*)\s*\+([0-9]+),?([0-9]*)\s*@@/.exec(header);
|
|
768
|
+
if (!m) continue;
|
|
769
|
+
const aStart = parseInt(m[1] || '0', 10) || 0;
|
|
770
|
+
const cStart = parseInt(m[3] || '0', 10) || 0;
|
|
771
|
+
const content = block.slice(1);
|
|
772
|
+
|
|
773
|
+
if (wantWhole.has(h)) {
|
|
774
|
+
out += header + '\n' + content.join('\n') + '\n';
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const picksRaw = (sel.partial_hunks && Array.isArray(sel.partial_hunks[h]))
|
|
779
|
+
? sel.partial_hunks[h]
|
|
780
|
+
: (sel.partial_hunks && sel.partial_hunks[h] ? sel.partial_hunks[h] : []);
|
|
781
|
+
const picksAdj = Array.isArray(picksRaw)
|
|
782
|
+
? picksRaw.map((i: number) => i - 1).filter((i: number) => i >= 0 && i < content.length)
|
|
783
|
+
: [];
|
|
784
|
+
const pickSet = new Set<number>(picksAdj);
|
|
785
|
+
if (pickSet.size === 0) continue;
|
|
786
|
+
|
|
787
|
+
// prefix counts for old/new positions
|
|
788
|
+
const prefOld: number[] = new Array(content.length + 1).fill(0);
|
|
789
|
+
const prefNew: number[] = new Array(content.length + 1).fill(0);
|
|
790
|
+
for (let i = 0; i < content.length; i++) {
|
|
791
|
+
const ch = (content[i] || '')[0] || ' ';
|
|
792
|
+
const isMeta = ch === '\\';
|
|
793
|
+
prefOld[i + 1] = prefOld[i] + (isMeta ? 0 : (ch === '+' ? 0 : 1));
|
|
794
|
+
prefNew[i + 1] = prefNew[i] + (isMeta ? 0 : (ch === '-' ? 0 : 1));
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Group consecutive selected lines into mini-hunks
|
|
798
|
+
const sorted = Array.from(pickSet).sort((x, y) => x - y);
|
|
799
|
+
let group: number[] = [];
|
|
800
|
+
|
|
801
|
+
const flush = (): void => {
|
|
802
|
+
/* c8 ignore next 3 */
|
|
803
|
+
if (group.length === 0) return;
|
|
804
|
+
const i0 = group[0];
|
|
805
|
+
const old_start = aStart + prefOld[i0];
|
|
806
|
+
const new_start = cStart + prefNew[i0];
|
|
807
|
+
const slice = group.map(i => content[i]);
|
|
808
|
+
const contentLines = slice.filter(l => (l || '')[0] !== '\\');
|
|
809
|
+
const metaLines = slice.filter(l => (l || '')[0] === '\\');
|
|
810
|
+
const old_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '+'; }).length;
|
|
811
|
+
const new_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '-'; }).length;
|
|
812
|
+
if (old_count === 0 && new_count === 0) { group = []; return; }
|
|
813
|
+
out += `@@ -${old_start},${old_count} +${new_start},${new_count} @@\n`;
|
|
814
|
+
out += contentLines.join('\n') + '\n';
|
|
815
|
+
if (metaLines.length) out += metaLines.join('\n') + '\n';
|
|
816
|
+
group = [];
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
820
|
+
if (group.length === 0) { group.push(sorted[i]); continue; }
|
|
821
|
+
if (sorted[i] === group[group.length - 1] + 1) group.push(sorted[i]);
|
|
822
|
+
else { flush(); group.push(sorted[i]); }
|
|
823
|
+
}
|
|
824
|
+
flush();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
filePatches.push(out.trimEnd());
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (filePatches.length === 0) return;
|
|
831
|
+
|
|
832
|
+
const combinedPatch = filePatches.join('\n') + '\n';
|
|
833
|
+
this.runChecked(['apply', '--cached', '--unidiff-zero'], 'git-stage-patch-failed', {
|
|
834
|
+
stdin: combinedPatch,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
623
838
|
applyReversePatch(patch: string): void {
|
|
624
839
|
this.runChecked(['apply', '-R', '--unidiff-zero'], 'git-apply-reverse-failed', {
|
|
625
840
|
stdin: patch,
|
|
@@ -44,6 +44,8 @@ function splitDiffLines(output: string): string[] {
|
|
|
44
44
|
/** Reduces a file status string to the primary status code needed for discard routing. */
|
|
45
45
|
function getPrimaryDiscardStatus(status: string): string {
|
|
46
46
|
const normalized = asTrimmedString(status);
|
|
47
|
+
/* c8 ignore next 3 */
|
|
48
|
+
// Unreachable: parseStatusOutput always produces non-empty status (falls back to 'M')
|
|
47
49
|
if (!normalized) {
|
|
48
50
|
return 'M';
|
|
49
51
|
}
|
|
@@ -54,6 +56,8 @@ function getPrimaryDiscardStatus(status: string): string {
|
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
/* c8 ignore next 3 */
|
|
60
|
+
// Unreachable: normalized[0] is always defined on non-empty string
|
|
57
61
|
return normalized[0] ?? 'M';
|
|
58
62
|
}
|
|
59
63
|
|
|
@@ -232,6 +236,9 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
232
236
|
.map((line): OpenVcs.VcsBranchEntry => {
|
|
233
237
|
const [name = '', fullRef = '', headMark = ''] = line.split('\t');
|
|
234
238
|
const isRemote = fullRef.startsWith('refs/remotes/');
|
|
239
|
+
// isRemote is true → name = 'origin/main'. split('/')[0] = 'origin' (always defined).
|
|
240
|
+
// ?? null is unreachable, kept for type-safety.
|
|
241
|
+
/* c8 ignore next */
|
|
235
242
|
const remote = isRemote ? name.split('/')[0] ?? null : null;
|
|
236
243
|
const kind: OpenVcs.VcsBranchKind = isRemote
|
|
237
244
|
? { type: 'Remote', remote }
|
|
@@ -428,9 +435,9 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
428
435
|
override diffFile(
|
|
429
436
|
params: OpenVcs.VcsDiffFileParams,
|
|
430
437
|
_context: PluginRuntimeContext,
|
|
431
|
-
):
|
|
438
|
+
): OpenVcs.VcsDiffFileResponse {
|
|
432
439
|
const git = this.requireGit(params.session_id);
|
|
433
|
-
return
|
|
440
|
+
return git.diffFile(asTrimmedString(params.path));
|
|
434
441
|
}
|
|
435
442
|
|
|
436
443
|
override diffCommit(
|
|
@@ -484,6 +491,15 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
484
491
|
return null;
|
|
485
492
|
}
|
|
486
493
|
|
|
494
|
+
override stageSelections(
|
|
495
|
+
params: OpenVcs.VcsStageSelectionsParams,
|
|
496
|
+
_context: PluginRuntimeContext,
|
|
497
|
+
): null {
|
|
498
|
+
const git = this.requireGit(params.session_id);
|
|
499
|
+
git.stageSelections(params.selections);
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
|
|
487
503
|
override stagePaths(
|
|
488
504
|
params: OpenVcs.VcsStagePathsParams,
|
|
489
505
|
_context: PluginRuntimeContext,
|