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.
- package/dist/agent/infra/llm/providers/google.js +1 -1
- 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 +1 -1
- 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 +6 -1
- 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 +639 -566
- package/package.json +3 -1
- package/dist/webui/assets/index-DFMY2d5W.css +0 -1
- 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
|
|
250
|
-
const
|
|
251
|
-
if (!
|
|
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:
|
|
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
|
|
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
|
|
1197
|
+
return this.resolveSingleRef(dir, baseRef);
|
|
1015
1198
|
}
|
|
1016
|
-
let oid = await
|
|
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,
|
|
@@ -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) =>
|
|
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.
|
|
@@ -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
|
-
|
|
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",
|
|
@@ -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
|
+
}
|