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.
- package/README.md +8 -34
- package/dist/agent/infra/llm/providers/google.js +1 -1
- package/dist/oclif/commands/login.d.ts +15 -2
- package/dist/oclif/commands/login.js +106 -29
- package/dist/oclif/commands/providers/list.js +3 -0
- package/dist/oclif/commands/vc/diff.d.ts +12 -0
- package/dist/oclif/commands/vc/diff.js +40 -0
- package/dist/oclif/commands/vc/remote/remove.d.ts +9 -0
- package/dist/oclif/commands/vc/remote/remove.js +23 -0
- package/dist/server/core/domain/entities/brv-config.d.ts +4 -0
- package/dist/server/core/domain/entities/brv-config.js +12 -0
- package/dist/server/core/domain/entities/provider-registry.js +3 -3
- package/dist/server/core/interfaces/services/i-git-service.d.ts +55 -4
- package/dist/server/infra/context-tree/summary-frontmatter.js +2 -2
- package/dist/server/infra/dream/operations/consolidate.js +5 -4
- package/dist/server/infra/dream/operations/synthesize.js +1 -1
- package/dist/server/infra/git/isomorphic-git-service.d.ts +24 -1
- package/dist/server/infra/git/isomorphic-git-service.js +207 -7
- package/dist/server/infra/transport/handlers/config-handler.js +1 -0
- package/dist/server/infra/transport/handlers/locations-handler.d.ts +1 -0
- package/dist/server/infra/transport/handlers/locations-handler.js +25 -1
- package/dist/server/infra/transport/handlers/reveal-command.d.ts +9 -0
- package/dist/server/infra/transport/handlers/reveal-command.js +7 -0
- package/dist/server/infra/transport/handlers/vc-handler.d.ts +11 -0
- package/dist/server/infra/transport/handlers/vc-handler.js +143 -9
- package/dist/server/infra/webui/webui-middleware.js +10 -4
- package/dist/shared/transport/events/config-events.d.ts +1 -0
- package/dist/shared/transport/events/index.d.ts +1 -0
- package/dist/shared/transport/events/locations-events.d.ts +7 -0
- package/dist/shared/transport/events/locations-events.js +1 -0
- package/dist/shared/transport/events/vc-events.d.ts +56 -5
- package/dist/shared/transport/events/vc-events.js +7 -0
- package/dist/tui/features/commands/definitions/vc-diff.d.ts +2 -0
- package/dist/tui/features/commands/definitions/vc-diff.js +23 -0
- package/dist/tui/features/commands/definitions/vc-remote.js +16 -7
- package/dist/tui/features/commands/definitions/vc.js +2 -0
- package/dist/tui/features/vc/diff/api/execute-vc-diff.d.ts +8 -0
- package/dist/tui/features/vc/diff/api/execute-vc-diff.js +13 -0
- package/dist/tui/features/vc/diff/components/vc-diff-flow.d.ts +8 -0
- package/dist/tui/features/vc/diff/components/vc-diff-flow.js +31 -0
- package/dist/tui/features/vc/diff/utils/format-diff.d.ts +2 -0
- package/dist/tui/features/vc/diff/utils/format-diff.js +83 -0
- package/dist/tui/features/vc/diff/utils/parse-mode.d.ts +2 -0
- package/dist/tui/features/vc/diff/utils/parse-mode.js +16 -0
- package/dist/tui/features/vc/remote/components/vc-remote-flow.js +23 -8
- package/dist/webui/assets/index-CvcqpMYn.css +1 -0
- package/dist/webui/assets/index-thSZZahh.js +130 -0
- package/dist/webui/index.html +3 -3
- package/dist/webui/sw.js +1 -1
- package/oclif.manifest.json +1009 -933
- package/package.json +3 -1
- package/dist/webui/assets/index-Cti7S_1o.js +0 -130
- 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) =>
|
|
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(
|
|
495
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
47
|
+
res.sendFile('index.html', { root: webuiDistDir });
|
|
42
48
|
});
|
|
43
49
|
}
|
|
44
50
|
return app;
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
284
|
-
*
|
|
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
|
|
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:
|
|
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,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>
|
|
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 (
|
|
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:
|
|
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,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
|
+
}
|