@openvcs/git-plugin 0.3.2-nightly.20260603.124 → 0.3.2-nightly.20260613.146

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/bin/git.js CHANGED
@@ -88,7 +88,7 @@ export class GitCommand {
88
88
  const baseLine = isCurrent ? line.slice(0, -1) : line;
89
89
  const name = baseLine.trim();
90
90
  if (name) {
91
- branches.push({ name, current: name === current || (isCurrent && name === current) });
91
+ branches.push({ name, current: name === current });
92
92
  }
93
93
  }
94
94
  return { current, branches };
@@ -302,7 +302,7 @@ export class GitCommand {
302
302
  return false;
303
303
  }
304
304
  return lines.some((line) => {
305
- const candidate = String(line || '');
305
+ const candidate = String(line);
306
306
  return /^binary files /i.test(candidate)
307
307
  || /^git binary patch/i.test(candidate)
308
308
  || /^literal /i.test(candidate);
@@ -359,7 +359,7 @@ export class GitCommand {
359
359
  const line = rawLine.trim();
360
360
  if (!line)
361
361
  continue;
362
- const marker = line[0] || ' ';
362
+ const marker = line[0];
363
363
  const rest = line.slice(1).trim();
364
364
  const [commit = '', path = ''] = rest.split(/\s+/);
365
365
  if (!path)
@@ -409,7 +409,7 @@ export class GitCommand {
409
409
  this.runChecked(['submodule', 'deinit', '-f', '--', path], 'git-submodule-remove-failed');
410
410
  this.runChecked(['rm', '-f', '--', path], 'git-submodule-remove-failed');
411
411
  const modulesPath = join(this.cwd, '.git', 'modules', path);
412
- /* c8 ignore next 4 */
412
+ /* c8 ignore next 5 */
413
413
  try {
414
414
  rmSync(modulesPath, { recursive: true, force: true });
415
415
  }
@@ -517,6 +517,140 @@ export class GitCommand {
517
517
  stdin: patch,
518
518
  });
519
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
+ }
520
654
  applyReversePatch(patch) {
521
655
  this.runChecked(['apply', '-R', '--unidiff-zero'], 'git-apply-reverse-failed', {
522
656
  stdin: patch,
@@ -545,8 +679,21 @@ export class GitCommand {
545
679
  this.runChecked(['config', '--local', 'user.name', name], 'git-identity-set-failed');
546
680
  this.runChecked(['config', '--local', 'user.email', email], 'git-identity-set-failed');
547
681
  }
548
- mergeIntoCurrent(branch) {
549
- this.runChecked(['merge', branch], 'git-merge-failed');
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
+ }
550
697
  }
551
698
  mergeAbort() {
552
699
  this.runChecked(['merge', '--abort'], 'git-merge-abort-failed');
@@ -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 }
@@ -295,6 +303,11 @@ export class GitVcsDelegates extends VcsDelegateBase {
295
303
  git.stagePatch(asString(params.patch));
296
304
  return null;
297
305
  }
306
+ stageSelections(params, _context) {
307
+ const git = this.requireGit(params.session_id);
308
+ git.stageSelections(params.selections);
309
+ return null;
310
+ }
298
311
  stagePaths(params, _context) {
299
312
  const git = this.requireGit(params.session_id);
300
313
  git.stagePaths(asStringArray(params.paths));
@@ -350,7 +363,12 @@ export class GitVcsDelegates extends VcsDelegateBase {
350
363
  }
351
364
  mergeIntoCurrent(params, _context) {
352
365
  const git = this.requireGit(params.session_id);
353
- git.mergeIntoCurrent(asTrimmedString(params.name));
366
+ const rawStrategy = asTrimmedString(params.strategy);
367
+ const strategy = rawStrategy && !['merge', 'squash', 'rebase'].includes(rawStrategy)
368
+ ? (() => { throw pluginError('vcs-merge-invalid-strategy', `Unknown merge strategy '${rawStrategy}'. Must be 'merge', 'squash', or 'rebase'.`); })()
369
+ : (rawStrategy || undefined);
370
+ const message = asTrimmedString(params.message) || undefined;
371
+ git.mergeIntoCurrent(asTrimmedString(params.name), strategy, message);
354
372
  return null;
355
373
  }
356
374
  mergeAbort(params, _context) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvcs/git-plugin",
3
- "version": "0.3.2-nightly.20260603.124",
3
+ "version": "0.3.2-nightly.20260613.146",
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.20260603.124"
38
+ "version": "0.3.2-nightly.20260613.146"
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 95 --functions 95 --branches 95 --statements 95 --per-file tsx --test test/*.test.ts",
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
@@ -180,7 +180,7 @@ export class GitCommand {
180
180
  const baseLine = isCurrent ? line.slice(0, -1) : line;
181
181
  const name = baseLine.trim();
182
182
  if (name) {
183
- branches.push({ name, current: name === current || (isCurrent && name === current) });
183
+ branches.push({ name, current: name === current });
184
184
  }
185
185
  }
186
186
 
@@ -441,7 +441,7 @@ export class GitCommand {
441
441
  }
442
442
 
443
443
  return lines.some((line) => {
444
- const candidate = String(line || '');
444
+ const candidate = String(line);
445
445
  return /^binary files /i.test(candidate)
446
446
  || /^git binary patch/i.test(candidate)
447
447
  || /^literal /i.test(candidate);
@@ -504,7 +504,7 @@ export class GitCommand {
504
504
  const line = rawLine.trim();
505
505
  if (!line) continue;
506
506
 
507
- const marker = line[0] || ' ';
507
+ const marker = line[0];
508
508
  const rest = line.slice(1).trim();
509
509
  const [commit = '', path = ''] = rest.split(/\s+/);
510
510
  if (!path) continue;
@@ -562,7 +562,7 @@ export class GitCommand {
562
562
  this.runChecked(['rm', '-f', '--', path], 'git-submodule-remove-failed');
563
563
 
564
564
  const modulesPath = join(this.cwd, '.git', 'modules', path);
565
- /* c8 ignore next 4 */
565
+ /* c8 ignore next 5 */
566
566
  try {
567
567
  rmSync(modulesPath, { recursive: true, force: true });
568
568
  } catch {
@@ -698,6 +698,143 @@ export class GitCommand {
698
698
  });
699
699
  }
700
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
+
701
838
  applyReversePatch(patch: string): void {
702
839
  this.runChecked(['apply', '-R', '--unidiff-zero'], 'git-apply-reverse-failed', {
703
840
  stdin: patch,
@@ -733,8 +870,21 @@ export class GitCommand {
733
870
  this.runChecked(['config', '--local', 'user.email', email], 'git-identity-set-failed');
734
871
  }
735
872
 
736
- mergeIntoCurrent(branch: string): void {
737
- this.runChecked(['merge', branch], 'git-merge-failed');
873
+ mergeIntoCurrent(branch: string, strategy?: string, message?: string): void {
874
+ switch (strategy) {
875
+ case 'squash': {
876
+ this.runChecked(['merge', '--squash', branch], 'git-merge-failed');
877
+ const commitArgs = ['commit', '-m', message ?? `Merge branch '${branch}'`];
878
+ this.runChecked(commitArgs, 'git-merge-failed');
879
+ break;
880
+ }
881
+ case 'rebase':
882
+ this.runChecked(['merge', '--rebase', branch], 'git-merge-failed');
883
+ break;
884
+ default:
885
+ this.runChecked(['merge', branch], 'git-merge-failed');
886
+ break;
887
+ }
738
888
  }
739
889
 
740
890
  mergeAbort(): void {
@@ -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 }
@@ -484,6 +492,15 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
484
492
  return null;
485
493
  }
486
494
 
495
+ override stageSelections(
496
+ params: OpenVcs.VcsStageSelectionsParams,
497
+ _context: PluginRuntimeContext,
498
+ ): null {
499
+ const git = this.requireGit(params.session_id);
500
+ git.stageSelections(params.selections);
501
+ return null;
502
+ }
503
+
487
504
  override stagePaths(
488
505
  params: OpenVcs.VcsStagePathsParams,
489
506
  _context: PluginRuntimeContext,
@@ -576,7 +593,12 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
576
593
  _context: PluginRuntimeContext,
577
594
  ): null {
578
595
  const git = this.requireGit(params.session_id);
579
- git.mergeIntoCurrent(asTrimmedString(params.name));
596
+ const rawStrategy = asTrimmedString(params.strategy);
597
+ const strategy = rawStrategy && !['merge', 'squash', 'rebase'].includes(rawStrategy)
598
+ ? (() => { throw pluginError('vcs-merge-invalid-strategy', `Unknown merge strategy '${rawStrategy}'. Must be 'merge', 'squash', or 'rebase'.`); })()
599
+ : (rawStrategy || undefined);
600
+ const message = asTrimmedString(params.message) || undefined;
601
+ git.mergeIntoCurrent(asTrimmedString(params.name), strategy, message);
580
602
  return null;
581
603
  }
582
604