byterover-cli 3.8.2 → 3.9.0

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.
Files changed (53) hide show
  1. package/README.md +8 -34
  2. package/dist/agent/infra/llm/providers/google.js +1 -1
  3. package/dist/oclif/commands/login.d.ts +15 -2
  4. package/dist/oclif/commands/login.js +106 -29
  5. package/dist/oclif/commands/providers/list.js +3 -0
  6. package/dist/oclif/commands/vc/diff.d.ts +12 -0
  7. package/dist/oclif/commands/vc/diff.js +40 -0
  8. package/dist/oclif/commands/vc/remote/remove.d.ts +9 -0
  9. package/dist/oclif/commands/vc/remote/remove.js +23 -0
  10. package/dist/server/core/domain/entities/brv-config.d.ts +4 -0
  11. package/dist/server/core/domain/entities/brv-config.js +12 -0
  12. package/dist/server/core/domain/entities/provider-registry.js +3 -3
  13. package/dist/server/core/interfaces/services/i-git-service.d.ts +55 -4
  14. package/dist/server/infra/context-tree/summary-frontmatter.js +2 -2
  15. package/dist/server/infra/dream/operations/consolidate.js +5 -4
  16. package/dist/server/infra/dream/operations/synthesize.js +1 -1
  17. package/dist/server/infra/git/isomorphic-git-service.d.ts +24 -1
  18. package/dist/server/infra/git/isomorphic-git-service.js +207 -7
  19. package/dist/server/infra/transport/handlers/config-handler.js +1 -0
  20. package/dist/server/infra/transport/handlers/locations-handler.d.ts +1 -0
  21. package/dist/server/infra/transport/handlers/locations-handler.js +25 -1
  22. package/dist/server/infra/transport/handlers/reveal-command.d.ts +9 -0
  23. package/dist/server/infra/transport/handlers/reveal-command.js +7 -0
  24. package/dist/server/infra/transport/handlers/vc-handler.d.ts +11 -0
  25. package/dist/server/infra/transport/handlers/vc-handler.js +143 -9
  26. package/dist/server/infra/webui/webui-middleware.js +10 -4
  27. package/dist/shared/transport/events/config-events.d.ts +1 -0
  28. package/dist/shared/transport/events/index.d.ts +1 -0
  29. package/dist/shared/transport/events/locations-events.d.ts +7 -0
  30. package/dist/shared/transport/events/locations-events.js +1 -0
  31. package/dist/shared/transport/events/vc-events.d.ts +56 -5
  32. package/dist/shared/transport/events/vc-events.js +7 -0
  33. package/dist/tui/features/commands/definitions/vc-diff.d.ts +2 -0
  34. package/dist/tui/features/commands/definitions/vc-diff.js +23 -0
  35. package/dist/tui/features/commands/definitions/vc-remote.js +16 -7
  36. package/dist/tui/features/commands/definitions/vc.js +2 -0
  37. package/dist/tui/features/vc/diff/api/execute-vc-diff.d.ts +8 -0
  38. package/dist/tui/features/vc/diff/api/execute-vc-diff.js +13 -0
  39. package/dist/tui/features/vc/diff/components/vc-diff-flow.d.ts +8 -0
  40. package/dist/tui/features/vc/diff/components/vc-diff-flow.js +31 -0
  41. package/dist/tui/features/vc/diff/utils/format-diff.d.ts +2 -0
  42. package/dist/tui/features/vc/diff/utils/format-diff.js +83 -0
  43. package/dist/tui/features/vc/diff/utils/parse-mode.d.ts +2 -0
  44. package/dist/tui/features/vc/diff/utils/parse-mode.js +16 -0
  45. package/dist/tui/features/vc/remote/components/vc-remote-flow.js +23 -8
  46. package/dist/webui/assets/index-CvcqpMYn.css +1 -0
  47. package/dist/webui/assets/index-thSZZahh.js +130 -0
  48. package/dist/webui/index.html +3 -3
  49. package/dist/webui/sw.js +1 -1
  50. package/oclif.manifest.json +1009 -933
  51. package/package.json +3 -1
  52. package/dist/webui/assets/index-Cti7S_1o.js +0 -130
  53. package/dist/webui/assets/index-Dpw6osIL.css +0 -1
@@ -32,6 +32,29 @@ const FIELD_MAP = {
32
32
  'user.email': 'email',
33
33
  'user.name': 'name',
34
34
  };
35
+ function inferStatus(oldExists, newExists) {
36
+ if (!oldExists && newExists)
37
+ return 'added';
38
+ if (oldExists && !newExists)
39
+ return 'deleted';
40
+ return 'modified';
41
+ }
42
+ function resolveDiffSides(mode) {
43
+ switch (mode.kind) {
44
+ case 'range': {
45
+ return { from: { commitish: mode.from }, to: { commitish: mode.to } };
46
+ }
47
+ case 'ref-vs-worktree': {
48
+ return { from: { commitish: mode.ref }, to: 'WORKDIR' };
49
+ }
50
+ case 'staged': {
51
+ return { from: { commitish: 'HEAD' }, to: 'STAGE' };
52
+ }
53
+ case 'unstaged': {
54
+ return { from: 'STAGE', to: 'WORKDIR' };
55
+ }
56
+ }
57
+ }
35
58
  /**
36
59
  * Handles vc:* events (Version Control commands).
37
60
  */
@@ -96,6 +119,35 @@ export class VcHandler {
96
119
  }
97
120
  return 'Run: brv vc config user.name <value> and brv vc config user.email <value>.';
98
121
  }
122
+ /**
123
+ * Builds a single diff entry for a changed file, or `undefined` when either required side
124
+ * is absent/binary (the caller filters these out to keep binaries out of diff output).
125
+ */
126
+ async buildDiffFile(params) {
127
+ const { directory, from, path, status, to } = params;
128
+ const [oldSide, newSide] = await Promise.all([
129
+ status === 'added' ? undefined : this.readSideEntry(directory, from, path),
130
+ status === 'deleted' ? undefined : this.readSideEntry(directory, to, path),
131
+ ]);
132
+ if (status !== 'added' && !oldSide)
133
+ return undefined;
134
+ if (status !== 'deleted' && !newSide)
135
+ return undefined;
136
+ const binary = (oldSide?.binary ?? false) || (newSide?.binary ?? false);
137
+ const result = {
138
+ newContent: binary ? '' : (newSide?.content ?? ''),
139
+ oldContent: binary ? '' : (oldSide?.content ?? ''),
140
+ path,
141
+ status,
142
+ };
143
+ if (oldSide)
144
+ result.oldOid = oldSide.oid;
145
+ if (newSide)
146
+ result.newOid = newSide.oid;
147
+ if (binary)
148
+ result.binary = true;
149
+ return result;
150
+ }
99
151
  buildNoRemoteMessage(nextStep) {
100
152
  return (`No remote configured.\n\nTo connect to cloud:\n` +
101
153
  ` 1. Go to ${this.webAppUrl} → create or open a Space\n` +
@@ -106,7 +158,7 @@ export class VcHandler {
106
158
  async computeDiff(directory, path, side) {
107
159
  if (side === 'staged') {
108
160
  const [head, stage] = await Promise.all([
109
- this.gitService.getBlobContent({ directory, path, ref: 'HEAD' }),
161
+ this.gitService.getBlobContent({ directory, path, ref: { commitish: 'HEAD' } }),
110
162
  this.gitService.getBlobContent({ directory, path, ref: 'STAGE' }),
111
163
  ]);
112
164
  return { newContent: stage ?? '', oldContent: head ?? '', path };
@@ -480,19 +532,63 @@ export class VcHandler {
480
532
  if (!gitInitialized) {
481
533
  throw new VcError('ByteRover version control not initialized.', VcErrorCode.GIT_NOT_INITIALIZED);
482
534
  }
535
+ // Mode-based call (CLI/TUI): auto-discover changed files + return full IVcDiffFile entries.
536
+ // Binary or unreadable files are dropped by buildDiffFile (returns undefined).
537
+ if ('mode' in data) {
538
+ const { from, to } = resolveDiffSides(data.mode);
539
+ try {
540
+ const changed = await this.gitService.listChangedFiles({ directory, from, to });
541
+ const entries = await Promise.all(changed.map((change) => this.buildDiffFile({ directory, from, path: change.path, status: change.status, to })));
542
+ const diffs = entries.filter((d) => d !== undefined);
543
+ return { diffs, mode: data.mode };
544
+ }
545
+ catch (error) {
546
+ const classified = classifyIsomorphicGitError(error, VcErrorCode.INVALID_REF);
547
+ if (classified)
548
+ throw classified;
549
+ throw error;
550
+ }
551
+ }
552
+ // WebUI call: caller-supplied paths + side. Status is derived from blob presence,
553
+ // so an empty-blob edit is correctly reported as `modified`, not `added`.
483
554
  const { paths, side } = data;
484
555
  if (side === 'staged') {
485
556
  const [head, stage] = await Promise.all([
486
- this.gitService.getBlobContents({ directory, paths, ref: 'HEAD' }),
557
+ this.gitService.getBlobContents({ directory, paths, ref: { commitish: 'HEAD' } }),
487
558
  this.gitService.getBlobContents({ directory, paths, ref: 'STAGE' }),
488
559
  ]);
489
- const diffs = paths.map((path) => ({ newContent: stage[path] ?? '', oldContent: head[path] ?? '', path }));
560
+ const diffs = paths.map((path) => {
561
+ const oldBlob = head[path];
562
+ const newBlob = stage[path];
563
+ return {
564
+ newContent: newBlob ?? '',
565
+ oldContent: oldBlob ?? '',
566
+ path,
567
+ status: inferStatus(oldBlob !== undefined, newBlob !== undefined),
568
+ };
569
+ });
490
570
  return { diffs };
491
571
  }
492
572
  // unstaged: compare index (old) against working tree (new)
493
573
  const stage = await this.gitService.getBlobContents({ directory, paths, ref: 'STAGE' });
494
- const workingTree = await Promise.all(paths.map((path) => fs.promises.readFile(join(directory, path), 'utf8').catch(() => '')));
495
- const diffs = paths.map((path, i) => ({ newContent: workingTree[i], oldContent: stage[path] ?? '', path }));
574
+ const workingTree = await Promise.all(paths.map(async (path) => {
575
+ try {
576
+ return await fs.promises.readFile(join(directory, path), 'utf8');
577
+ }
578
+ catch {
579
+ return undefined;
580
+ }
581
+ }));
582
+ const diffs = paths.map((path, i) => {
583
+ const oldBlob = stage[path];
584
+ const newFile = workingTree[i];
585
+ return {
586
+ newContent: newFile ?? '',
587
+ oldContent: oldBlob ?? '',
588
+ path,
589
+ status: inferStatus(oldBlob !== undefined, newFile !== undefined),
590
+ };
591
+ });
496
592
  return { diffs };
497
593
  }
498
594
  async handleDiscard(data, clientId) {
@@ -506,15 +602,13 @@ export class VcHandler {
506
602
  // Prefer index blob (preserves staged changes); fall back to HEAD; else delete (untracked).
507
603
  const [stage, head] = await Promise.all([
508
604
  this.gitService.getBlobContents({ directory, paths: filePaths, ref: 'STAGE' }),
509
- this.gitService.getBlobContents({ directory, paths: filePaths, ref: 'HEAD' }),
605
+ this.gitService.getBlobContents({ directory, paths: filePaths, ref: { commitish: 'HEAD' } }),
510
606
  ]);
511
607
  const results = await Promise.all(filePaths.map(async (path) => {
512
608
  const target = stage[path] ?? head[path];
513
609
  const absolutePath = join(directory, path);
514
610
  try {
515
- await (target === undefined
516
- ? fs.promises.unlink(absolutePath)
517
- : fs.promises.writeFile(absolutePath, target));
611
+ await (target === undefined ? fs.promises.unlink(absolutePath) : fs.promises.writeFile(absolutePath, target));
518
612
  return true;
519
613
  }
520
614
  catch {
@@ -820,6 +914,25 @@ export class VcHandler {
820
914
  const url = await this.gitService.getRemoteUrl({ directory, remote: 'origin' });
821
915
  return { action: 'show', url: url ? maskCredentialsInUrl(url) : undefined };
822
916
  }
917
+ if (data.subcommand === 'remove') {
918
+ const existingUrl = await this.gitService.getRemoteUrl({ directory, remote: 'origin' });
919
+ if (!existingUrl) {
920
+ throw new VcError("No remote 'origin' to remove.", VcErrorCode.NO_REMOTE);
921
+ }
922
+ // Clear config before removing the remote so a mid-way failure leaves a retry-friendly state:
923
+ // if removeRemote throws, config is already cleared and remote is still present, so
924
+ // re-running `remove` will retry removeRemote and succeed. The reverse order leaves the
925
+ // remote gone but config stale, and the next `remove` fails with NO_REMOTE — unrecoverable.
926
+ const existingConfig = await this.projectConfigStore.read(projectPath);
927
+ if (existingConfig) {
928
+ await this.projectConfigStore.write(existingConfig.withoutSpace(), projectPath);
929
+ }
930
+ await this.gitService.removeRemote({ directory, remote: 'origin' });
931
+ return { action: 'remove' };
932
+ }
933
+ if (data.subcommand !== 'add' && data.subcommand !== 'set-url') {
934
+ throw new VcError('Unknown remote subcommand.', VcErrorCode.INVALID_ACTION);
935
+ }
823
936
  if (!data.url) {
824
937
  throw new VcError('URL is required.', VcErrorCode.INVALID_REMOTE_URL);
825
938
  }
@@ -981,6 +1094,27 @@ export class VcHandler {
981
1094
  untracked: gitStatus.files.filter((f) => f.status === 'untracked').map((f) => f.path),
982
1095
  };
983
1096
  }
1097
+ /**
1098
+ * Reads a diff side's content + short oid in a single pass. Returns `undefined`
1099
+ * only when the blob is absent on disk / at the ref. Binary content (NUL byte)
1100
+ * is marked via `binary: true` so the caller can emit `Binary files ... differ`.
1101
+ */
1102
+ async readSideEntry(directory, side, path) {
1103
+ if (side === 'WORKDIR') {
1104
+ let buf;
1105
+ try {
1106
+ buf = await fs.promises.readFile(join(directory, path));
1107
+ }
1108
+ catch {
1109
+ return undefined;
1110
+ }
1111
+ const oid = await this.gitService.hashBlob(buf);
1112
+ if (buf.includes(0))
1113
+ return { binary: true, content: '', oid };
1114
+ return { content: buf.toString('utf8'), oid };
1115
+ }
1116
+ return this.gitService.getTextBlob({ directory, path, ref: side });
1117
+ }
984
1118
  /**
985
1119
  * Resolve clone request data into a clean cogit URL + team/space info.
986
1120
  * Accepts either a URL or explicit teamName/spaceName.
@@ -1,6 +1,5 @@
1
1
  import express from 'express';
2
2
  import { existsSync } from 'node:fs';
3
- import { join } from 'node:path';
4
3
  /**
5
4
  * Creates an Express app that serves the web UI and config endpoint.
6
5
  *
@@ -20,7 +19,12 @@ export function createWebUiMiddleware({ getConfig, webuiDistDir }) {
20
19
  "connect-src 'self' ws: wss:",
21
20
  "font-src 'self' data: https://fonts.gstatic.com",
22
21
  "frame-ancestors 'none'",
23
- "img-src 'self' data:",
22
+ // `img-src https:` is deliberately broad: user avatars come from an
23
+ // open-ended set of OAuth provider CDNs (Google, GitHub, Gravatar,
24
+ // self-hosted identity providers, …) that can't be enumerated ahead
25
+ // of time. Images can't execute code, so the attack surface is just
26
+ // pixel exfiltration / tracking, which we accept for this use case.
27
+ "img-src 'self' data: https:",
24
28
  "object-src 'none'",
25
29
  "script-src 'self'",
26
30
  "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
@@ -36,9 +40,11 @@ export function createWebUiMiddleware({ getConfig, webuiDistDir }) {
36
40
  // Serve static files from dist/webui/
37
41
  if (existsSync(webuiDistDir)) {
38
42
  app.use(express.static(webuiDistDir));
39
- // SPA fallback: serve index.html for unmatched routes
43
+ // SPA fallback. `root` scopes send's dotfile check to the relative
44
+ // path; without it, a dotfile anywhere in the absolute install path
45
+ // (e.g. ~/.nvm/...) triggers a 404.
40
46
  app.get('*splat', (_req, res) => {
41
- res.sendFile(join(webuiDistDir, 'index.html'));
47
+ res.sendFile('index.html', { root: webuiDistDir });
42
48
  });
43
49
  }
44
50
  return app;
@@ -5,6 +5,7 @@ export declare const ConfigEvents: {
5
5
  readonly PROJECT_CHANGED: "config:projectChanged";
6
6
  };
7
7
  export interface ConfigGetEnvironmentResponse {
8
+ gitRemoteBaseUrl: string;
8
9
  iamBaseUrl: string;
9
10
  isDevelopment: boolean;
10
11
  webAppUrl: string;
@@ -130,6 +130,7 @@ export declare const AllEventGroups: readonly [{
130
130
  readonly SWITCHED: "session:switched";
131
131
  }, {
132
132
  readonly GET: "locations:get";
133
+ readonly REVEAL: "locations:reveal";
133
134
  }, {
134
135
  readonly ADD: "source:add";
135
136
  readonly LIST: "source:list";
@@ -1,7 +1,14 @@
1
1
  import type { ProjectLocationDTO } from '../types/dto.js';
2
2
  export declare const LocationsEvents: {
3
3
  readonly GET: "locations:get";
4
+ readonly REVEAL: "locations:reveal";
4
5
  };
5
6
  export interface LocationsGetResponse {
6
7
  locations: ProjectLocationDTO[];
7
8
  }
9
+ export interface LocationsRevealRequest {
10
+ projectPath: string;
11
+ }
12
+ export interface LocationsRevealResponse {
13
+ projectPath: string;
14
+ }
@@ -1,3 +1,4 @@
1
1
  export const LocationsEvents = {
2
2
  GET: 'locations:get',
3
+ REVEAL: 'locations:reveal',
3
4
  };
@@ -155,8 +155,9 @@ export interface IVcLogResponse {
155
155
  }>;
156
156
  currentBranch?: string;
157
157
  }
158
- export type VcRemoteSubcommand = 'add' | 'set-url' | 'show';
158
+ export type VcRemoteSubcommand = 'add' | 'remove' | 'set-url' | 'show';
159
159
  export declare const VC_REMOTE_SUBCOMMANDS: readonly string[];
160
+ export declare const VC_REMOTE_SUBCOMMAND_REQUIRES_URL: Record<VcRemoteSubcommand, boolean>;
160
161
  export declare function isVcRemoteSubcommand(value: string): value is VcRemoteSubcommand;
161
162
  export interface IVcRemoteRequest {
162
163
  subcommand: VcRemoteSubcommand;
@@ -280,15 +281,65 @@ export interface IVcDiffResponse {
280
281
  path: string;
281
282
  }
282
283
  /**
283
- * Batched diff — returns diffs for multiple paths on the same side in one round-trip.
284
- * Preserves the order of the input `paths`.
284
+ * Batched diff — returns diffs for multiple files in one round-trip.
285
+ *
286
+ * Discriminated union of two mutually exclusive request shapes:
287
+ * - WebUI: `{paths, side}` — caller supplies the paths, server returns raw content pairs.
288
+ * - CLI/TUI: `{mode}` — server auto-discovers changed files, returns full `IVcDiffFile`
289
+ * entries (status + oids). Binary files are filtered out.
290
+ *
291
+ * The union form guarantees callers can't accidentally mix the two shapes (type error).
285
292
  */
286
- export interface IVcDiffsRequest {
293
+ export type IVcDiffsRequest = {
294
+ mode: VcDiffMode;
295
+ } | {
287
296
  paths: string[];
288
297
  side: VcDiffSide;
298
+ };
299
+ /**
300
+ * Diff modes for `brv vc diff` / `/vc diff`. Mirrors the four diff modes from `git diff`:
301
+ * - unstaged → STAGE → WORKDIR (tracked files only; matches `git diff` no args)
302
+ * - staged → HEAD → STAGE (matches `git diff --staged`)
303
+ * - ref-vs-worktree → <commit|branch> → WORKDIR (matches `git diff <ref>`)
304
+ * - range → <ref1> → <ref2> (matches `git diff <ref1>..<ref2>`)
305
+ */
306
+ export type VcDiffMode = {
307
+ from: string;
308
+ kind: 'range';
309
+ to: string;
310
+ } | {
311
+ kind: 'ref-vs-worktree';
312
+ ref: string;
313
+ } | {
314
+ kind: 'staged';
315
+ } | {
316
+ kind: 'unstaged';
317
+ };
318
+ export type VcDiffFileStatus = 'added' | 'deleted' | 'modified';
319
+ /**
320
+ * Per-file diff entry. Extends `IVcDiffResponse` with status + oid.
321
+ * Binary files (NUL byte on either side) are filtered out of the response upstream,
322
+ * so consumers only ever see text content here.
323
+ *
324
+ * Legacy (WebUI) consumers read `oldContent`/`newContent`/`path` only; the extra
325
+ * fields are forward-compatible extras they ignore.
326
+ */
327
+ export interface IVcDiffFile {
328
+ /** True when either side is binary (contains a NUL byte). Content fields are empty. */
329
+ binary?: boolean;
330
+ newContent: string;
331
+ /** 7-char short oid; omitted for deleted files. */
332
+ newOid?: string;
333
+ oldContent: string;
334
+ /** 7-char short oid; omitted for added files. */
335
+ oldOid?: string;
336
+ path: string;
337
+ status: VcDiffFileStatus;
289
338
  }
290
339
  export interface IVcDiffsResponse {
291
- diffs: IVcDiffResponse[];
340
+ diffs: IVcDiffFile[];
341
+ /** Echoed when the request used `mode`; absent for legacy calls. */
342
+ mode?: VcDiffMode;
292
343
  }
293
344
  /**
294
345
  * Discards unstaged changes in the working tree.
@@ -62,9 +62,16 @@ export function isVcConfigKey(key) {
62
62
  }
63
63
  export const VC_REMOTE_SUBCOMMANDS = [
64
64
  'add',
65
+ 'remove',
65
66
  'set-url',
66
67
  'show',
67
68
  ];
69
+ export const VC_REMOTE_SUBCOMMAND_REQUIRES_URL = {
70
+ add: true,
71
+ remove: false,
72
+ 'set-url': true,
73
+ show: false,
74
+ };
68
75
  export function isVcRemoteSubcommand(value) {
69
76
  return VC_REMOTE_SUBCOMMANDS.includes(value);
70
77
  }
@@ -0,0 +1,2 @@
1
+ import type { SlashCommand } from '../../../types/commands.js';
2
+ export declare const vcDiffSubCommand: SlashCommand;
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import { VcDiffFlow } from '../../vc/diff/components/vc-diff-flow.js';
3
+ import { parseMode } from '../../vc/diff/utils/parse-mode.js';
4
+ import { Args, Flags, parseReplArgs, toCommandFlags } from '../utils/arg-parser.js';
5
+ const vcDiffArgs = {
6
+ ref: Args.string({ description: 'commit, branch, or <ref1>..<ref2> range' }),
7
+ };
8
+ const vcDiffFlags = {
9
+ staged: Flags.boolean({ default: false, description: 'Show staged changes (HEAD vs index)' }),
10
+ };
11
+ export const vcDiffSubCommand = {
12
+ async action(_context, args) {
13
+ const parsed = await parseReplArgs(args, { args: vcDiffArgs, flags: vcDiffFlags, strict: false });
14
+ const mode = parseMode(parsed.args.ref, parsed.flags.staged ?? false);
15
+ return {
16
+ render: ({ onCancel, onComplete }) => React.createElement(VcDiffFlow, { mode, onCancel, onComplete }),
17
+ };
18
+ },
19
+ args: [{ description: 'commit, branch, or <ref1>..<ref2> range', name: 'ref' }],
20
+ description: 'Show changes between commits, the index, or the working tree',
21
+ flags: toCommandFlags(vcDiffFlags),
22
+ name: 'diff',
23
+ };
@@ -1,11 +1,11 @@
1
1
  import React from 'react';
2
- import { isVcRemoteSubcommand } from '../../../../shared/transport/events/vc-events.js';
2
+ import { isVcRemoteSubcommand, VC_REMOTE_SUBCOMMAND_REQUIRES_URL } from '../../../../shared/transport/events/vc-events.js';
3
3
  import { getGitRemoteBaseUrl } from '../../../lib/environment.js';
4
4
  import { VcRemoteFlow } from '../../vc/remote/components/vc-remote-flow.js';
5
5
  import { Args, parseReplArgs } from '../utils/arg-parser.js';
6
6
  /* eslint-disable perfectionist/sort-objects -- positional order matters: subcommand, name, url */
7
7
  const vcRemoteArgs = {
8
- subcommand: Args.string({ description: 'Subcommand: add | set-url (omit to show current remote)' }),
8
+ subcommand: Args.string({ description: 'Subcommand: add | set-url | remove (omit to show current remote)' }),
9
9
  name: Args.string({ description: 'Remote name (e.g. origin)' }),
10
10
  url: Args.string({ description: `Remote URL (e.g. ${getGitRemoteBaseUrl()}/<team>/<space>.git)` }),
11
11
  };
@@ -21,15 +21,24 @@ export const vcRemoteSubCommand = {
21
21
  }
22
22
  if (!isVcRemoteSubcommand(rawSubcommand)) {
23
23
  const errorMsg = {
24
- content: `Unknown subcommand '${rawSubcommand}'. Usage: /vc remote [add|set-url] <name> <url>`,
24
+ content: `Unknown subcommand '${rawSubcommand}'. Usage: /vc remote [add|set-url|remove] <name> [url]`,
25
25
  messageType: 'error',
26
26
  type: 'message',
27
27
  };
28
28
  return errorMsg;
29
29
  }
30
- if (!name || !url) {
30
+ if (rawSubcommand === 'show') {
31
+ return {
32
+ render: ({ onCancel, onComplete }) => React.createElement(VcRemoteFlow, { onCancel, onComplete, subcommand: 'show' }),
33
+ };
34
+ }
35
+ const requiresUrl = VC_REMOTE_SUBCOMMAND_REQUIRES_URL[rawSubcommand];
36
+ if (!name || (requiresUrl && !url)) {
37
+ const usage = requiresUrl
38
+ ? `Usage: /vc remote ${rawSubcommand} <name> <url>`
39
+ : `Usage: /vc remote ${rawSubcommand} <name>`;
31
40
  const errorMsg = {
32
- content: `Usage: /vc remote ${rawSubcommand} <name> <url>`,
41
+ content: usage,
33
42
  messageType: 'error',
34
43
  type: 'message',
35
44
  };
@@ -48,9 +57,9 @@ export const vcRemoteSubCommand = {
48
57
  };
49
58
  },
50
59
  args: [
51
- { description: 'Subcommand: add | set-url (omit to show current remote)', name: 'subcommand' },
60
+ { description: 'Subcommand: add | set-url | remove (omit to show current remote)', name: 'subcommand' },
52
61
  { description: 'Remote name (e.g. origin)', name: 'name' },
53
- { description: 'Remote URL', name: 'url' },
62
+ { description: 'Remote URL (required for add | set-url)', name: 'url' },
54
63
  ],
55
64
  description: 'Manage remote origin for ByteRover version control',
56
65
  name: 'remote',
@@ -4,6 +4,7 @@ import { vcCheckoutSubCommand } from './vc-checkout.js';
4
4
  import { vcCloneSubCommand } from './vc-clone.js';
5
5
  import { vcCommitSubCommand } from './vc-commit.js';
6
6
  import { vcConfigSubCommand } from './vc-config.js';
7
+ import { vcDiffSubCommand } from './vc-diff.js';
7
8
  import { vcFetchSubCommand } from './vc-fetch.js';
8
9
  import { vcInitSubCommand } from './vc-init.js';
9
10
  import { vcLogSubCommand } from './vc-log.js';
@@ -26,6 +27,7 @@ export const vcCommand = {
26
27
  vcPullSubCommand,
27
28
  vcPushSubCommand,
28
29
  vcStatusSubCommand,
30
+ vcDiffSubCommand,
29
31
  vcLogSubCommand,
30
32
  vcMergeSubCommand,
31
33
  vcBranchSubCommand,
@@ -0,0 +1,8 @@
1
+ import type { MutationConfig } from '../../../../lib/react-query.js';
2
+ import { type IVcDiffsRequest, type IVcDiffsResponse } from '../../../../../shared/transport/events/vc-events.js';
3
+ export declare const executeVcDiff: (request: IVcDiffsRequest) => Promise<IVcDiffsResponse>;
4
+ type UseExecuteVcDiffOptions = {
5
+ mutationConfig?: MutationConfig<typeof executeVcDiff>;
6
+ };
7
+ export declare const useExecuteVcDiff: ({ mutationConfig }?: UseExecuteVcDiffOptions) => import("@tanstack/react-query").UseMutationResult<IVcDiffsResponse, Error, IVcDiffsRequest, unknown>;
8
+ export {};
@@ -0,0 +1,13 @@
1
+ import { useMutation } from '@tanstack/react-query';
2
+ import { VcEvents, } from '../../../../../shared/transport/events/vc-events.js';
3
+ import { useTransportStore } from '../../../../stores/transport-store.js';
4
+ export const executeVcDiff = (request) => {
5
+ const { apiClient } = useTransportStore.getState();
6
+ if (!apiClient)
7
+ return Promise.reject(new Error('Not connected'));
8
+ return apiClient.request(VcEvents.DIFFS, request);
9
+ };
10
+ export const useExecuteVcDiff = ({ mutationConfig } = {}) => useMutation({
11
+ ...mutationConfig,
12
+ mutationFn: executeVcDiff,
13
+ });
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ import type { VcDiffMode } from '../../../../../shared/transport/events/vc-events.js';
3
+ import type { CustomDialogCallbacks } from '../../../../types/commands.js';
4
+ type VcDiffFlowProps = CustomDialogCallbacks & {
5
+ mode: VcDiffMode;
6
+ };
7
+ export declare function VcDiffFlow({ mode, onCancel, onComplete }: VcDiffFlowProps): React.ReactNode;
8
+ export {};
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text, useInput } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import React, { useEffect } from 'react';
5
+ import { formatTransportError } from '../../../../utils/error-messages.js';
6
+ import { useExecuteVcDiff } from '../api/execute-vc-diff.js';
7
+ import { formatDiff } from '../utils/format-diff.js';
8
+ export function VcDiffFlow({ mode, onCancel, onComplete }) {
9
+ const diffMutation = useExecuteVcDiff();
10
+ useInput((_, key) => {
11
+ if (key.escape && !diffMutation.isPending) {
12
+ onCancel();
13
+ }
14
+ });
15
+ const fired = React.useRef(false);
16
+ useEffect(() => {
17
+ if (fired.current)
18
+ return;
19
+ fired.current = true;
20
+ diffMutation.mutate({ mode }, {
21
+ onError(error) {
22
+ onComplete(`Failed to compute diff: ${formatTransportError(error)}`);
23
+ },
24
+ onSuccess(result) {
25
+ const text = formatDiff(result);
26
+ onComplete(text.length === 0 ? 'No changes.' : text.replace(/\n$/, ''));
27
+ },
28
+ });
29
+ }, []);
30
+ return (_jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), " Computing diff..."] }));
31
+ }
@@ -0,0 +1,2 @@
1
+ import type { IVcDiffsResponse } from '../../../../../shared/transport/events/vc-events.js';
2
+ export declare function formatDiff(response: IVcDiffsResponse): string;
@@ -0,0 +1,83 @@
1
+ import chalk from 'chalk';
2
+ import { structuredPatch } from 'diff';
3
+ export function formatDiff(response) {
4
+ if (response.diffs.length === 0)
5
+ return '';
6
+ return response.diffs.map((file) => formatFile(file)).join('');
7
+ }
8
+ function formatFile(f) {
9
+ const aPath = f.status === 'added' ? '/dev/null' : `a/${f.path}`;
10
+ const bPath = f.status === 'deleted' ? '/dev/null' : `b/${f.path}`;
11
+ const lines = [chalk.bold(`diff --git a/${f.path} b/${f.path}`)];
12
+ if (f.status === 'added') {
13
+ lines.push(chalk.bold('new file mode 100644'));
14
+ if (f.newOid)
15
+ lines.push(chalk.bold(`index 0000000..${f.newOid}`));
16
+ }
17
+ else if (f.status === 'deleted') {
18
+ lines.push(chalk.bold('deleted file mode 100644'));
19
+ if (f.oldOid)
20
+ lines.push(chalk.bold(`index ${f.oldOid}..0000000`));
21
+ }
22
+ else if (f.oldOid && f.newOid) {
23
+ lines.push(chalk.bold(`index ${f.oldOid}..${f.newOid} 100644`));
24
+ }
25
+ if (f.binary) {
26
+ lines.push(`Binary files ${aPath} and ${bPath} differ`);
27
+ return lines.join('\n') + '\n';
28
+ }
29
+ const patch = structuredPatch(f.path, f.path, f.oldContent, f.newContent, '', '', { context: 3 });
30
+ // Skip `---`/`+++` when there are no hunks (e.g. empty file added/deleted).
31
+ // Matches `git diff` which omits these lines when there's nothing to show.
32
+ if (patch.hunks.length === 0) {
33
+ return lines.join('\n') + '\n';
34
+ }
35
+ // Append a tab after paths containing spaces/tabs so the unified-diff parser
36
+ // can unambiguously locate the path end. Matches `git diff` behavior.
37
+ const aHeader = hasWhitespace(f.path) && f.status !== 'added' ? `${aPath}\t` : aPath;
38
+ const bHeader = hasWhitespace(f.path) && f.status !== 'deleted' ? `${bPath}\t` : bPath;
39
+ lines.push(chalk.bold(`--- ${aHeader}`), chalk.bold(`+++ ${bHeader}`));
40
+ const oldLines = f.oldContent.split('\n');
41
+ for (const hunk of patch.hunks) {
42
+ const header = `@@ -${formatHunkRange(hunk.oldStart, hunk.oldLines)} +${formatHunkRange(hunk.newStart, hunk.newLines)} @@`;
43
+ const context = findHunkContext(oldLines, hunk.oldStart);
44
+ lines.push(chalk.cyan(context ? `${header} ${context}` : header));
45
+ for (const line of hunk.lines) {
46
+ if (line.startsWith('+'))
47
+ lines.push(chalk.green(line));
48
+ else if (line.startsWith('-'))
49
+ lines.push(chalk.red(line));
50
+ else
51
+ lines.push(line);
52
+ }
53
+ }
54
+ return lines.join('\n') + '\n';
55
+ }
56
+ function hasWhitespace(path) {
57
+ return /\s/.test(path);
58
+ }
59
+ /**
60
+ * Approximates git's trailing hunk-context line. Scans backwards from the line before
61
+ * the hunk for the closest non-empty line that starts with an identifier-ish character
62
+ * (matches git's default xfuncname for plain text: `^[A-Za-z_$]`).
63
+ */
64
+ function findHunkContext(oldLines, oldStart) {
65
+ const startIdx = oldStart - 2; // oldStart is 1-based line number of hunk's first line
66
+ for (let i = startIdx; i >= 0; i--) {
67
+ const line = oldLines[i];
68
+ if (line && /^[A-Z_a-z]/.test(line))
69
+ return line;
70
+ }
71
+ return undefined;
72
+ }
73
+ // Matches `git diff` hunk-range formatting:
74
+ // - count === 1: omit `,1`
75
+ // - count === 0: emit `<start-1>,0` (git convention — start is the line BEFORE the insertion point)
76
+ // - otherwise: `<start>,<count>`
77
+ function formatHunkRange(start, count) {
78
+ if (count === 0)
79
+ return `${start - 1},0`;
80
+ if (count === 1)
81
+ return `${start}`;
82
+ return `${start},${count}`;
83
+ }
@@ -0,0 +1,2 @@
1
+ import type { VcDiffMode } from '../../../../../shared/transport/events/vc-events.js';
2
+ export declare function parseMode(arg: string | undefined, staged: boolean): VcDiffMode;