@openvcs/git-plugin 0.3.2 → 0.3.3-edge.20260613.148
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 +225 -12
- package/bin/plugin-helpers.js +1 -5
- package/bin/plugin-request-handler.js +20 -4
- package/package.json +6 -7
- package/src/git.ts +240 -15
- package/src/plugin-helpers.ts +1 -6
- package/src/plugin-request-handler.ts +25 -5
- 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}^`]);
|
|
@@ -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,140 @@ 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
|
+
/* 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
|
+
}
|
|
454
654
|
applyReversePatch(patch) {
|
|
455
655
|
this.runChecked(['apply', '-R', '--unidiff-zero'], 'git-apply-reverse-failed', {
|
|
456
656
|
stdin: patch,
|
|
@@ -479,8 +679,21 @@ export class GitCommand {
|
|
|
479
679
|
this.runChecked(['config', '--local', 'user.name', name], 'git-identity-set-failed');
|
|
480
680
|
this.runChecked(['config', '--local', 'user.email', email], 'git-identity-set-failed');
|
|
481
681
|
}
|
|
482
|
-
mergeIntoCurrent(branch) {
|
|
483
|
-
|
|
682
|
+
mergeIntoCurrent(branch, strategy, message) {
|
|
683
|
+
switch (strategy) {
|
|
684
|
+
case 'squash': {
|
|
685
|
+
this.runChecked(['merge', '--squash', branch], 'git-merge-failed');
|
|
686
|
+
const commitArgs = ['commit', '-m', message ?? `Merge branch '${branch}'`];
|
|
687
|
+
this.runChecked(commitArgs, 'git-merge-failed');
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
case 'rebase':
|
|
691
|
+
this.runChecked(['merge', '--rebase', branch], 'git-merge-failed');
|
|
692
|
+
break;
|
|
693
|
+
default:
|
|
694
|
+
this.runChecked(['merge', branch], 'git-merge-failed');
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
484
697
|
}
|
|
485
698
|
mergeAbort() {
|
|
486
699
|
this.runChecked(['merge', '--abort'], 'git-merge-abort-failed');
|
package/bin/plugin-helpers.js
CHANGED
|
@@ -36,11 +36,7 @@ function pushOptionalArg(args, value) {
|
|
|
36
36
|
}
|
|
37
37
|
/** Builds `git fetch` arguments while omitting empty optional values. */
|
|
38
38
|
export function buildFetchArgs(params) {
|
|
39
|
-
const args = ['fetch'];
|
|
40
|
-
const options = asRecord(params.opts);
|
|
41
|
-
if (options.prune === true) {
|
|
42
|
-
args.push('--prune');
|
|
43
|
-
}
|
|
39
|
+
const args = ['fetch', '--prune'];
|
|
44
40
|
pushOptionalArg(args, params.remote);
|
|
45
41
|
pushOptionalArg(args, params.refspec);
|
|
46
42
|
return args;
|
|
@@ -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. */
|
|
@@ -86,6 +90,7 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
86
90
|
staging: true,
|
|
87
91
|
push_pull: true,
|
|
88
92
|
fast_forward: true,
|
|
93
|
+
merge_strategies: ['merge', 'squash', 'rebase'],
|
|
89
94
|
};
|
|
90
95
|
}
|
|
91
96
|
open(params, _context) {
|
|
@@ -142,6 +147,9 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
142
147
|
.map((line) => {
|
|
143
148
|
const [name = '', fullRef = '', headMark = ''] = line.split('\t');
|
|
144
149
|
const isRemote = fullRef.startsWith('refs/remotes/');
|
|
150
|
+
// isRemote is true → name = 'origin/main'. split('/')[0] = 'origin' (always defined).
|
|
151
|
+
// ?? null is unreachable, kept for type-safety.
|
|
152
|
+
/* c8 ignore next */
|
|
145
153
|
const remote = isRemote ? name.split('/')[0] ?? null : null;
|
|
146
154
|
const kind = isRemote
|
|
147
155
|
? { type: 'Remote', remote }
|
|
@@ -205,11 +213,9 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
205
213
|
}
|
|
206
214
|
fetch(params, _context) {
|
|
207
215
|
const git = this.requireGit(params.session_id);
|
|
208
|
-
const options = asRecord(params.opts);
|
|
209
216
|
git.fetch({
|
|
210
217
|
remote: asTrimmedString(params.remote) || undefined,
|
|
211
218
|
refspec: asTrimmedString(params.refspec) || undefined,
|
|
212
|
-
opts: { prune: options.prune === true },
|
|
213
219
|
});
|
|
214
220
|
return null;
|
|
215
221
|
}
|
|
@@ -265,7 +271,7 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
265
271
|
}
|
|
266
272
|
diffFile(params, _context) {
|
|
267
273
|
const git = this.requireGit(params.session_id);
|
|
268
|
-
return
|
|
274
|
+
return git.diffFile(asTrimmedString(params.path));
|
|
269
275
|
}
|
|
270
276
|
diffCommit(params, _context) {
|
|
271
277
|
const git = this.requireGit(params.session_id);
|
|
@@ -295,6 +301,11 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
295
301
|
git.stagePatch(asString(params.patch));
|
|
296
302
|
return null;
|
|
297
303
|
}
|
|
304
|
+
stageSelections(params, _context) {
|
|
305
|
+
const git = this.requireGit(params.session_id);
|
|
306
|
+
git.stageSelections(params.selections);
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
298
309
|
stagePaths(params, _context) {
|
|
299
310
|
const git = this.requireGit(params.session_id);
|
|
300
311
|
git.stagePaths(asStringArray(params.paths));
|
|
@@ -350,7 +361,12 @@ export class GitVcsDelegates extends VcsDelegateBase {
|
|
|
350
361
|
}
|
|
351
362
|
mergeIntoCurrent(params, _context) {
|
|
352
363
|
const git = this.requireGit(params.session_id);
|
|
353
|
-
|
|
364
|
+
const rawStrategy = asTrimmedString(params.strategy);
|
|
365
|
+
const strategy = rawStrategy && !['merge', 'squash', 'rebase'].includes(rawStrategy)
|
|
366
|
+
? (() => { throw pluginError('vcs-merge-invalid-strategy', `Unknown merge strategy '${rawStrategy}'. Must be 'merge', 'squash', or 'rebase'.`); })()
|
|
367
|
+
: (rawStrategy || undefined);
|
|
368
|
+
const message = asTrimmedString(params.message) || undefined;
|
|
369
|
+
git.mergeIntoCurrent(asTrimmedString(params.name), strategy, message);
|
|
354
370
|
return null;
|
|
355
371
|
}
|
|
356
372
|
mergeAbort(params, _context) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openvcs/git-plugin",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.3-edge.20260613.148",
|
|
4
|
+
"description": "Git VCS backend plugin for OpenVCS",
|
|
5
|
+
"author": "OpenVCS Contributors",
|
|
5
6
|
"license": "GPL-3.0-or-later",
|
|
6
7
|
"homepage": "https://github.com/Open-VCS/OpenVCS-Plugin-Git",
|
|
7
8
|
"repository": {
|
|
@@ -18,9 +19,6 @@
|
|
|
18
19
|
"openvcs": {
|
|
19
20
|
"id": "openvcs.git",
|
|
20
21
|
"name": "Git",
|
|
21
|
-
"version": "0.3.2",
|
|
22
|
-
"author": "OpenVCS Contributors",
|
|
23
|
-
"description": "Git VCS backend plugin for OpenVCS",
|
|
24
22
|
"default_enabled": true,
|
|
25
23
|
"module": {
|
|
26
24
|
"exec": "openvcs-git-plugin.js",
|
|
@@ -36,12 +34,13 @@
|
|
|
36
34
|
}
|
|
37
35
|
}
|
|
38
36
|
]
|
|
39
|
-
}
|
|
37
|
+
},
|
|
38
|
+
"version": "0.3.3-edge.20260613.148"
|
|
40
39
|
},
|
|
41
40
|
"scripts": {
|
|
42
41
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
43
42
|
"test": "tsx --test test/*.test.ts",
|
|
44
|
-
"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",
|
|
45
44
|
"prepack": "npm run build",
|
|
46
45
|
"build:plugin": "tsc -p tsconfig.json",
|
|
47
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 {
|
|
@@ -25,7 +27,6 @@ import {
|
|
|
25
27
|
export interface FetchOptions {
|
|
26
28
|
remote?: string;
|
|
27
29
|
refspec?: string;
|
|
28
|
-
opts?: { prune?: boolean };
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export interface PushOptions {
|
|
@@ -65,7 +66,6 @@ export interface ConflictDetails {
|
|
|
65
66
|
theirs: string | null;
|
|
66
67
|
base: string | null;
|
|
67
68
|
binary: boolean;
|
|
68
|
-
lfs_pointer: boolean;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
export interface StashEntry {
|
|
@@ -151,7 +151,17 @@ export class GitCommand {
|
|
|
151
151
|
status(): StatusParseResult & { exitCode: number } {
|
|
152
152
|
const result = this.run(['status', '--porcelain=1', '--branch', '-z', '-uall']);
|
|
153
153
|
const parsed = applySubmoduleStatusHints(parseStatusOutput(result.stdout), this.listSubmodulePaths());
|
|
154
|
-
return {
|
|
154
|
+
return {
|
|
155
|
+
...parsed,
|
|
156
|
+
payload: {
|
|
157
|
+
...parsed.payload,
|
|
158
|
+
files: parsed.payload.files.map((file) => ({
|
|
159
|
+
...file,
|
|
160
|
+
binary: this.detectStatusFileBinary(file),
|
|
161
|
+
})),
|
|
162
|
+
},
|
|
163
|
+
exitCode: result.status,
|
|
164
|
+
};
|
|
155
165
|
}
|
|
156
166
|
|
|
157
167
|
currentBranch(): string {
|
|
@@ -169,7 +179,7 @@ export class GitCommand {
|
|
|
169
179
|
const baseLine = isCurrent ? line.slice(0, -1) : line;
|
|
170
180
|
const name = baseLine.trim();
|
|
171
181
|
if (name) {
|
|
172
|
-
branches.push({ name, current: name === current
|
|
182
|
+
branches.push({ name, current: name === current });
|
|
173
183
|
}
|
|
174
184
|
}
|
|
175
185
|
|
|
@@ -416,6 +426,63 @@ export class GitCommand {
|
|
|
416
426
|
return new Set(this.readSubmoduleConfig().byPath.keys());
|
|
417
427
|
}
|
|
418
428
|
|
|
429
|
+
/** Splits diff stdout without manufacturing a blank line for empty output. */
|
|
430
|
+
private splitDiffLines(output: string): string[] {
|
|
431
|
+
const normalized = output.trimEnd();
|
|
432
|
+
return normalized.length > 0 ? normalized.split('\n') : [];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Returns true when Git already reported the diff target as binary. */
|
|
436
|
+
private isBinaryDiffOutput(output: string): boolean {
|
|
437
|
+
const lines = this.splitDiffLines(output);
|
|
438
|
+
if (lines.length === 0) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return lines.some((line) => {
|
|
443
|
+
const candidate = String(line);
|
|
444
|
+
return /^binary files /i.test(candidate)
|
|
445
|
+
|| /^git binary patch/i.test(candidate)
|
|
446
|
+
|| /^literal /i.test(candidate);
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Returns binary classification for a status entry when worktree bytes are available. */
|
|
451
|
+
private detectStatusFileBinary(file: StatusFileEntry): boolean | null {
|
|
452
|
+
return this.readWorktreeBinaryFlag(file.path);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Reads worktree bytes and classifies likely binary content, or `null` when unavailable. */
|
|
456
|
+
private readWorktreeBinaryFlag(path: string): boolean | null {
|
|
457
|
+
const trimmedPath = String(path || '').trim();
|
|
458
|
+
if (!trimmedPath) {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
const contents = readFileSync(join(this.cwd, trimmedPath));
|
|
464
|
+
return this.isBinaryBuffer(contents);
|
|
465
|
+
} catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Applies a lightweight byte sniff that keeps UTF-16 BOM text out of binary placeholders. */
|
|
471
|
+
private isBinaryBuffer(contents: Uint8Array): boolean {
|
|
472
|
+
if (contents.length === 0) {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const hasUtf16LeBom = contents.length >= 2 && contents[0] === 0xff && contents[1] === 0xfe;
|
|
477
|
+
const hasUtf16BeBom = contents.length >= 2 && contents[0] === 0xfe && contents[1] === 0xff;
|
|
478
|
+
if (hasUtf16LeBom || hasUtf16BeBom) {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const sample = contents.subarray(0, Math.min(contents.length, 8192));
|
|
483
|
+
return sample.includes(0);
|
|
484
|
+
}
|
|
485
|
+
|
|
419
486
|
listSubmodules(): SubmoduleEntry[] {
|
|
420
487
|
const { byPath: configByPath } = this.readSubmoduleConfig();
|
|
421
488
|
|
|
@@ -436,7 +503,7 @@ export class GitCommand {
|
|
|
436
503
|
const line = rawLine.trim();
|
|
437
504
|
if (!line) continue;
|
|
438
505
|
|
|
439
|
-
const marker = line[0]
|
|
506
|
+
const marker = line[0];
|
|
440
507
|
const rest = line.slice(1).trim();
|
|
441
508
|
const [commit = '', path = ''] = rest.split(/\s+/);
|
|
442
509
|
if (!path) continue;
|
|
@@ -494,7 +561,7 @@ export class GitCommand {
|
|
|
494
561
|
this.runChecked(['rm', '-f', '--', path], 'git-submodule-remove-failed');
|
|
495
562
|
|
|
496
563
|
const modulesPath = join(this.cwd, '.git', 'modules', path);
|
|
497
|
-
/* c8 ignore next
|
|
564
|
+
/* c8 ignore next 5 */
|
|
498
565
|
try {
|
|
499
566
|
rmSync(modulesPath, { recursive: true, force: true });
|
|
500
567
|
} catch {
|
|
@@ -502,12 +569,21 @@ export class GitCommand {
|
|
|
502
569
|
}
|
|
503
570
|
}
|
|
504
571
|
|
|
505
|
-
diffFile(path: string):
|
|
572
|
+
diffFile(path: string): VcsDiffResult {
|
|
506
573
|
const cachedDiff = this.runChecked(['diff', '--cached', '--no-ext-diff', '--', path], 'git-diff-failed')
|
|
507
574
|
.stdout;
|
|
508
575
|
const worktreeDiff = this.runChecked(['diff', '--no-ext-diff', '--', path], 'git-diff-failed')
|
|
509
576
|
.stdout;
|
|
510
|
-
|
|
577
|
+
const lines = this.splitDiffLines(cachedDiff + worktreeDiff);
|
|
578
|
+
const binaryFromOutput = this.isBinaryDiffOutput(cachedDiff) || this.isBinaryDiffOutput(worktreeDiff);
|
|
579
|
+
const binary = binaryFromOutput
|
|
580
|
+
? true
|
|
581
|
+
: (lines.length > 0 ? false : this.readWorktreeBinaryFlag(path));
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
lines,
|
|
585
|
+
binary,
|
|
586
|
+
};
|
|
511
587
|
}
|
|
512
588
|
|
|
513
589
|
diffCommit(commit: string): string {
|
|
@@ -528,15 +604,15 @@ export class GitCommand {
|
|
|
528
604
|
const base = this.run(['show', `:1:${path}`]);
|
|
529
605
|
|
|
530
606
|
if (ours.status !== 0 || theirs.status !== 0) {
|
|
531
|
-
return { path, ours: null, theirs: null, base: null, binary: false
|
|
607
|
+
return { path, ours: null, theirs: null, base: null, binary: false };
|
|
532
608
|
}
|
|
533
609
|
|
|
534
610
|
const oursContent = ours.stdout;
|
|
535
|
-
const
|
|
611
|
+
const lfsPointer =
|
|
536
612
|
ours.stdout.includes('version https://git-lfs.github.com/spec/v1') ||
|
|
537
613
|
theirs.stdout.includes('version https://git-lfs.github.com/spec/v1');
|
|
538
614
|
|
|
539
|
-
const binary = !
|
|
615
|
+
const binary = !lfsPointer && (oursContent.startsWith('Binary\0') || oursContent.includes('\0'));
|
|
540
616
|
|
|
541
617
|
return {
|
|
542
618
|
path,
|
|
@@ -544,7 +620,6 @@ export class GitCommand {
|
|
|
544
620
|
theirs: theirs.stdout,
|
|
545
621
|
base: base.status === 0 ? base.stdout : null,
|
|
546
622
|
binary,
|
|
547
|
-
lfs_pointer,
|
|
548
623
|
};
|
|
549
624
|
}
|
|
550
625
|
|
|
@@ -622,6 +697,143 @@ export class GitCommand {
|
|
|
622
697
|
});
|
|
623
698
|
}
|
|
624
699
|
|
|
700
|
+
/**
|
|
701
|
+
* Stages structured hunk/line selections (VCS-agnostic).
|
|
702
|
+
*
|
|
703
|
+
* For each file, fetches the worktree diff, extracts the selected hunks/lines,
|
|
704
|
+
* builds a combined sub-patch, and applies it to the index atomically.
|
|
705
|
+
*/
|
|
706
|
+
stageSelections(
|
|
707
|
+
selections: Array<{ path: string; whole_hunks: number[]; partial_hunks: Record<number, number[]> }>,
|
|
708
|
+
): void {
|
|
709
|
+
const filePatches: string[] = [];
|
|
710
|
+
|
|
711
|
+
for (const sel of selections) {
|
|
712
|
+
// Fetch only the worktree diff (not --cached) — cached changes are
|
|
713
|
+
// already staged and must not be re-applied.
|
|
714
|
+
const raw = this.runChecked(
|
|
715
|
+
['diff', '--no-ext-diff', '--no-color', '--', sel.path],
|
|
716
|
+
'git-diff-failed',
|
|
717
|
+
).stdout;
|
|
718
|
+
if (!raw.trim()) continue;
|
|
719
|
+
|
|
720
|
+
const lines = this.splitDiffLines(raw);
|
|
721
|
+
/* c8 ignore next 3 */
|
|
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
|
+
/* c8 ignore next 3 */
|
|
747
|
+
if (starts.length === 0) continue;
|
|
748
|
+
starts.push(rest.length);
|
|
749
|
+
|
|
750
|
+
const isAdd = prelude.some(l => l.startsWith('--- /dev/null'));
|
|
751
|
+
const isDel = prelude.some(l => l.startsWith('+++ /dev/null'));
|
|
752
|
+
const wantWhole = new Set(sel.whole_hunks.filter(n => Number.isFinite(n)));
|
|
753
|
+
|
|
754
|
+
let out = `diff --git a/${normPath} b/${normPath}\n`;
|
|
755
|
+
if (headerExtras.length) out += headerExtras.join('\n') + '\n';
|
|
756
|
+
if (isAdd) out += `--- /dev/null\n+++ b/${normPath}\n`;
|
|
757
|
+
else if (isDel) out += `--- a/${normPath}\n+++ /dev/null\n`;
|
|
758
|
+
else out += `--- a/${normPath}\n+++ b/${normPath}\n`;
|
|
759
|
+
|
|
760
|
+
for (let h = 0; h < starts.length - 1; h++) {
|
|
761
|
+
const s = starts[h];
|
|
762
|
+
const e = starts[h + 1];
|
|
763
|
+
const block = rest.slice(s, e);
|
|
764
|
+
/* c8 ignore next 7 */
|
|
765
|
+
const header = block[0] || '';
|
|
766
|
+
const m = /@@\s*-([0-9]+),?([0-9]*)\s*\+([0-9]+),?([0-9]*)\s*@@/.exec(header);
|
|
767
|
+
if (!m) continue;
|
|
768
|
+
const aStart = parseInt(m[1] || '0', 10) || 0;
|
|
769
|
+
const cStart = parseInt(m[3] || '0', 10) || 0;
|
|
770
|
+
const content = block.slice(1);
|
|
771
|
+
|
|
772
|
+
if (wantWhole.has(h)) {
|
|
773
|
+
out += header + '\n' + content.join('\n') + '\n';
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const picksRaw = (sel.partial_hunks && Array.isArray(sel.partial_hunks[h]))
|
|
778
|
+
? sel.partial_hunks[h]
|
|
779
|
+
: (sel.partial_hunks && sel.partial_hunks[h] ? sel.partial_hunks[h] : []);
|
|
780
|
+
const picksAdj = Array.isArray(picksRaw)
|
|
781
|
+
? picksRaw.map((i: number) => i - 1).filter((i: number) => i >= 0 && i < content.length)
|
|
782
|
+
: [];
|
|
783
|
+
const pickSet = new Set<number>(picksAdj);
|
|
784
|
+
if (pickSet.size === 0) continue;
|
|
785
|
+
|
|
786
|
+
// prefix counts for old/new positions
|
|
787
|
+
const prefOld: number[] = new Array(content.length + 1).fill(0);
|
|
788
|
+
const prefNew: number[] = new Array(content.length + 1).fill(0);
|
|
789
|
+
for (let i = 0; i < content.length; i++) {
|
|
790
|
+
const ch = (content[i] || '')[0] || ' ';
|
|
791
|
+
const isMeta = ch === '\\';
|
|
792
|
+
prefOld[i + 1] = prefOld[i] + (isMeta ? 0 : (ch === '+' ? 0 : 1));
|
|
793
|
+
prefNew[i + 1] = prefNew[i] + (isMeta ? 0 : (ch === '-' ? 0 : 1));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Group consecutive selected lines into mini-hunks
|
|
797
|
+
const sorted = Array.from(pickSet).sort((x, y) => x - y);
|
|
798
|
+
let group: number[] = [];
|
|
799
|
+
|
|
800
|
+
const flush = (): void => {
|
|
801
|
+
/* c8 ignore next 3 */
|
|
802
|
+
if (group.length === 0) return;
|
|
803
|
+
const i0 = group[0];
|
|
804
|
+
const old_start = aStart + prefOld[i0];
|
|
805
|
+
const new_start = cStart + prefNew[i0];
|
|
806
|
+
const slice = group.map(i => content[i]);
|
|
807
|
+
const contentLines = slice.filter(l => (l || '')[0] !== '\\');
|
|
808
|
+
const metaLines = slice.filter(l => (l || '')[0] === '\\');
|
|
809
|
+
const old_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '+'; }).length;
|
|
810
|
+
const new_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '-'; }).length;
|
|
811
|
+
if (old_count === 0 && new_count === 0) { group = []; return; }
|
|
812
|
+
out += `@@ -${old_start},${old_count} +${new_start},${new_count} @@\n`;
|
|
813
|
+
out += contentLines.join('\n') + '\n';
|
|
814
|
+
if (metaLines.length) out += metaLines.join('\n') + '\n';
|
|
815
|
+
group = [];
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
819
|
+
if (group.length === 0) { group.push(sorted[i]); continue; }
|
|
820
|
+
if (sorted[i] === group[group.length - 1] + 1) group.push(sorted[i]);
|
|
821
|
+
else { flush(); group.push(sorted[i]); }
|
|
822
|
+
}
|
|
823
|
+
flush();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
filePatches.push(out.trimEnd());
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (filePatches.length === 0) return;
|
|
830
|
+
|
|
831
|
+
const combinedPatch = filePatches.join('\n') + '\n';
|
|
832
|
+
this.runChecked(['apply', '--cached', '--unidiff-zero'], 'git-stage-patch-failed', {
|
|
833
|
+
stdin: combinedPatch,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
625
837
|
applyReversePatch(patch: string): void {
|
|
626
838
|
this.runChecked(['apply', '-R', '--unidiff-zero'], 'git-apply-reverse-failed', {
|
|
627
839
|
stdin: patch,
|
|
@@ -657,8 +869,21 @@ export class GitCommand {
|
|
|
657
869
|
this.runChecked(['config', '--local', 'user.email', email], 'git-identity-set-failed');
|
|
658
870
|
}
|
|
659
871
|
|
|
660
|
-
mergeIntoCurrent(branch: string): void {
|
|
661
|
-
|
|
872
|
+
mergeIntoCurrent(branch: string, strategy?: string, message?: string): void {
|
|
873
|
+
switch (strategy) {
|
|
874
|
+
case 'squash': {
|
|
875
|
+
this.runChecked(['merge', '--squash', branch], 'git-merge-failed');
|
|
876
|
+
const commitArgs = ['commit', '-m', message ?? `Merge branch '${branch}'`];
|
|
877
|
+
this.runChecked(commitArgs, 'git-merge-failed');
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
case 'rebase':
|
|
881
|
+
this.runChecked(['merge', '--rebase', branch], 'git-merge-failed');
|
|
882
|
+
break;
|
|
883
|
+
default:
|
|
884
|
+
this.runChecked(['merge', branch], 'git-merge-failed');
|
|
885
|
+
break;
|
|
886
|
+
}
|
|
662
887
|
}
|
|
663
888
|
|
|
664
889
|
mergeAbort(): void {
|
package/src/plugin-helpers.ts
CHANGED
|
@@ -53,12 +53,7 @@ function pushOptionalArg(args: string[], value: unknown): void {
|
|
|
53
53
|
|
|
54
54
|
/** Builds `git fetch` arguments while omitting empty optional values. */
|
|
55
55
|
export function buildFetchArgs(params: RequestParams): string[] {
|
|
56
|
-
const args = ['fetch'];
|
|
57
|
-
const options = asRecord(params.opts);
|
|
58
|
-
|
|
59
|
-
if (options.prune === true) {
|
|
60
|
-
args.push('--prune');
|
|
61
|
-
}
|
|
56
|
+
const args = ['fetch', '--prune'];
|
|
62
57
|
|
|
63
58
|
pushOptionalArg(args, params.remote);
|
|
64
59
|
pushOptionalArg(args, params.refspec);
|
|
@@ -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
|
|
|
@@ -143,6 +147,7 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
143
147
|
staging: true,
|
|
144
148
|
push_pull: true,
|
|
145
149
|
fast_forward: true,
|
|
150
|
+
merge_strategies: ['merge', 'squash', 'rebase'],
|
|
146
151
|
};
|
|
147
152
|
}
|
|
148
153
|
|
|
@@ -232,6 +237,9 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
232
237
|
.map((line): OpenVcs.VcsBranchEntry => {
|
|
233
238
|
const [name = '', fullRef = '', headMark = ''] = line.split('\t');
|
|
234
239
|
const isRemote = fullRef.startsWith('refs/remotes/');
|
|
240
|
+
// isRemote is true → name = 'origin/main'. split('/')[0] = 'origin' (always defined).
|
|
241
|
+
// ?? null is unreachable, kept for type-safety.
|
|
242
|
+
/* c8 ignore next */
|
|
235
243
|
const remote = isRemote ? name.split('/')[0] ?? null : null;
|
|
236
244
|
const kind: OpenVcs.VcsBranchKind = isRemote
|
|
237
245
|
? { type: 'Remote', remote }
|
|
@@ -329,11 +337,9 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
329
337
|
_context: PluginRuntimeContext,
|
|
330
338
|
): null {
|
|
331
339
|
const git = this.requireGit(params.session_id);
|
|
332
|
-
const options = asRecord(params.opts);
|
|
333
340
|
git.fetch({
|
|
334
341
|
remote: asTrimmedString(params.remote) || undefined,
|
|
335
342
|
refspec: asTrimmedString(params.refspec) || undefined,
|
|
336
|
-
opts: { prune: options.prune === true },
|
|
337
343
|
});
|
|
338
344
|
return null;
|
|
339
345
|
}
|
|
@@ -428,9 +434,9 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
428
434
|
override diffFile(
|
|
429
435
|
params: OpenVcs.VcsDiffFileParams,
|
|
430
436
|
_context: PluginRuntimeContext,
|
|
431
|
-
):
|
|
437
|
+
): OpenVcs.VcsDiffFileResponse {
|
|
432
438
|
const git = this.requireGit(params.session_id);
|
|
433
|
-
return
|
|
439
|
+
return git.diffFile(asTrimmedString(params.path));
|
|
434
440
|
}
|
|
435
441
|
|
|
436
442
|
override diffCommit(
|
|
@@ -484,6 +490,15 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
484
490
|
return null;
|
|
485
491
|
}
|
|
486
492
|
|
|
493
|
+
override stageSelections(
|
|
494
|
+
params: OpenVcs.VcsStageSelectionsParams,
|
|
495
|
+
_context: PluginRuntimeContext,
|
|
496
|
+
): null {
|
|
497
|
+
const git = this.requireGit(params.session_id);
|
|
498
|
+
git.stageSelections(params.selections);
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
487
502
|
override stagePaths(
|
|
488
503
|
params: OpenVcs.VcsStagePathsParams,
|
|
489
504
|
_context: PluginRuntimeContext,
|
|
@@ -576,7 +591,12 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
|
|
|
576
591
|
_context: PluginRuntimeContext,
|
|
577
592
|
): null {
|
|
578
593
|
const git = this.requireGit(params.session_id);
|
|
579
|
-
|
|
594
|
+
const rawStrategy = asTrimmedString(params.strategy);
|
|
595
|
+
const strategy = rawStrategy && !['merge', 'squash', 'rebase'].includes(rawStrategy)
|
|
596
|
+
? (() => { throw pluginError('vcs-merge-invalid-strategy', `Unknown merge strategy '${rawStrategy}'. Must be 'merge', 'squash', or 'rebase'.`); })()
|
|
597
|
+
: (rawStrategy || undefined);
|
|
598
|
+
const message = asTrimmedString(params.message) || undefined;
|
|
599
|
+
git.mergeIntoCurrent(asTrimmedString(params.name), strategy, message);
|
|
580
600
|
return null;
|
|
581
601
|
}
|
|
582
602
|
|