@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 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');
@@ -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 || (isCurrent && 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 4 */
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
- 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,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
- 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
+ }
484
697
  }
485
698
  mergeAbort() {
486
699
  this.runChecked(['merge', '--abort'], 'git-merge-abort-failed');
@@ -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 splitDiffLines(git.diffFile(asTrimmedString(params.path)));
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
- git.mergeIntoCurrent(asTrimmedString(params.name));
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.2",
4
- "description": "OpenVCS Git plugin - Node.js runtime",
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 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",
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 { ...parsed, exitCode: result.status };
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 || (isCurrent && 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 4 */
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): 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
- return cachedDiff + worktreeDiff;
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, lfs_pointer: false };
607
+ return { path, ours: null, theirs: null, base: null, binary: false };
532
608
  }
533
609
 
534
610
  const oursContent = ours.stdout;
535
- const lfs_pointer =
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 = !lfs_pointer && (oursContent.startsWith('Binary\0') || oursContent.includes('\0'));
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
- this.runChecked(['merge', branch], 'git-merge-failed');
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 {
@@ -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
- ): string[] {
437
+ ): OpenVcs.VcsDiffFileResponse {
432
438
  const git = this.requireGit(params.session_id);
433
- return splitDiffLines(git.diffFile(asTrimmedString(params.path)));
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
- git.mergeIntoCurrent(asTrimmedString(params.name));
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
 
@@ -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. */