@openvcs/git-plugin 0.3.2-edge.20260603.125 → 0.3.2-edge.20260603.126

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
@@ -517,6 +517,136 @@ 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
+ if (lines.length === 0)
536
+ continue;
537
+ const normPath = sel.path.replace(/\\/g, '/');
538
+ // Find the first @@ line to separate prelude from hunks
539
+ const firstHunk = lines.findIndex(l => l.startsWith('@@'));
540
+ if (firstHunk < 0)
541
+ continue;
542
+ const prelude = lines.slice(0, firstHunk);
543
+ const rest = lines.slice(firstHunk);
544
+ // Build headerExtras: metadata lines that aren't diff --git, ---, or +++
545
+ const headerExtras = prelude.filter(l => !!l &&
546
+ !l.startsWith('diff --git') &&
547
+ !l.startsWith('--- ') &&
548
+ !l.startsWith('+++ '));
549
+ // Locate all hunk @@ positions within rest
550
+ const starts = [];
551
+ for (let i = 0; i < rest.length; i++) {
552
+ if (rest[i].startsWith('@@'))
553
+ starts.push(i);
554
+ }
555
+ if (starts.length === 0)
556
+ continue;
557
+ starts.push(rest.length);
558
+ const isAdd = prelude.some(l => l.startsWith('--- /dev/null'));
559
+ const isDel = prelude.some(l => l.startsWith('+++ /dev/null'));
560
+ const wantWhole = new Set(sel.whole_hunks.filter(n => Number.isFinite(n)));
561
+ let out = `diff --git a/${normPath} b/${normPath}\n`;
562
+ if (headerExtras.length)
563
+ out += headerExtras.join('\n') + '\n';
564
+ if (isAdd)
565
+ out += `--- /dev/null\n+++ b/${normPath}\n`;
566
+ else if (isDel)
567
+ out += `--- a/${normPath}\n+++ /dev/null\n`;
568
+ else
569
+ out += `--- a/${normPath}\n+++ b/${normPath}\n`;
570
+ for (let h = 0; h < starts.length - 1; h++) {
571
+ const s = starts[h];
572
+ const e = starts[h + 1];
573
+ const block = rest.slice(s, e);
574
+ const header = block[0] || '';
575
+ const m = /@@\s*-([0-9]+),?([0-9]*)\s*\+([0-9]+),?([0-9]*)\s*@@/.exec(header);
576
+ if (!m)
577
+ continue;
578
+ const aStart = parseInt(m[1] || '0', 10) || 0;
579
+ const cStart = parseInt(m[3] || '0', 10) || 0;
580
+ const content = block.slice(1);
581
+ if (wantWhole.has(h)) {
582
+ out += header + '\n' + content.join('\n') + '\n';
583
+ continue;
584
+ }
585
+ const picksRaw = (sel.partial_hunks && Array.isArray(sel.partial_hunks[h]))
586
+ ? sel.partial_hunks[h]
587
+ : (sel.partial_hunks && sel.partial_hunks[h] ? sel.partial_hunks[h] : []);
588
+ const picksAdj = Array.isArray(picksRaw)
589
+ ? picksRaw.map((i) => i - 1).filter((i) => i >= 0 && i < content.length)
590
+ : [];
591
+ const pickSet = new Set(picksAdj);
592
+ if (pickSet.size === 0)
593
+ continue;
594
+ // prefix counts for old/new positions
595
+ const prefOld = new Array(content.length + 1).fill(0);
596
+ const prefNew = new Array(content.length + 1).fill(0);
597
+ for (let i = 0; i < content.length; i++) {
598
+ const ch = (content[i] || '')[0] || ' ';
599
+ const isMeta = ch === '\\';
600
+ prefOld[i + 1] = prefOld[i] + (isMeta ? 0 : (ch === '+' ? 0 : 1));
601
+ prefNew[i + 1] = prefNew[i] + (isMeta ? 0 : (ch === '-' ? 0 : 1));
602
+ }
603
+ // Group consecutive selected lines into mini-hunks
604
+ const sorted = Array.from(pickSet).sort((x, y) => x - y);
605
+ let group = [];
606
+ const flush = () => {
607
+ if (group.length === 0)
608
+ return;
609
+ const i0 = group[0];
610
+ const old_start = aStart + prefOld[i0];
611
+ const new_start = cStart + prefNew[i0];
612
+ const slice = group.map(i => content[i]);
613
+ const contentLines = slice.filter(l => (l || '')[0] !== '\\');
614
+ const metaLines = slice.filter(l => (l || '')[0] === '\\');
615
+ const old_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '+'; }).length;
616
+ const new_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '-'; }).length;
617
+ if (old_count === 0 && new_count === 0) {
618
+ group = [];
619
+ return;
620
+ }
621
+ out += `@@ -${old_start},${old_count} +${new_start},${new_count} @@\n`;
622
+ out += contentLines.join('\n') + '\n';
623
+ if (metaLines.length)
624
+ out += metaLines.join('\n') + '\n';
625
+ group = [];
626
+ };
627
+ for (let i = 0; i < sorted.length; i++) {
628
+ if (group.length === 0) {
629
+ group.push(sorted[i]);
630
+ continue;
631
+ }
632
+ if (sorted[i] === group[group.length - 1] + 1)
633
+ group.push(sorted[i]);
634
+ else {
635
+ flush();
636
+ group.push(sorted[i]);
637
+ }
638
+ }
639
+ flush();
640
+ }
641
+ filePatches.push(out.trimEnd());
642
+ }
643
+ if (filePatches.length === 0)
644
+ return;
645
+ const combinedPatch = filePatches.join('\n') + '\n';
646
+ this.runChecked(['apply', '--cached', '--unidiff-zero'], 'git-stage-patch-failed', {
647
+ stdin: combinedPatch,
648
+ });
649
+ }
520
650
  applyReversePatch(patch) {
521
651
  this.runChecked(['apply', '-R', '--unidiff-zero'], 'git-apply-reverse-failed', {
522
652
  stdin: patch,
@@ -295,6 +295,11 @@ export class GitVcsDelegates extends VcsDelegateBase {
295
295
  git.stagePatch(asString(params.patch));
296
296
  return null;
297
297
  }
298
+ stageSelections(params, _context) {
299
+ const git = this.requireGit(params.session_id);
300
+ git.stageSelections(params.selections);
301
+ return null;
302
+ }
298
303
  stagePaths(params, _context) {
299
304
  const git = this.requireGit(params.session_id);
300
305
  git.stagePaths(asStringArray(params.paths));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvcs/git-plugin",
3
- "version": "0.3.2-edge.20260603.125",
3
+ "version": "0.3.2-edge.20260603.126",
4
4
  "description": "Git VCS backend plugin for OpenVCS",
5
5
  "author": "OpenVCS Contributors",
6
6
  "license": "GPL-3.0-or-later",
@@ -35,7 +35,7 @@
35
35
  }
36
36
  ]
37
37
  },
38
- "version": "0.3.2-edge.20260603.125"
38
+ "version": "0.3.2-edge.20260603.126"
39
39
  },
40
40
  "scripts": {
41
41
  "lint": "tsc -p tsconfig.json --noEmit",
package/src/git.ts CHANGED
@@ -698,6 +698,139 @@ 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
+ if (lines.length === 0) continue;
723
+
724
+ const normPath = sel.path.replace(/\\/g, '/');
725
+
726
+ // Find the first @@ line to separate prelude from hunks
727
+ const firstHunk = lines.findIndex(l => l.startsWith('@@'));
728
+ if (firstHunk < 0) continue;
729
+
730
+ const prelude = lines.slice(0, firstHunk);
731
+ const rest = lines.slice(firstHunk);
732
+
733
+ // Build headerExtras: metadata lines that aren't diff --git, ---, or +++
734
+ const headerExtras = prelude.filter(l =>
735
+ !!l &&
736
+ !l.startsWith('diff --git') &&
737
+ !l.startsWith('--- ') &&
738
+ !l.startsWith('+++ ')
739
+ );
740
+
741
+ // Locate all hunk @@ positions within rest
742
+ const starts: number[] = [];
743
+ for (let i = 0; i < rest.length; i++) {
744
+ if (rest[i].startsWith('@@')) starts.push(i);
745
+ }
746
+ if (starts.length === 0) continue;
747
+ starts.push(rest.length);
748
+
749
+ const isAdd = prelude.some(l => l.startsWith('--- /dev/null'));
750
+ const isDel = prelude.some(l => l.startsWith('+++ /dev/null'));
751
+ const wantWhole = new Set(sel.whole_hunks.filter(n => Number.isFinite(n)));
752
+
753
+ let out = `diff --git a/${normPath} b/${normPath}\n`;
754
+ if (headerExtras.length) out += headerExtras.join('\n') + '\n';
755
+ if (isAdd) out += `--- /dev/null\n+++ b/${normPath}\n`;
756
+ else if (isDel) out += `--- a/${normPath}\n+++ /dev/null\n`;
757
+ else out += `--- a/${normPath}\n+++ b/${normPath}\n`;
758
+
759
+ for (let h = 0; h < starts.length - 1; h++) {
760
+ const s = starts[h];
761
+ const e = starts[h + 1];
762
+ const block = rest.slice(s, e);
763
+ const header = block[0] || '';
764
+ const m = /@@\s*-([0-9]+),?([0-9]*)\s*\+([0-9]+),?([0-9]*)\s*@@/.exec(header);
765
+ if (!m) continue;
766
+ const aStart = parseInt(m[1] || '0', 10) || 0;
767
+ const cStart = parseInt(m[3] || '0', 10) || 0;
768
+ const content = block.slice(1);
769
+
770
+ if (wantWhole.has(h)) {
771
+ out += header + '\n' + content.join('\n') + '\n';
772
+ continue;
773
+ }
774
+
775
+ const picksRaw = (sel.partial_hunks && Array.isArray(sel.partial_hunks[h]))
776
+ ? sel.partial_hunks[h]
777
+ : (sel.partial_hunks && sel.partial_hunks[h] ? sel.partial_hunks[h] : []);
778
+ const picksAdj = Array.isArray(picksRaw)
779
+ ? picksRaw.map((i: number) => i - 1).filter((i: number) => i >= 0 && i < content.length)
780
+ : [];
781
+ const pickSet = new Set<number>(picksAdj);
782
+ if (pickSet.size === 0) continue;
783
+
784
+ // prefix counts for old/new positions
785
+ const prefOld: number[] = new Array(content.length + 1).fill(0);
786
+ const prefNew: number[] = new Array(content.length + 1).fill(0);
787
+ for (let i = 0; i < content.length; i++) {
788
+ const ch = (content[i] || '')[0] || ' ';
789
+ const isMeta = ch === '\\';
790
+ prefOld[i + 1] = prefOld[i] + (isMeta ? 0 : (ch === '+' ? 0 : 1));
791
+ prefNew[i + 1] = prefNew[i] + (isMeta ? 0 : (ch === '-' ? 0 : 1));
792
+ }
793
+
794
+ // Group consecutive selected lines into mini-hunks
795
+ const sorted = Array.from(pickSet).sort((x, y) => x - y);
796
+ let group: number[] = [];
797
+
798
+ const flush = (): void => {
799
+ if (group.length === 0) return;
800
+ const i0 = group[0];
801
+ const old_start = aStart + prefOld[i0];
802
+ const new_start = cStart + prefNew[i0];
803
+ const slice = group.map(i => content[i]);
804
+ const contentLines = slice.filter(l => (l || '')[0] !== '\\');
805
+ const metaLines = slice.filter(l => (l || '')[0] === '\\');
806
+ const old_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '+'; }).length;
807
+ const new_count = contentLines.filter(l => { const c = (l || '')[0]; return c !== '-'; }).length;
808
+ if (old_count === 0 && new_count === 0) { group = []; return; }
809
+ out += `@@ -${old_start},${old_count} +${new_start},${new_count} @@\n`;
810
+ out += contentLines.join('\n') + '\n';
811
+ if (metaLines.length) out += metaLines.join('\n') + '\n';
812
+ group = [];
813
+ };
814
+
815
+ for (let i = 0; i < sorted.length; i++) {
816
+ if (group.length === 0) { group.push(sorted[i]); continue; }
817
+ if (sorted[i] === group[group.length - 1] + 1) group.push(sorted[i]);
818
+ else { flush(); group.push(sorted[i]); }
819
+ }
820
+ flush();
821
+ }
822
+
823
+ filePatches.push(out.trimEnd());
824
+ }
825
+
826
+ if (filePatches.length === 0) return;
827
+
828
+ const combinedPatch = filePatches.join('\n') + '\n';
829
+ this.runChecked(['apply', '--cached', '--unidiff-zero'], 'git-stage-patch-failed', {
830
+ stdin: combinedPatch,
831
+ });
832
+ }
833
+
701
834
  applyReversePatch(patch: string): void {
702
835
  this.runChecked(['apply', '-R', '--unidiff-zero'], 'git-apply-reverse-failed', {
703
836
  stdin: patch,
@@ -484,6 +484,15 @@ export class GitVcsDelegates extends VcsDelegateBase<GitRuntimeDependencies> {
484
484
  return null;
485
485
  }
486
486
 
487
+ override stageSelections(
488
+ params: OpenVcs.VcsStageSelectionsParams,
489
+ _context: PluginRuntimeContext,
490
+ ): null {
491
+ const git = this.requireGit(params.session_id);
492
+ git.stageSelections(params.selections);
493
+ return null;
494
+ }
495
+
487
496
  override stagePaths(
488
497
  params: OpenVcs.VcsStagePathsParams,
489
498
  _context: PluginRuntimeContext,