@openvcs/git-plugin 0.3.2-edge.20260531.120 → 0.3.2-edge.20260602.123

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}^`]);
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvcs/git-plugin",
3
- "version": "0.3.2-edge.20260531.120",
3
+ "version": "0.3.2-edge.20260602.123",
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.20260531.120"
38
+ "version": "0.3.2-edge.20260602.123"
39
39
  },
40
40
  "scripts": {
41
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 {
@@ -150,7 +152,17 @@ export class GitCommand {
150
152
  status(): StatusParseResult & { exitCode: number } {
151
153
  const result = this.run(['status', '--porcelain=1', '--branch', '-z', '-uall']);
152
154
  const parsed = applySubmoduleStatusHints(parseStatusOutput(result.stdout), this.listSubmodulePaths());
153
- 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
+ };
154
166
  }
155
167
 
156
168
  currentBranch(): string {
@@ -415,6 +427,63 @@ export class GitCommand {
415
427
  return new Set(this.readSubmoduleConfig().byPath.keys());
416
428
  }
417
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
+
418
487
  listSubmodules(): SubmoduleEntry[] {
419
488
  const { byPath: configByPath } = this.readSubmoduleConfig();
420
489
 
@@ -501,12 +570,21 @@ export class GitCommand {
501
570
  }
502
571
  }
503
572
 
504
- diffFile(path: string): string {
573
+ diffFile(path: string): VcsDiffResult {
505
574
  const cachedDiff = this.runChecked(['diff', '--cached', '--no-ext-diff', '--', path], 'git-diff-failed')
506
575
  .stdout;
507
576
  const worktreeDiff = this.runChecked(['diff', '--no-ext-diff', '--', path], 'git-diff-failed')
508
577
  .stdout;
509
- 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
+ };
510
588
  }
511
589
 
512
590
  diffCommit(commit: string): string {
@@ -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(
@@ -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. */