byterover-cli 3.8.3 → 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 (49) hide show
  1. package/dist/agent/infra/llm/providers/google.js +1 -1
  2. package/dist/oclif/commands/vc/diff.d.ts +12 -0
  3. package/dist/oclif/commands/vc/diff.js +40 -0
  4. package/dist/oclif/commands/vc/remote/remove.d.ts +9 -0
  5. package/dist/oclif/commands/vc/remote/remove.js +23 -0
  6. package/dist/server/core/domain/entities/brv-config.d.ts +4 -0
  7. package/dist/server/core/domain/entities/brv-config.js +12 -0
  8. package/dist/server/core/domain/entities/provider-registry.js +1 -1
  9. package/dist/server/core/interfaces/services/i-git-service.d.ts +55 -4
  10. package/dist/server/infra/context-tree/summary-frontmatter.js +2 -2
  11. package/dist/server/infra/dream/operations/consolidate.js +5 -4
  12. package/dist/server/infra/dream/operations/synthesize.js +1 -1
  13. package/dist/server/infra/git/isomorphic-git-service.d.ts +24 -1
  14. package/dist/server/infra/git/isomorphic-git-service.js +207 -7
  15. package/dist/server/infra/transport/handlers/config-handler.js +1 -0
  16. package/dist/server/infra/transport/handlers/locations-handler.d.ts +1 -0
  17. package/dist/server/infra/transport/handlers/locations-handler.js +25 -1
  18. package/dist/server/infra/transport/handlers/reveal-command.d.ts +9 -0
  19. package/dist/server/infra/transport/handlers/reveal-command.js +7 -0
  20. package/dist/server/infra/transport/handlers/vc-handler.d.ts +11 -0
  21. package/dist/server/infra/transport/handlers/vc-handler.js +143 -9
  22. package/dist/server/infra/webui/webui-middleware.js +6 -1
  23. package/dist/shared/transport/events/config-events.d.ts +1 -0
  24. package/dist/shared/transport/events/index.d.ts +1 -0
  25. package/dist/shared/transport/events/locations-events.d.ts +7 -0
  26. package/dist/shared/transport/events/locations-events.js +1 -0
  27. package/dist/shared/transport/events/vc-events.d.ts +56 -5
  28. package/dist/shared/transport/events/vc-events.js +7 -0
  29. package/dist/tui/features/commands/definitions/vc-diff.d.ts +2 -0
  30. package/dist/tui/features/commands/definitions/vc-diff.js +23 -0
  31. package/dist/tui/features/commands/definitions/vc-remote.js +16 -7
  32. package/dist/tui/features/commands/definitions/vc.js +2 -0
  33. package/dist/tui/features/vc/diff/api/execute-vc-diff.d.ts +8 -0
  34. package/dist/tui/features/vc/diff/api/execute-vc-diff.js +13 -0
  35. package/dist/tui/features/vc/diff/components/vc-diff-flow.d.ts +8 -0
  36. package/dist/tui/features/vc/diff/components/vc-diff-flow.js +31 -0
  37. package/dist/tui/features/vc/diff/utils/format-diff.d.ts +2 -0
  38. package/dist/tui/features/vc/diff/utils/format-diff.js +83 -0
  39. package/dist/tui/features/vc/diff/utils/parse-mode.d.ts +2 -0
  40. package/dist/tui/features/vc/diff/utils/parse-mode.js +16 -0
  41. package/dist/tui/features/vc/remote/components/vc-remote-flow.js +23 -8
  42. package/dist/webui/assets/index-CvcqpMYn.css +1 -0
  43. package/dist/webui/assets/index-thSZZahh.js +130 -0
  44. package/dist/webui/index.html +3 -3
  45. package/dist/webui/sw.js +1 -1
  46. package/oclif.manifest.json +639 -566
  47. package/package.json +3 -1
  48. package/dist/webui/assets/index-DFMY2d5W.css +0 -1
  49. package/dist/webui/assets/index-Dkyf6c5F.js +0 -130
@@ -246,13 +246,13 @@ export class IsomorphicGitService {
246
246
  const result = Object.fromEntries(paths.map((p) => [p, undefined]));
247
247
  if (paths.length === 0)
248
248
  return result;
249
- if (ref === 'HEAD') {
250
- const headOid = await git.resolveRef({ dir, fs, ref: 'HEAD' }).catch(() => null);
251
- if (!headOid)
249
+ if (ref !== 'STAGE') {
250
+ const commitOid = await this.resolveRefExpression(dir, ref.commitish).catch(() => null);
251
+ if (!commitOid)
252
252
  return result;
253
253
  await Promise.all(paths.map(async (path) => {
254
254
  try {
255
- const { blob } = await git.readBlob({ dir, filepath: path, fs, oid: headOid });
255
+ const { blob } = await git.readBlob({ dir, filepath: path, fs, oid: commitOid });
256
256
  result[path] = Buffer.from(blob).toString('utf8');
257
257
  }
258
258
  catch {
@@ -361,6 +361,16 @@ export class IsomorphicGitService {
361
361
  const result = await git.getConfig({ dir, fs, path: `remote.${params.remote}.url` });
362
362
  return result === undefined || result === null ? undefined : String(result);
363
363
  }
364
+ async getTextBlob(params) {
365
+ const dir = this.requireDirectory(params);
366
+ const { path, ref } = params;
367
+ const raw = await this.readRawBlob(dir, path, ref);
368
+ if (!raw)
369
+ return undefined;
370
+ if (raw.bytes.includes(0))
371
+ return { binary: true, content: '', oid: raw.oid.slice(0, 7) };
372
+ return { content: Buffer.from(raw.bytes).toString('utf8'), oid: raw.oid.slice(0, 7) };
373
+ }
364
374
  async getTrackingBranch(params) {
365
375
  const dir = this.requireDirectory(params);
366
376
  const remote = await git.getConfig({ dir, fs, path: `branch.${params.branch}.remote` });
@@ -374,6 +384,10 @@ export class IsomorphicGitService {
374
384
  const remoteBranch = mergeStr.startsWith('refs/heads/') ? mergeStr.slice('refs/heads/'.length) : mergeStr;
375
385
  return { remote: String(remote), remoteBranch };
376
386
  }
387
+ async hashBlob(content) {
388
+ const { oid } = await git.hashBlob({ object: content });
389
+ return oid.slice(0, 7);
390
+ }
377
391
  async init(params) {
378
392
  const dir = this.requireDirectory(params);
379
393
  await git.init({ defaultBranch: params.defaultBranch ?? 'main', dir, fs });
@@ -431,6 +445,15 @@ export class IsomorphicGitService {
431
445
  }
432
446
  return result;
433
447
  }
448
+ async listChangedFiles(params) {
449
+ const dir = this.requireDirectory(params);
450
+ const { from, to } = params;
451
+ // Commit-vs-commit: walk both trees, compare oids
452
+ if (from !== 'STAGE' && from !== 'WORKDIR' && to !== 'STAGE' && to !== 'WORKDIR') {
453
+ return this.listChangedBetweenCommits(dir, from.commitish, to.commitish);
454
+ }
455
+ return this.listChangedFromMatrix(dir, from, to);
456
+ }
434
457
  async listRemotes(params) {
435
458
  const dir = this.requireDirectory(params);
436
459
  const remotes = await git.listRemotes({ dir, fs });
@@ -737,6 +760,56 @@ export class IsomorphicGitService {
737
760
  const credentials = Buffer.from(`${userId}:${sessionKey}`).toString('base64');
738
761
  return { Authorization: `Basic ${credentials}` };
739
762
  }
763
+ async classifyRefVsWorkdir(dir, fromOid, refSet, matrix) {
764
+ // "Tracked in workdir" = present on disk (workdir != 0) AND tracked (HEAD or stage has it).
765
+ // This excludes untracked files (matches `git diff <commit>` which does not show untracked).
766
+ const trackedOnDisk = new Set();
767
+ for (const [filepath, head, workdir, stage] of matrix) {
768
+ if (workdir !== 0 && (head !== 0 || stage !== 0))
769
+ trackedOnDisk.add(String(filepath));
770
+ }
771
+ const candidates = new Set([...refSet, ...trackedOnDisk]);
772
+ const results = await Promise.all([...candidates].map(async (path) => {
773
+ const inRef = refSet.has(path);
774
+ const inWork = trackedOnDisk.has(path);
775
+ if (inRef && !inWork)
776
+ return { path, status: 'deleted' };
777
+ if (!inRef && inWork)
778
+ return { path, status: 'added' };
779
+ if (inRef && inWork) {
780
+ const [fromBlobOid, workOid] = await Promise.all([
781
+ this.readBlobOid(dir, fromOid, path),
782
+ this.hashWorkdirFile(dir, path),
783
+ ]);
784
+ if (fromBlobOid && workOid && fromBlobOid !== workOid)
785
+ return { path, status: 'modified' };
786
+ }
787
+ return undefined;
788
+ }));
789
+ return results.filter((r) => r !== undefined).sort((a, b) => a.path.localeCompare(b.path));
790
+ }
791
+ classifyStagedRow(row) {
792
+ const [filepath, head, , stage] = row;
793
+ const path = String(filepath);
794
+ if (head === 0 && (stage === 2 || stage === 3))
795
+ return { path, status: 'added' };
796
+ if (head === 1 && stage === 0)
797
+ return { path, status: 'deleted' };
798
+ if (head === 1 && (stage === 2 || stage === 3))
799
+ return { path, status: 'modified' };
800
+ return undefined;
801
+ }
802
+ classifyUnstagedRow(row) {
803
+ const [filepath, head, workdir, stage] = row;
804
+ const path = String(filepath);
805
+ if (head === 0 && stage === 0)
806
+ return undefined; // skip untracked
807
+ if (workdir === 0 && (stage === 1 || stage === 2 || stage === 3))
808
+ return { path, status: 'deleted' };
809
+ if (workdir === 2 && (stage === 1 || stage === 3))
810
+ return { path, status: 'modified' };
811
+ return undefined;
812
+ }
740
813
  conflictsFromError(error) {
741
814
  if (!IsomorphicGitService.isConflictError(error))
742
815
  return [];
@@ -756,6 +829,13 @@ export class IsomorphicGitService {
756
829
  }))
757
830
  .sort((a, b) => a.path.localeCompare(b.path));
758
831
  }
832
+ describeSide(side) {
833
+ if (side === 'STAGE')
834
+ return 'STAGE';
835
+ if (side === 'WORKDIR')
836
+ return 'WORKDIR';
837
+ return `commitish(${side.commitish})`;
838
+ }
759
839
  getAuthor() {
760
840
  const token = this.authStateStore.getToken();
761
841
  if (!token)
@@ -812,6 +892,69 @@ export class IsomorphicGitService {
812
892
  '\nPlease commit your changes or stash them before you switch branches.');
813
893
  }
814
894
  }
895
+ async hashWorkdirFile(dir, path) {
896
+ try {
897
+ const buf = await fs.promises.readFile(join(dir, path));
898
+ const { oid } = await git.hashBlob({ object: buf });
899
+ return oid;
900
+ }
901
+ catch {
902
+ return null;
903
+ }
904
+ }
905
+ isCommitishSide(side) {
906
+ return side !== 'STAGE' && side !== 'WORKDIR';
907
+ }
908
+ async listChangedBetweenCommits(dir, fromRef, toRef) {
909
+ const [fromOid, toOid] = await Promise.all([
910
+ this.resolveRefExpression(dir, fromRef),
911
+ this.resolveRefExpression(dir, toRef),
912
+ ]);
913
+ const changes = [];
914
+ await git.walk({
915
+ dir,
916
+ fs,
917
+ async map(filepath, [a, b]) {
918
+ if (filepath === '.')
919
+ return;
920
+ const [aType, bType] = await Promise.all([a?.type(), b?.type()]);
921
+ if (aType === 'tree' || bType === 'tree')
922
+ return;
923
+ if (!a && b && bType === 'blob') {
924
+ changes.push({ path: filepath, status: 'added' });
925
+ return;
926
+ }
927
+ if (a && !b && aType === 'blob') {
928
+ changes.push({ path: filepath, status: 'deleted' });
929
+ return;
930
+ }
931
+ if (a && b && aType === 'blob' && bType === 'blob') {
932
+ const [aOid, bOid] = await Promise.all([a.oid(), b.oid()]);
933
+ if (aOid !== bOid)
934
+ changes.push({ path: filepath, status: 'modified' });
935
+ }
936
+ },
937
+ // eslint-disable-next-line new-cap
938
+ trees: [git.TREE({ ref: fromOid }), git.TREE({ ref: toOid })],
939
+ });
940
+ return changes;
941
+ }
942
+ async listChangedFromMatrix(dir, from, to) {
943
+ const matrix = await git.statusMatrix({ dir, fs });
944
+ if (from === 'STAGE' && to === 'WORKDIR') {
945
+ return matrix.map((row) => this.classifyUnstagedRow(row)).filter((c) => c !== undefined);
946
+ }
947
+ if (this.isCommitishSide(from) && to === 'STAGE') {
948
+ return matrix.map((row) => this.classifyStagedRow(row)).filter((c) => c !== undefined);
949
+ }
950
+ if (this.isCommitishSide(from) && to === 'WORKDIR') {
951
+ const fromOid = await this.resolveRefExpression(dir, from.commitish);
952
+ const tracked = await git.listFiles({ dir, fs, ref: fromOid });
953
+ const trackedSet = new Set(tracked);
954
+ return this.classifyRefVsWorkdir(dir, fromOid, trackedSet, matrix);
955
+ }
956
+ throw new GitError(`unsupported diff side combination: from=${this.describeSide(from)} to=${this.describeSide(to)}`);
957
+ }
815
958
  /**
816
959
  * Manual merge for unrelated histories (no common ancestor).
817
960
  * isomorphic-git throws MergeNotSupportedError because it can't handle
@@ -940,6 +1083,46 @@ export class IsomorphicGitService {
940
1083
  return null;
941
1084
  }
942
1085
  }
1086
+ /**
1087
+ * Reads the raw blob bytes + full oid at the given ref in a single pass.
1088
+ * Returns `undefined` when the blob is absent (path missing, ref unresolved, etc.).
1089
+ * Used by {@link getTextBlob}; callers downstream decide binary vs text.
1090
+ */
1091
+ async readRawBlob(dir, path, ref) {
1092
+ if (ref === 'STAGE') {
1093
+ let found;
1094
+ await git
1095
+ .walk({
1096
+ dir,
1097
+ fs,
1098
+ async map(filepath, [entry]) {
1099
+ if (filepath !== path || !entry)
1100
+ return;
1101
+ if ((await entry.type()) !== 'blob')
1102
+ return;
1103
+ const oid = await entry.oid();
1104
+ const { blob } = await git.readBlob({ dir, fs, oid });
1105
+ found = { bytes: blob, oid };
1106
+ },
1107
+ // eslint-disable-next-line new-cap
1108
+ trees: [git.STAGE()],
1109
+ })
1110
+ .catch(() => {
1111
+ // leave found undefined
1112
+ });
1113
+ return found;
1114
+ }
1115
+ const commitOid = await this.resolveRefExpression(dir, ref.commitish).catch(() => null);
1116
+ if (!commitOid)
1117
+ return undefined;
1118
+ try {
1119
+ const { blob, oid } = await git.readBlob({ dir, filepath: path, fs, oid: commitOid });
1120
+ return { bytes: blob, oid };
1121
+ }
1122
+ catch {
1123
+ return undefined;
1124
+ }
1125
+ }
943
1126
  requireDirectory(params) {
944
1127
  // Guard against empty string — undefined/null are caught by TypeScript at compile time
945
1128
  if (!params.directory)
@@ -1006,14 +1189,14 @@ export class IsomorphicGitService {
1006
1189
  async resolveRefExpression(dir, ref) {
1007
1190
  const tildeMatch = /^(.+)~(\d+)$/.exec(ref);
1008
1191
  if (!tildeMatch) {
1009
- return git.resolveRef({ dir, fs, ref });
1192
+ return this.resolveSingleRef(dir, ref);
1010
1193
  }
1011
1194
  const baseRef = tildeMatch[1];
1012
1195
  const count = Number.parseInt(tildeMatch[2], 10);
1013
1196
  if (count === 0) {
1014
- return git.resolveRef({ dir, fs, ref: baseRef });
1197
+ return this.resolveSingleRef(dir, baseRef);
1015
1198
  }
1016
- let oid = await git.resolveRef({ dir, fs, ref: baseRef });
1199
+ let oid = await this.resolveSingleRef(dir, baseRef);
1017
1200
  for (let i = 0; i < count; i++) {
1018
1201
  // eslint-disable-next-line no-await-in-loop
1019
1202
  const commit = await git.readCommit({ dir, fs, oid });
@@ -1024,6 +1207,23 @@ export class IsomorphicGitService {
1024
1207
  }
1025
1208
  return oid;
1026
1209
  }
1210
+ /**
1211
+ * Resolve a single ref (branch name, tag, full SHA, or short SHA).
1212
+ * Falls back to `git.expandOid` for short SHAs since `git.resolveRef`
1213
+ * only accepts full OIDs and symbolic refs.
1214
+ */
1215
+ async resolveSingleRef(dir, ref) {
1216
+ try {
1217
+ return await git.resolveRef({ dir, fs, ref });
1218
+ }
1219
+ catch (error) {
1220
+ // Short SHA: 4-39 hex chars. Try expandOid which disambiguates against the object DB.
1221
+ if (/^[\da-f]{4,39}$/i.test(ref)) {
1222
+ return git.expandOid({ dir, fs, oid: ref });
1223
+ }
1224
+ throw error;
1225
+ }
1226
+ }
1027
1227
  /**
1028
1228
  * Fixes conflict markers written by isomorphic-git to match native git:
1029
1229
  * 1. Replaces `<<<<<<< <branchName>` with `<<<<<<< HEAD`
@@ -13,6 +13,7 @@ export class ConfigHandler {
13
13
  this.transport.onRequest(ConfigEvents.GET_ENVIRONMENT, () => {
14
14
  const config = getCurrentConfig();
15
15
  return {
16
+ gitRemoteBaseUrl: config.gitRemoteBaseUrl,
16
17
  iamBaseUrl: config.iamBaseUrl,
17
18
  isDevelopment: isDevelopment(),
18
19
  webAppUrl: config.webAppUrl,
@@ -24,4 +24,5 @@ export declare class LocationsHandler {
24
24
  constructor(deps: LocationsHandlerDeps);
25
25
  setup(): void;
26
26
  private buildLocations;
27
+ private handleReveal;
27
28
  }
@@ -1,6 +1,8 @@
1
+ import { spawn } from 'node:child_process';
1
2
  import { join } from 'node:path';
2
- import { LocationsEvents } from '../../../../shared/transport/events/locations-events.js';
3
+ import { LocationsEvents, } from '../../../../shared/transport/events/locations-events.js';
3
4
  import { BRV_DIR, CONTEXT_TREE_DIR } from '../../../constants.js';
5
+ import { resolveRevealCommand } from './reveal-command.js';
4
6
  /**
5
7
  * Handles locations:get event.
6
8
  * Returns all registered project locations with context tree status.
@@ -31,6 +33,7 @@ export class LocationsHandler {
31
33
  return { locations: [] };
32
34
  }
33
35
  });
36
+ this.transport.onRequest(LocationsEvents.REVEAL, async (data) => this.handleReveal(data));
34
37
  }
35
38
  async buildLocations(currentProjectPath) {
36
39
  const all = this.projectRegistry.getAll();
@@ -75,4 +78,25 @@ export class LocationsHandler {
75
78
  return (all.get(b.projectPath)?.registeredAt ?? 0) - (all.get(a.projectPath)?.registeredAt ?? 0);
76
79
  });
77
80
  }
81
+ async handleReveal(data) {
82
+ const { projectPath } = data;
83
+ if (!projectPath)
84
+ throw new Error('projectPath is required');
85
+ // Only allow revealing paths that are registered projects — the client
86
+ // controls this argument, so we must not trust it blindly.
87
+ const registered = this.projectRegistry.getAll();
88
+ if (!registered.has(projectPath)) {
89
+ throw new Error('Project is not registered.');
90
+ }
91
+ const exists = await this.pathExists(projectPath).catch(() => false);
92
+ if (!exists)
93
+ throw new Error('Project folder no longer exists.');
94
+ const { args, command } = resolveRevealCommand(process.platform, projectPath);
95
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
96
+ child.on('error', () => {
97
+ /* best-effort — nothing to report back once the ack resolved */
98
+ });
99
+ child.unref();
100
+ return { projectPath };
101
+ }
78
102
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Resolves the OS-native command for revealing a path in the system file manager.
3
+ * Extracted so the branching can be unit-tested without spawning real processes.
4
+ */
5
+ export type RevealCommand = {
6
+ args: string[];
7
+ command: string;
8
+ };
9
+ export declare function resolveRevealCommand(platformName: NodeJS.Platform, targetPath: string): RevealCommand;
@@ -0,0 +1,7 @@
1
+ export function resolveRevealCommand(platformName, targetPath) {
2
+ if (platformName === 'darwin')
3
+ return { args: [targetPath], command: 'open' };
4
+ if (platformName === 'win32')
5
+ return { args: [targetPath], command: 'explorer' };
6
+ return { args: [targetPath], command: 'xdg-open' };
7
+ }
@@ -40,6 +40,11 @@ export declare class VcHandler {
40
40
  constructor(deps: IVcHandlerDeps);
41
41
  setup(): void;
42
42
  private buildAuthorHint;
43
+ /**
44
+ * Builds a single diff entry for a changed file, or `undefined` when either required side
45
+ * is absent/binary (the caller filters these out to keep binaries out of diff output).
46
+ */
47
+ private buildDiffFile;
43
48
  private buildNoRemoteMessage;
44
49
  private computeDiff;
45
50
  /**
@@ -77,6 +82,12 @@ export declare class VcHandler {
77
82
  private handleRemote;
78
83
  private handleReset;
79
84
  private handleStatus;
85
+ /**
86
+ * Reads a diff side's content + short oid in a single pass. Returns `undefined`
87
+ * only when the blob is absent on disk / at the ref. Binary content (NUL byte)
88
+ * is marked via `binary: true` so the caller can emit `Binary files ... differ`.
89
+ */
90
+ private readSideEntry;
80
91
  /**
81
92
  * Resolve clone request data into a clean cogit URL + team/space info.
82
93
  * Accepts either a URL or explicit teamName/spaceName.
@@ -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.
@@ -19,7 +19,12 @@ export function createWebUiMiddleware({ getConfig, webuiDistDir }) {
19
19
  "connect-src 'self' ws: wss:",
20
20
  "font-src 'self' data: https://fonts.gstatic.com",
21
21
  "frame-ancestors 'none'",
22
- "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:",
23
28
  "object-src 'none'",
24
29
  "script-src 'self'",
25
30
  "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
@@ -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
  };