@openvcs/git-plugin 0.3.2-beta.113 → 0.3.2-beta.127

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