History

Add JJ VCS driver support

- Detect and route jj repositories alongside git - Thread VCS kind through status and UI labels - Add jj driver contract coverage and shared freshness helper

Julius Marminge committed 6 months ago

Commit c31beca1 parent 128b43230 files · +838 −44Browse files

Unified · Split

30 files
  1. apps/server/integration/OrchestrationEngineHarness.integration.ts +1 −0
  2. apps/server/src/git/GitManager.ts +1 −0
  3. apps/server/src/git/GitWorkflowService.ts +67 −3
  4. apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +1 −0
  5. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +1 −0
  6. apps/server/src/server.test.ts +10 −0
  7. apps/server/src/vcs/GitVcsDriver.ts +2 −10
  8. apps/server/src/vcs/GitVcsDriverCore.ts +1 −0
  9. apps/server/src/vcs/JjVcsDriver.test.ts +191 −0
  10. apps/server/src/vcs/JjVcsDriver.ts +319 −0
  11. apps/server/src/vcs/VcsDriverRegistry.test.ts +78 −3
  12. apps/server/src/vcs/VcsDriverRegistry.ts +9 −1
  13. apps/server/src/vcs/VcsFreshness.ts +10 −0
  14. apps/server/src/vcs/VcsStatusBroadcaster.test.ts +1 −0
  15. apps/server/src/vcs/testing/VcsDriverContractHarness.ts +3 −1
  16. apps/web/src/components/BranchToolbarBranchSelector.tsx +14 −8
  17. apps/web/src/components/ChatView.tsx +7 −5
  18. apps/web/src/components/DiffPanel.tsx +6 −4
  19. apps/web/src/components/GitActionsControl.browser.tsx +1 −0
  20. apps/web/src/components/GitActionsControl.logic.test.ts +1 −0
  21. apps/web/src/components/GitActionsControl.tsx +25 −1
  22. apps/web/src/components/chat/ChatHeader.tsx +12 −7
  23. apps/web/src/lib/gitStatusState.test.ts +1 −0
  24. apps/web/src/lib/providerReactQuery.ts +1 −1
  25. apps/web/src/localApi.test.ts +1 −0
  26. apps/web/src/rpc/wsRpcClient.test.ts +1 −0
  27. apps/web/src/vcsPresentation.ts +67 −0
  28. packages/contracts/src/git.ts +2 −0
  29. packages/shared/src/git.test.ts +2 −0
  30. packages/shared/src/git.ts +2 −0
apps/server/integration/OrchestrationEngineHarness.integration.ts+1−0
@@ -331,6 +331,7 @@ export const makeOrchestrationIntegrationHarness = ( · 330 unmodified lines
331 refreshLocalStatus: () =>331 refreshLocalStatus: () =>
332 Effect.succeed({332 Effect.succeed({
333 isRepo: true,333 isRepo: true,
334 kind: "git",
334 hasPrimaryRemote: false,335 hasPrimaryRemote: false,
335 isDefaultRef: true,336 isDefaultRef: true,
336 refName: "main",337 refName: "main",
apps/server/src/git/GitManager.ts+1−0
@@ -704,6 +704,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 703 unmodified lines
704 : null;704 : null;
705 705
706 return {706 return {
707 kind: details.isRepo ? ("git" as const) : ("unknown" as const),
707 isRepo: details.isRepo,708 isRepo: details.isRepo,
708 ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}),709 ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}),
709 hasPrimaryRemote: details.hasOriginRemote,710 hasPrimaryRemote: details.hasOriginRemote,
apps/server/src/git/GitWorkflowService.ts+67−3
@@ -3,6 +3,7 @@ import { Context, Effect, Layer } from "effect"; · 2 unmodified lines
3import {3import {
4 GitManagerError,4 GitManagerError,
5 GitCommandError,5 GitCommandError,
6 type VcsDriverKind,
6 type VcsSwitchRefInput,7 type VcsSwitchRefInput,
7 type VcsSwitchRefResult,8 type VcsSwitchRefResult,
8 type VcsCreateRefInput,9 type VcsCreateRefInput,
@@ -29,6 +30,7 @@ import { · 20 unmodified lines
29import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts";30import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts";
30import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";31import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";
31import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";32import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
33import { mergeGitStatusParts } from "@t3tools/shared/git";
32 34
33export interface GitWorkflowServiceShape {35export interface GitWorkflowServiceShape {
34 readonly status: (36 readonly status: (
@@ -92,6 +94,18 @@ const unsupportedGitCommand = (operation: string, cwd: string, detail: string) = · 57 unmodified lines
92 detail,94 detail,
93 });95 });
94 96
97const emptyWorkingTree = { files: [], insertions: 0, deletions: 0 } as const;
98
99const nonGitLocalStatus = (kind: VcsDriverKind, isRepo: boolean): VcsStatusLocalResult => ({
100 kind,
101 isRepo,
102 hasPrimaryRemote: false,
103 isDefaultRef: false,
104 refName: null,
105 hasWorkingTreeChanges: false,
106 workingTree: emptyWorkingTree,
107});
108
95export const make = Effect.fn("makeGitWorkflowService")(function* () {109export const make = Effect.fn("makeGitWorkflowService")(function* () {
96 const registry = yield* VcsDriverRegistry;110 const registry = yield* VcsDriverRegistry;
97 const git = yield* GitVcsDriver;111 const git = yield* GitVcsDriver;
@@ -153,10 +167,60 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { · 55 unmodified lines
153 (input: Input) =>167 (input: Input) =>
154 ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input)));168 ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input)));
155 169
170 const localStatus: GitWorkflowServiceShape["localStatus"] = Effect.fn(
171 "GitWorkflowService.localStatus",
172 )(function* (input) {
173 const handle = yield* registry
174 .detect({ cwd: input.cwd })
175 .pipe(
176 Effect.mapError((error) =>
177 unsupportedGitWorkflow(
178 "GitWorkflowService.localStatus",
179 input.cwd,
180 error instanceof Error ? error.message : String(error),
181 ),
182 ),
183 );
184 if (!handle) {
185 return nonGitLocalStatus("unknown", false);
186 }
187 if (handle.kind === "git") {
188 return yield* gitManager.localStatus(input);
189 }
190 return nonGitLocalStatus(handle.kind, true);
191 });
192
193 const remoteStatus: GitWorkflowServiceShape["remoteStatus"] = Effect.fn(
194 "GitWorkflowService.remoteStatus",
195 )(function* (input) {
196 const handle = yield* registry
197 .detect({ cwd: input.cwd })
198 .pipe(
199 Effect.mapError((error) =>
200 unsupportedGitWorkflow(
201 "GitWorkflowService.remoteStatus",
202 input.cwd,
203 error instanceof Error ? error.message : String(error),
204 ),
205 ),
206 );
207 if (handle?.kind === "git") {
208 return yield* gitManager.remoteStatus(input);
209 }
210 return null;
211 });
212
213 const status: GitWorkflowServiceShape["status"] = Effect.fn("GitWorkflowService.status")(
214 function* (input) {
215 const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]);
216 return mergeGitStatusParts(local, remote);
217 },
218 );
219
156 return GitWorkflowService.of({220 return GitWorkflowService.of({
157 status: routeGitManager("GitWorkflowService.status", gitManager.status),221 status,
158 localStatus: routeGitManager("GitWorkflowService.localStatus", gitManager.localStatus),222 localStatus,
159 remoteStatus: routeGitManager("GitWorkflowService.remoteStatus", gitManager.remoteStatus),223 remoteStatus,
160 invalidateLocalStatus: gitManager.invalidateLocalStatus,224 invalidateLocalStatus: gitManager.invalidateLocalStatus,
161 invalidateRemoteStatus: gitManager.invalidateRemoteStatus,225 invalidateRemoteStatus: gitManager.invalidateRemoteStatus,
162 invalidateStatus: gitManager.invalidateStatus,226 invalidateStatus: gitManager.invalidateStatus,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+1−0
@@ -291,6 +291,7 @@ describe("CheckpointReactor", () => { · 290 unmodified lines
291 }).pipe(291 }).pipe(
292 Effect.as({292 Effect.as({
293 isRepo: true,293 isRepo: true,
294 kind: "git",
294 hasPrimaryRemote: false,295 hasPrimaryRemote: false,
295 isDefaultRef: true,296 isDefaultRef: true,
296 refName: "main",297 refName: "main",
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+1−0
@@ -240,6 +240,7 @@ describe("ProviderCommandReactor", () => { · 239 unmodified lines
240 const refreshStatus = vi.fn((_: string) =>240 const refreshStatus = vi.fn((_: string) =>
241 Effect.succeed({241 Effect.succeed({
242 isRepo: true,242 isRepo: true,
243 kind: "git" as const,
243 hasPrimaryRemote: true,244 hasPrimaryRemote: true,
244 isDefaultRef: false,245 isDefaultRef: false,
245 refName: "renamed-branch",246 refName: "renamed-branch",
apps/server/src/server.test.ts+10−0
@@ -2373,6 +2373,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 2372 unmodified lines
2373 localStatus: () =>2373 localStatus: () =>
2374 Effect.succeed({2374 Effect.succeed({
2375 isRepo: true,2375 isRepo: true,
2376 kind: "git",
2376 hasPrimaryRemote: true,2377 hasPrimaryRemote: true,
2377 isDefaultRef: true,2378 isDefaultRef: true,
2378 refName: "main",2379 refName: "main",
@@ -2389,6 +2390,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 10 unmodified lines
2389 status: () =>2390 status: () =>
2390 Effect.succeed({2391 Effect.succeed({
2391 isRepo: true,2392 isRepo: true,
2393 kind: "git",
2392 hasPrimaryRemote: true,2394 hasPrimaryRemote: true,
2393 isDefaultRef: true,2395 isDefaultRef: true,
2394 refName: "main",2396 refName: "main",
@@ -2643,6 +2645,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 248 unmodified lines
2643 localStatus: () =>2645 localStatus: () =>
2644 Effect.succeed({2646 Effect.succeed({
2645 isRepo: true,2647 isRepo: true,
2648 kind: "git",
2646 hasPrimaryRemote: true,2649 hasPrimaryRemote: true,
2647 isDefaultRef: true,2650 isDefaultRef: true,
2648 refName: "main",2651 refName: "main",
@@ -2664,6 +2667,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 15 unmodified lines
2664 statusCalls += 1;2667 statusCalls += 1;
2665 return {2668 return {
2666 isRepo: true,2669 isRepo: true,
2670 kind: "git",
2667 hasPrimaryRemote: true,2671 hasPrimaryRemote: true,
2668 isDefaultRef: true,2672 isDefaultRef: true,
2669 refName: "main",2673 refName: "main",
@@ -2720,6 +2724,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 50 unmodified lines
2720 localStatus: () =>2724 localStatus: () =>
2721 Effect.succeed({2725 Effect.succeed({
2722 isRepo: true,2726 isRepo: true,
2727 kind: "git",
2723 hasPrimaryRemote: true,2728 hasPrimaryRemote: true,
2724 isDefaultRef: false,2729 isDefaultRef: false,
2725 refName: "feature/demo",2730 refName: "feature/demo",
@@ -2741,6 +2746,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 15 unmodified lines
2741 statusCalls += 1;2746 statusCalls += 1;
2742 return {2747 return {
2743 isRepo: true,2748 isRepo: true,
2749 kind: "git",
2744 hasPrimaryRemote: true,2750 hasPrimaryRemote: true,
2745 isDefaultRef: false,2751 isDefaultRef: false,
2746 refName: "feature/demo",2752 refName: "feature/demo",
@@ -2792,6 +2798,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 45 unmodified lines
2792 localStatus: () =>2798 localStatus: () =>
2793 Effect.succeed({2799 Effect.succeed({
2794 isRepo: true,2800 isRepo: true,
2801 kind: "git",
2795 hasPrimaryRemote: true,2802 hasPrimaryRemote: true,
2796 isDefaultRef: true,2803 isDefaultRef: true,
2797 refName: "main",2804 refName: "main",
@@ -2835,6 +2842,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 37 unmodified lines
2835 localStatus: () =>2842 localStatus: () =>
2836 Effect.succeed({2843 Effect.succeed({
2837 isRepo: true,2844 isRepo: true,
2845 kind: "git",
2838 hasPrimaryRemote: true,2846 hasPrimaryRemote: true,
2839 isDefaultRef: false,2847 isDefaultRef: false,
2840 refName: "feature/demo",2848 refName: "feature/demo",
@@ -2911,6 +2919,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 70 unmodified lines
2911 Effect.andThen(2919 Effect.andThen(
2912 Effect.succeed({2920 Effect.succeed({
2913 isRepo: true,2921 isRepo: true,
2922 kind: "git" as const,
2914 hasPrimaryRemote: true,2923 hasPrimaryRemote: true,
2915 isDefaultRef: false,2924 isDefaultRef: false,
2916 refName: "feature/demo",2925 refName: "feature/demo",
@@ -3577,6 +3586,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 660 unmodified lines
3577 const refreshStatus = vi.fn((_: string) =>3586 const refreshStatus = vi.fn((_: string) =>
3578 Effect.succeed({3587 Effect.succeed({
3579 isRepo: true,3588 isRepo: true,
3589 kind: "git" as const,
3580 hasPrimaryRemote: true,3590 hasPrimaryRemote: true,
3581 isDefaultRef: false,3591 isDefaultRef: false,
3582 refName: "t3code/bootstrap-refName",3592 refName: "t3code/bootstrap-refName",
apps/server/src/vcs/GitVcsDriver.ts+2−10
@@ -1,4 +1,4 @@
1import { Context, DateTime, Effect, Layer, Option } from "effect";1import { Context, Effect, Layer } from "effect";
2import { ChildProcessSpawner } from "effect/unstable/process";2import { ChildProcessSpawner } from "effect/unstable/process";
3 3
4import {4import {
@@ -20,6 +20,7 @@ import { · 15 unmodified lines
20} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts";21import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts";
22import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts";22import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts";
23import { nowFreshness } from "./VcsFreshness.ts";
23import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts";24import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts";
24 25
25export interface ExecuteGitInput {26export interface ExecuteGitInput {
@@ -209,15 +210,6 @@ const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ · 183 unmodified lines
209 "core.untrackedCache=false",210 "core.untrackedCache=false",
210] as const;211] as const;
211 212
212const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () {
213 const now = yield* DateTime.now;
214 return {
215 source: "live-local" as const,
216 observedAt: now,
217 expiresAt: Option.none(),
218 };
219});
220
221function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {213function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
222 const parts = input.split("\0");214 const parts = input.split("\0");
223 if (parts.length === 0) return [];215 if (parts.length === 0) return [];
apps/server/src/vcs/GitVcsDriverCore.ts+1−0
@@ -1314,6 +1314,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 1313 unmodified lines
1314 const status: GitVcsDriverShape["status"] = (input) =>1314 const status: GitVcsDriverShape["status"] = (input) =>
1315 statusDetails(input.cwd).pipe(1315 statusDetails(input.cwd).pipe(
1316 Effect.map((details) => ({1316 Effect.map((details) => ({
1317 kind: "git" as const,
1317 isRepo: details.isRepo,1318 isRepo: details.isRepo,
1318 hasPrimaryRemote: details.hasOriginRemote,1319 hasPrimaryRemote: details.hasOriginRemote,
1319 isDefaultRef: details.isDefaultBranch,1320 isDefaultRef: details.isDefaultBranch,
apps/server/src/vcs/JjVcsDriver.test.ts+191−0
@@ -0,0 +1,191 @@
1import { spawnSync } from "node:child_process";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, it } from "@effect/vitest";
5import { Effect, FileSystem, Layer, Path, PlatformError } from "effect";
6import { ChildProcessSpawner } from "effect/unstable/process";
7import { describe } from "vitest";
8
9import type { VcsError } from "@t3tools/contracts";
10import type { VcsProcessInput, VcsProcessOutput } from "./VcsProcess.ts";
11import { VcsProcess, layer as VcsProcessLayer } from "./VcsProcess.ts";
12import * as JjVcsDriver from "./JjVcsDriver.ts";
13import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts";
14
15const JjContractLayer = JjVcsDriver.vcsLayer.pipe(
16 Layer.provideMerge(VcsProcessLayer),
17 Layer.provideMerge(NodeServices.layer),
18);
19
20const commandCalls = (calls: ReadonlyArray<VcsProcessInput>) =>
21 calls.map((call) => [call.command].concat(call.args));
22
23const processOutput = (stdout: string, exitCode = 0, stderr = ""): VcsProcessOutput => ({
24 exitCode: ChildProcessSpawner.ExitCode(exitCode),
25 stdout,
26 stderr,
27 stdoutTruncated: false,
28 stderrTruncated: false,
29});
30
31const hasJj = spawnSync("jj", ["--version"], { stdio: "ignore" }).status === 0;
32
33const runJj = (cwd: string, args: ReadonlyArray<string>) =>
34 Effect.gen(function* () {
35 const process = yield* VcsProcess;
36 yield* process.run({
37 operation: "JjVcsDriver.contract.jj",
38 command: "jj",
39 cwd,
40 args,
41 timeoutMs: 10_000,
42 });
43 });
44
45type JjContractError = PlatformError.PlatformError | VcsError;
46
47if (hasJj) {
48 runVcsDriverContractSuite<VcsProcess, JjContractError>({
49 name: "JJ",
50 kind: "jj",
51 layer: JjContractLayer,
52 fixture: {
53 createRepo: (cwd) => runJj(cwd, ["git", "init", "--colocate"]),
54 writeFile: (cwd, relativePath, contents) =>
55 Effect.gen(function* () {
56 const fileSystem = yield* FileSystem.FileSystem;
57 const path = yield* Path.Path;
58 const absolutePath = path.join(cwd, relativePath);
59 yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true });
60 yield* fileSystem.writeFileString(absolutePath, contents);
61 }),
62 ignorePath: (cwd, pattern) =>
63 Effect.gen(function* () {
64 const fileSystem = yield* FileSystem.FileSystem;
65 const path = yield* Path.Path;
66 yield* fileSystem.writeFileString(path.join(cwd, ".gitignore"), `${pattern}\n`);
67 }),
68 },
69 });
70} else {
71 it("skips the JJ VCS driver contract when jj is not installed", () => {
72 assert.isFalse(hasJj);
73 });
74}
75
76describe("JjVcsDriver", () => {
77 it.effect("detects repository identity with jj root", () => {
78 const calls: VcsProcessInput[] = [];
79
80 return Effect.gen(function* () {
81 const driver = yield* JjVcsDriver.makeVcsDriverShape();
82 const identity = yield* driver.detectRepository("/repo/src");
83
84 assert.equal(identity?.kind, "jj");
85 assert.equal(identity?.rootPath, "/repo");
86 assert.equal(identity?.metadataPath, "/repo/.jj");
87 assert.deepStrictEqual(commandCalls(calls), [["jj", "--no-pager", "root"]]);
88 }).pipe(
89 Effect.provide(
90 Layer.mock(VcsProcess)({
91 run: (input) =>
92 Effect.sync(() => {
93 calls.push(input);
94 return processOutput("/repo\n");
95 }),
96 }),
97 ),
98 );
99 });
100
101 it.effect("lists workspace files using jj file list", () => {
102 let observedInput: VcsProcessInput | null = null;
103
104 return Effect.gen(function* () {
105 const driver = yield* JjVcsDriver.makeVcsDriverShape();
106 const result = yield* driver.listWorkspaceFiles("/repo");
107
108 assert.deepStrictEqual(result.paths, ["README.md", "src/index.ts"]);
109 assert.deepStrictEqual(observedInput?.args, ["--no-pager", "file", "list"]);
110 }).pipe(
111 Effect.provide(
112 Layer.mock(VcsProcess)({
113 run: (input) =>
114 Effect.sync(() => {
115 observedInput = input;
116 return processOutput("README.md\nsrc/index.ts\n");
117 }),
118 }),
119 ),
120 );
121 });
122
123 it.effect("filters paths with the git ignore oracle", () => {
124 const calls: VcsProcessInput[] = [];
125
126 return Effect.gen(function* () {
127 const driver = yield* JjVcsDriver.makeVcsDriverShape();
128 const result = yield* driver.filterIgnoredPaths("/repo", [
129 "keep.ts",
130 "debug.log",
131 "src/index.ts",
132 ]);
133
134 assert.deepStrictEqual(result, ["keep.ts", "src/index.ts"]);
135 assert.equal(calls[0]?.command, "git");
136 assert.deepStrictEqual(calls[0]?.args.slice(-2), ["init", "--bare"]);
137 assert.equal(calls[1]?.command, "git");
138 assert.deepStrictEqual(calls[1]?.args.slice(-4), [
139 "check-ignore",
140 "--no-index",
141 "-z",
142 "--stdin",
143 ]);
144 assert.equal(calls[1]?.stdin, "keep.ts\0debug.log\0src/index.ts\0");
145 }).pipe(
146 Effect.provide(
147 Layer.mock(VcsProcess)({
148 run: (input) =>
149 Effect.sync(() => {
150 calls.push(input);
151 if (input.command === "git" && input.args.includes("check-ignore")) {
152 return processOutput("debug.log\0");
153 }
154 return processOutput("");
155 }),
156 }),
157 ),
158 );
159 });
160
161 it.effect("forwards execute env to the VCS process", () => {
162 let observedEnv: NodeJS.ProcessEnv | undefined;
163
164 return Effect.gen(function* () {
165 const driver = yield* JjVcsDriver.makeVcsDriverShape();
166
167 yield* driver.execute({
168 operation: "JjVcsDriver.test.env",
169 cwd: "/repo",
170 args: ["status"],
171 env: {
172 JJ_CONFIG: "/tmp/t3-jj-config.toml",
173 },
174 });
175
176 assert.deepStrictEqual(observedEnv, {
177 JJ_CONFIG: "/tmp/t3-jj-config.toml",
178 });
179 }).pipe(
180 Effect.provide(
181 Layer.mock(VcsProcess)({
182 run: (input) =>
183 Effect.sync(() => {
184 observedEnv = input.env;
185 return processOutput("");
186 }),
187 }),
188 ),
189 );
190 });
191});
apps/server/src/vcs/JjVcsDriver.ts+319−0
@@ -0,0 +1,319 @@
1import { mkdtemp, rm } from "node:fs/promises";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
5import { Effect, Layer } from "effect";
6
7import { VcsOutputDecodeError, VcsProcessExitError } from "@t3tools/contracts";
8import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts";
9import { nowFreshness } from "./VcsFreshness.ts";
10import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts";
11
12const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
13const CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
14
15function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
16 const parts = input.split("\0");
17 if (parts.length === 0) return [];
18
19 if (truncated && parts[parts.length - 1]?.length) {
20 parts.pop();
21 }
22
23 return parts.filter((value) => value.length > 0);
24}
25
26function splitLineSeparatedPaths(input: string, truncated: boolean): string[] {
27 const lines = input.split(/\r?\n/g);
28 if (truncated && lines[lines.length - 1]?.length) {
29 lines.pop();
30 }
31
32 return lines.map((line) => line.trim()).filter((line) => line.length > 0);
33}
34
35function chunkPathsForCheckIgnore(relativePaths: ReadonlyArray<string>): string[][] {
36 const chunks: string[][] = [];
37 let chunk: string[] = [];
38 let chunkBytes = 0;
39
40 for (const relativePath of relativePaths) {
41 const relativePathBytes = Buffer.byteLength(relativePath) + 1;
42 if (chunk.length > 0 && chunkBytes + relativePathBytes > CHECK_IGNORE_MAX_STDIN_BYTES) {
43 chunks.push(chunk);
44 chunk = [];
45 chunkBytes = 0;
46 }
47
48 chunk.push(relativePath);
49 chunkBytes += relativePathBytes;
50
51 if (chunkBytes >= CHECK_IGNORE_MAX_STDIN_BYTES) {
52 chunks.push(chunk);
53 chunk = [];
54 chunkBytes = 0;
55 }
56 }
57
58 if (chunk.length > 0) {
59 chunks.push(chunk);
60 }
61
62 return chunks;
63}
64
65const processCommand = (
66 process: VcsProcessShape,
67 command: string,
68 operation: string,
69 cwd: string,
70 args: ReadonlyArray<string>,
71 options?: {
72 readonly stdin?: string;
73 readonly env?: NodeJS.ProcessEnv;
74 readonly allowNonZeroExit?: boolean;
75 readonly timeoutMs?: number;
76 readonly maxOutputBytes?: number;
77 readonly truncateOutputAtMaxBytes?: boolean;
78 },
79) =>
80 process.run({
81 operation,
82 command,
83 args,
84 cwd,
85 ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}),
86 ...(options?.env !== undefined ? { env: options.env } : {}),
87 ...(options?.allowNonZeroExit !== undefined
88 ? { allowNonZeroExit: options.allowNonZeroExit }
89 : {}),
90 ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
91 ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
92 ...(options?.truncateOutputAtMaxBytes !== undefined
93 ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes }
94 : {}),
95 });
96
97const jjCommand = (
98 process: VcsProcessShape,
99 operation: string,
100 cwd: string,
101 args: ReadonlyArray<string>,
102 options?: {
103 readonly stdin?: string;
104 readonly env?: NodeJS.ProcessEnv;
105 readonly allowNonZeroExit?: boolean;
106 readonly timeoutMs?: number;
107 readonly maxOutputBytes?: number;
108 readonly truncateOutputAtMaxBytes?: boolean;
109 },
110) => processCommand(process, "jj", operation, cwd, ["--no-pager", ...args], options);
111
112const gitCommand = (
113 process: VcsProcessShape,
114 operation: string,
115 cwd: string,
116 args: ReadonlyArray<string>,
117 options?: Parameters<typeof processCommand>[5],
118) => processCommand(process, "git", operation, cwd, args, options);
119
120function tempDirError(operation: string, cwd: string, detail: string, cause: unknown) {
121 return new VcsOutputDecodeError({
122 operation,
123 command: "git check-ignore",
124 cwd,
125 detail,
126 cause,
127 });
128}
129
130const makeTempGitDir = (operation: string, cwd: string) =>
131 Effect.tryPromise({
132 try: () => mkdtemp(join(tmpdir(), "t3-jj-check-ignore-")),
133 catch: (cause) => tempDirError(operation, cwd, "failed to create temp git dir", cause),
134 });
135
136const removeTempGitDir = (gitDir: string) =>
137 Effect.promise(() => rm(gitDir, { force: true, recursive: true })).pipe(Effect.ignore);
138
139export const makeVcsDriverShape = Effect.fn("makeJjVcsDriverShape")(function* () {
140 const process = yield* VcsProcess;
141 const capabilities = {
142 kind: "jj" as const,
143 supportsWorktrees: true,
144 supportsBookmarks: true,
145 supportsAtomicSnapshot: true,
146 supportsPushDefaultRemote: false,
147 };
148
149 const isInsideWorkTree: VcsDriverShape["isInsideWorkTree"] = (cwd) =>
150 jjCommand(process, "JjVcsDriver.isInsideWorkTree", cwd, ["root"], {
151 allowNonZeroExit: true,
152 timeoutMs: 5_000,
153 maxOutputBytes: 4_096,
154 }).pipe(
155 Effect.map((result) => result.exitCode === 0 && result.stdout.trim().length > 0),
156 Effect.catch(() => Effect.succeed(false)),
157 );
158
159 const execute: VcsDriverShape["execute"] = (input) =>
160 jjCommand(process, input.operation, input.cwd, input.args, {
161 ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
162 ...(input.env !== undefined ? { env: input.env } : {}),
163 ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}),
164 ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
165 ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}),
166 ...(input.truncateOutputAtMaxBytes !== undefined
167 ? { truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes }
168 : {}),
169 });
170
171 const detectRepository: VcsDriverShape["detectRepository"] = Effect.fn("detectRepository")(
172 function* (cwd) {
173 const root = yield* jjCommand(process, "JjVcsDriver.detectRepository.root", cwd, ["root"], {
174 allowNonZeroExit: true,
175 timeoutMs: 5_000,
176 maxOutputBytes: 4_096,
177 }).pipe(Effect.catch(() => Effect.succeed(null)));
178 if (!root || root.exitCode !== 0) {
179 return null;
180 }
181
182 const rootPath = root.stdout.trim();
183 if (rootPath.length === 0) {
184 return null;
185 }
186
187 return {
188 kind: "jj" as const,
189 rootPath,
190 metadataPath: `${rootPath.replace(/[\\/]$/g, "")}/.jj`,
191 freshness: yield* nowFreshness(),
192 };
193 },
194 );
195
196 const listWorkspaceFiles: VcsDriverShape["listWorkspaceFiles"] = (cwd) =>
197 jjCommand(process, "JjVcsDriver.listWorkspaceFiles", cwd, ["file", "list"], {
198 allowNonZeroExit: true,
199 timeoutMs: 20_000,
200 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
201 truncateOutputAtMaxBytes: true,
202 }).pipe(
203 Effect.flatMap((result) =>
204 result.exitCode === 0
205 ? Effect.gen(function* () {
206 return {
207 paths: splitLineSeparatedPaths(result.stdout, result.stdoutTruncated),
208 truncated: result.stdoutTruncated,
209 freshness: yield* nowFreshness(),
210 };
211 })
212 : Effect.fail(
213 new VcsProcessExitError({
214 operation: "JjVcsDriver.listWorkspaceFiles",
215 command: "jj file list",
216 cwd,
217 exitCode: result.exitCode,
218 detail: result.stderr.trim() || "jj file list failed",
219 }),
220 ),
221 ),
222 );
223
224 const filterIgnoredPaths: VcsDriverShape["filterIgnoredPaths"] = Effect.fn("filterIgnoredPaths")(
225 function* (cwd, relativePaths) {
226 if (relativePaths.length === 0) {
227 return relativePaths;
228 }
229
230 const operation = "JjVcsDriver.filterIgnoredPaths";
231 const ignoredPaths = new Set<string>();
232 yield* Effect.acquireUseRelease(
233 makeTempGitDir(operation, cwd),
234 Effect.fn("JjVcsDriver.filterIgnoredPaths.withTempGitDir")(function* (gitDir) {
235 const initResult = yield* gitCommand(process, operation, cwd, [
236 "--git-dir",
237 gitDir,
238 "init",
239 "--bare",
240 ]);
241 if (initResult.exitCode !== 0) {
242 return yield* new VcsProcessExitError({
243 operation,
244 command: "git init --bare",
245 cwd,
246 exitCode: initResult.exitCode,
247 detail: initResult.stderr.trim() || "git init --bare failed",
248 });
249 }
250
251 for (const chunk of chunkPathsForCheckIgnore(relativePaths)) {
252 const result = yield* gitCommand(
253 process,
254 operation,
255 cwd,
256 [
257 "--git-dir",
258 gitDir,
259 "--work-tree",
260 cwd,
261 "check-ignore",
262 "--no-index",
263 "-z",
264 "--stdin",
265 ],
266 {
267 stdin: `${chunk.join("\0")}\0`,
268 allowNonZeroExit: true,
269 timeoutMs: 20_000,
270 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
271 truncateOutputAtMaxBytes: true,
272 },
273 );
274
275 if (result.exitCode !== 0 && result.exitCode !== 1) {
276 return yield* new VcsProcessExitError({
277 operation,
278 command: "git check-ignore",
279 cwd,
280 exitCode: result.exitCode,
281 detail: result.stderr.trim() || "git check-ignore failed",
282 });
283 }
284
285 for (const ignoredPath of splitNullSeparatedPaths(
286 result.stdout,
287 result.stdoutTruncated,
288 )) {
289 ignoredPaths.add(ignoredPath);
290 }
291 }
292 }),
293 removeTempGitDir,
294 );
295
296 if (ignoredPaths.size === 0) {
297 return relativePaths;
298 }
299
300 return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));
301 },
302 );
303
304 return {
305 capabilities,
306 execute,
307 detectRepository,
308 isInsideWorkTree,
309 listWorkspaceFiles,
310 filterIgnoredPaths,
311 } satisfies VcsDriverShape;
312});
313
314export const makeVcsDriver = Effect.fn("makeJjVcsDriver")(function* () {
315 const driver = yield* makeVcsDriverShape();
316 return VcsDriver.of(driver);
317});
318
319export const vcsLayer = Layer.effect(VcsDriver, makeVcsDriver());
apps/server/src/vcs/VcsDriverRegistry.test.ts+78−3
@@ -7,10 +7,13 @@ import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "./VcsPr · 6 unmodified lines
7import { VcsProjectConfig } from "./VcsProjectConfig.ts";7import { VcsProjectConfig } from "./VcsProjectConfig.ts";
8import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts";8import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts";
9 9
10const processOutput = (stdout: string): VcsProcessOutput => ({10const commandCalls = (calls: ReadonlyArray<VcsProcessInput>) =>
11 exitCode: ChildProcessSpawner.ExitCode(0),11 calls.map((call) => [call.command].concat(call.args));
12
13const processOutput = (stdout: string, exitCode = 0, stderr = ""): VcsProcessOutput => ({
14 exitCode: ChildProcessSpawner.ExitCode(exitCode),
12 stdout,15 stdout,
13 stderr: "",16 stderr,
14 stdoutTruncated: false,17 stdoutTruncated: false,
15 stderrTruncated: false,18 stderrTruncated: false,
16});19});
@@ -62,4 +65,76 @@ describe("VcsDriverRegistry", () => { · 45 unmodified lines
62 );65 );
63 }).pipe(Effect.provide(layer));66 }).pipe(Effect.provide(layer));
64 });67 });
68
69 it.effect("resolves an explicitly requested jj repository", () => {
70 const calls: VcsProcessInput[] = [];
71 const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe(
72 Layer.provide(
73 Layer.mock(VcsProjectConfig)({
74 resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"),
75 }),
76 ),
77 Layer.provide(
78 Layer.mock(VcsProcess)({
79 run: (input) =>
80 Effect.sync(() => {
81 calls.push(input);
82 const command = input.args.join(" ");
83 if (input.command === "jj" && command === "--no-pager root") {
84 return processOutput("/repo\n");
85 }
86 return processOutput("", 1, "not found");
87 }),
88 }),
89 ),
90 );
91
92 return Effect.gen(function* () {
93 const registry = yield* VcsDriverRegistry;
94 const handle = yield* registry.resolve({ cwd: "/repo", requestedKind: "jj" });
95
96 assert.equal(handle.kind, "jj");
97 assert.equal(handle.repository.rootPath, "/repo");
98 assert.deepStrictEqual(commandCalls(calls), [["jj", "--no-pager", "root"]]);
99 }).pipe(Effect.provide(layer));
100 });
101
102 it.effect("auto-detects jj after git misses", () => {
103 const calls: VcsProcessInput[] = [];
104 const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe(
105 Layer.provide(
106 Layer.mock(VcsProjectConfig)({
107 resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"),
108 }),
109 ),
110 Layer.provide(
111 Layer.mock(VcsProcess)({
112 run: (input) =>
113 Effect.sync(() => {
114 calls.push(input);
115 const command = input.args.join(" ");
116 if (input.command === "git" && command === "rev-parse --is-inside-work-tree") {
117 return processOutput("", 1, "not a git repository");
118 }
119 if (input.command === "jj" && command === "--no-pager root") {
120 return processOutput("/repo\n");
121 }
122 return processOutput("");
123 }),
124 }),
125 ),
126 );
127
128 return Effect.gen(function* () {
129 const registry = yield* VcsDriverRegistry;
130 const handle = yield* registry.resolve({ cwd: "/repo" });
131
132 assert.equal(handle.kind, "jj");
133 assert.equal(handle.repository.rootPath, "/repo");
134 assert.deepStrictEqual(commandCalls(calls), [
135 ["git", "rev-parse", "--is-inside-work-tree"],
136 ["jj", "--no-pager", "root"],
137 ]);
138 }).pipe(Effect.provide(layer));
139 });
65});140});
apps/server/src/vcs/VcsDriverRegistry.ts+9−1
@@ -3,6 +3,7 @@ import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; · 2 unmodified lines
3import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts";3import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts";
4import { VcsUnsupportedOperationError } from "@t3tools/contracts";4import { VcsUnsupportedOperationError } from "@t3tools/contracts";
5import * as GitVcsDriver from "./GitVcsDriver.ts";5import * as GitVcsDriver from "./GitVcsDriver.ts";
6import * as JjVcsDriver from "./JjVcsDriver.ts";
6import * as VcsProjectConfig from "./VcsProjectConfig.ts";7import * as VcsProjectConfig from "./VcsProjectConfig.ts";
7import type { VcsDriverShape } from "./VcsDriver.ts";8import type { VcsDriverShape } from "./VcsDriver.ts";
8 9
@@ -65,8 +66,10 @@ function parseDetectionCacheKey(key: string): { · 56 unmodified lines
65export const make = Effect.fn("makeVcsDriverRegistry")(function* () {66export const make = Effect.fn("makeVcsDriverRegistry")(function* () {
66 const projectConfig = yield* VcsProjectConfig.VcsProjectConfig;67 const projectConfig = yield* VcsProjectConfig.VcsProjectConfig;
67 const git = yield* GitVcsDriver.makeVcsDriverShape();68 const git = yield* GitVcsDriver.makeVcsDriverShape();
69 const jj = yield* JjVcsDriver.makeVcsDriverShape();
68 const drivers: Partial<Record<VcsDriverKind, VcsDriverShape>> = {70 const drivers: Partial<Record<VcsDriverKind, VcsDriverShape>> = {
69 git,71 git,
72 jj,
70 };73 };
71 74
72 const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* (75 const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* (
@@ -103,7 +106,12 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { · 30 unmodified lines
103 return yield* detectWithDriver(requestedKind, driver, input.cwd);106 return yield* detectWithDriver(requestedKind, driver, input.cwd);
104 }107 }
105 108
106 return yield* detectWithDriver("git", git, input.cwd);109 const gitDetected = yield* detectWithDriver("git", git, input.cwd);
110 if (gitDetected) {
111 return gitDetected;
112 }
113
114 return yield* detectWithDriver("jj", jj, input.cwd);
107 });115 });
108 116
109 const detectionCache = yield* Cache.makeWith<string, VcsDriverHandle | null, VcsError>(117 const detectionCache = yield* Cache.makeWith<string, VcsDriverHandle | null, VcsError>(
apps/server/src/vcs/VcsFreshness.ts+10−0
@@ -0,0 +1,10 @@
1import { DateTime, Effect, Option } from "effect";
2
3export const nowFreshness = Effect.fn("VcsFreshness.nowFreshness")(function* () {
4 const now = yield* DateTime.now;
5 return {
6 source: "live-local" as const,
7 observedAt: now,
8 expiresAt: Option.none(),
9 };
10});
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+1−0
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
16import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts";16import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts";
17 17
18const baseLocalStatus: VcsStatusLocalResult = {18const baseLocalStatus: VcsStatusLocalResult = {
19 kind: "git",
19 isRepo: true,20 isRepo: true,
20 sourceControlProvider: {21 sourceControlProvider: {
21 kind: "github",22 kind: "github",
apps/server/src/vcs/testing/VcsDriverContractHarness.ts+3−1
@@ -56,9 +56,11 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp · 55 unmodified lines
56 56
57 yield* input.fixture.createRepo(cwd);57 yield* input.fixture.createRepo(cwd);
58 yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n");58 yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n");
59 const fileSystem = yield* FileSystem.FileSystem;
60 const realCwd = yield* fileSystem.realPath(cwd);
59 const identity = yield* driver.detectRepository(cwd);61 const identity = yield* driver.detectRepository(cwd);
60 assert.equal(identity?.kind, input.kind);62 assert.equal(identity?.kind, input.kind);
61 assert.isTrue(identity?.rootPath.endsWith(cwd));63 assert.isTrue(identity?.rootPath.endsWith(realCwd));
62 assert.equal(identity?.freshness.source, "live-local");64 assert.equal(identity?.freshness.source, "live-local");
63 assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt));65 assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt));
64 assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none()));66 assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none()));
apps/web/src/components/BranchToolbarBranchSelector.tsx+14−8
@@ -23,6 +23,7 @@ import { cn } from "../lib/utils"; · 22 unmodified lines
23import { parsePullRequestReference } from "../pullRequestReference";23import { parsePullRequestReference } from "../pullRequestReference";
24import { useStore } from "../store";24import { useStore } from "../store";
25import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";25import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
26import { resolveVcsTerms } from "../vcsPresentation";
26import {27import {
27 deriveLocalBranchNameFromRemoteRef,28 deriveLocalBranchNameFromRemoteRef,
28 resolveBranchSelectionTarget,29 resolveBranchSelectionTarget,
@@ -66,10 +67,11 @@ function getBranchTriggerLabel(input: { · 37 unmodified lines
66 activeWorktreePath: string | null;67 activeWorktreePath: string | null;
67 effectiveEnvMode: "local" | "worktree";68 effectiveEnvMode: "local" | "worktree";
68 resolvedActiveBranch: string | null;69 resolvedActiveBranch: string | null;
70 refNoun: string;
69}): string {71}): string {
70 const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input;72 const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, refNoun } = input;
71 if (!resolvedActiveBranch) {73 if (!resolvedActiveBranch) {
72 return "Select ref";74 return `Select ${refNoun}`;
73 }75 }
74 if (effectiveEnvMode === "worktree" && !activeWorktreePath) {76 if (effectiveEnvMode === "worktree" && !activeWorktreePath) {
75 return `From ${resolvedActiveBranch}`;77 return `From ${resolvedActiveBranch}`;
@@ -201,6 +203,7 @@ export function BranchToolbarBranchSelector({ · 125 unmodified lines
201 const deferredBranchQuery = useDeferredValue(branchQuery);203 const deferredBranchQuery = useDeferredValue(branchQuery);
202 204
203 const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd });205 const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd });
206 const vcsTerms = resolveVcsTerms(branchStatusQuery.data?.kind);
204 const trimmedBranchQuery = branchQuery.trim();207 const trimmedBranchQuery = branchQuery.trim();
205 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();208 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
206 209
@@ -289,11 +292,11 @@ export function BranchToolbarBranchSelector({ · 82 unmodified lines
289 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;292 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;
290 const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0;293 const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0;
291 const branchStatusText = isBranchesSearchPending294 const branchStatusText = isBranchesSearchPending
292 ? "Loading refs..."295 ? `Loading ${vcsTerms.refNounPlural}...`
293 : isFetchingNextPage296 : isFetchingNextPage
294 ? "Loading more refs..."297 ? `Loading more ${vcsTerms.refNounPlural}...`
295 : hasNextPage298 : hasNextPage
296 ? `Showing ${refs.length} of ${totalBranchCount} refs`299 ? `Showing ${refs.length} of ${totalBranchCount} ${vcsTerms.refNounPlural}`
297 : null;300 : null;
298 301
299 // ---------------------------------------------------------------------------302 // ---------------------------------------------------------------------------
@@ -357,7 +360,7 @@ export function BranchToolbarBranchSelector({ · 57 unmodified lines
357 toastManager.add(360 toastManager.add(
358 stackedThreadToast({361 stackedThreadToast({
359 type: "error",362 type: "error",
360 title: "Failed to switch ref.",363 title: `Failed to switch ${vcsTerms.refNoun}.`,
361 description: toBranchActionErrorMessage(error),364 description: toBranchActionErrorMessage(error),
362 }),365 }),
363 );366 );
@@ -389,7 +392,7 @@ export function BranchToolbarBranchSelector({ · 25 unmodified lines
389 toastManager.add(392 toastManager.add(
390 stackedThreadToast({393 stackedThreadToast({
391 type: "error",394 type: "error",
392 title: "Failed to create and switch ref.",395 title: `Failed to create and switch ${vcsTerms.refNoun}.`,
393 description: toBranchActionErrorMessage(error),396 description: toBranchActionErrorMessage(error),
394 }),397 }),
395 );398 );
@@ -488,6 +491,7 @@ export function BranchToolbarBranchSelector({ · 92 unmodified lines
488 activeWorktreePath,491 activeWorktreePath,
489 effectiveEnvMode,492 effectiveEnvMode,
490 resolvedActiveBranch,493 resolvedActiveBranch,
494 refNoun: vcsTerms.refNoun,
491 });495 });
492 496
493 function renderPickerItem(itemValue: string, index: number) {497 function renderPickerItem(itemValue: string, index: number) {
@@ -524,7 +528,9 @@ export function BranchToolbarBranchSelector({ · 30 unmodified lines
524 value={itemValue}528 value={itemValue}
525 onClick={() => createRef(trimmedBranchQuery)}529 onClick={() => createRef(trimmedBranchQuery)}
526 >530 >
527 <span className="truncate">Create new ref &quot;{trimmedBranchQuery}&quot;</span>531 <span className="truncate">
532 Create new {vcsTerms.refNoun} &quot;{trimmedBranchQuery}&quot;
533 </span>
528 </ComboboxItem>534 </ComboboxItem>
529 );535 );
530 }536 }
apps/web/src/components/ChatView.tsx+7−5
@@ -1458,7 +1458,8 @@ export default function ChatView(props: ChatViewProps) { · 1457 unmodified lines
1458 ? terminalLaunchContext1458 ? terminalLaunchContext
1459 : (storeServerTerminalLaunchContext ?? null);1459 : (storeServerTerminalLaunchContext ?? null);
1460 // Default true while loading to avoid toolbar flicker.1460 // Default true while loading to avoid toolbar flicker.
1461 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;1461 const vcsKind = gitStatusQuery.data?.kind ?? null;
1462 const isVcsRepo = gitStatusQuery.data?.isRepo ?? true;
1462 const terminalShortcutLabelOptions = useMemo(1463 const terminalShortcutLabelOptions = useMemo(
1463 () => ({1464 () => ({
1464 context: {1465 context: {
@@ -2136,7 +2137,7 @@ export default function ChatView(props: ChatViewProps) { · 671 unmodified lines
2136 : (activeThread?.branch ?? null);2137 : (activeThread?.branch ?? null);
2137 const sendEnvMode = resolveSendEnvMode({2138 const sendEnvMode = resolveSendEnvMode({
2138 requestedEnvMode: envMode,2139 requestedEnvMode: envMode,
2139 isGitRepo,2140 isGitRepo: isVcsRepo,
2140 });2141 });
2141 2142
2142 useEffect(() => {2143 useEffect(() => {
@@ -3297,7 +3298,8 @@ export default function ChatView(props: ChatViewProps) { · 1154 unmodified lines
3297 {...(routeKind === "draft" && draftId ? { draftId } : {})}3298 {...(routeKind === "draft" && draftId ? { draftId } : {})}
3298 activeThreadTitle={activeThread.title}3299 activeThreadTitle={activeThread.title}
3299 activeProjectName={activeProject?.name}3300 activeProjectName={activeProject?.name}
3300 isGitRepo={isGitRepo}3301 isVcsRepo={isVcsRepo}
3302 vcsKind={vcsKind}
3301 openInCwd={gitCwd}3303 openInCwd={gitCwd}
3302 activeProjectScripts={activeProject?.scripts}3304 activeProjectScripts={activeProject?.scripts}
3303 preferredScriptId={3305 preferredScriptId={
@@ -3377,7 +3379,7 @@ export default function ChatView(props: ChatViewProps) { · 73 unmodified lines
3377 <div3379 <div
3378 className={cn(3380 className={cn(
3379 "pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-1.5 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-2",3381 "pl-[calc(env(safe-area-inset-left)+0.75rem)] pr-[calc(env(safe-area-inset-right)+0.75rem)] pt-1.5 sm:pl-[calc(env(safe-area-inset-left)+1.25rem)] sm:pr-[calc(env(safe-area-inset-right)+1.25rem)] sm:pt-2",
3380 isGitRepo3382 isVcsRepo
3381 ? "pb-[calc(env(safe-area-inset-bottom)+0.25rem)]"3383 ? "pb-[calc(env(safe-area-inset-bottom)+0.25rem)]"
3382 : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]",3384 : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]",
3383 )}3385 )}
@@ -3450,7 +3452,7 @@ export default function ChatView(props: ChatViewProps) { · 66 unmodified lines
3450 setThreadError={setThreadError}3452 setThreadError={setThreadError}
3451 onExpandImage={onExpandTimelineImage}3453 onExpandImage={onExpandTimelineImage}
3452 />3454 />
3453 {isGitRepo && (3455 {isVcsRepo && (
3454 <BranchToolbar3456 <BranchToolbar
3455 environmentId={activeThread.environmentId}3457 environmentId={activeThread.environmentId}
3456 threadId={activeThread.id}3458 threadId={activeThread.id}
apps/web/src/components/DiffPanel.tsx+6−4
@@ -35,6 +35,7 @@ import { createThreadSelectorByRef } from "../storeSelectors"; · 34 unmodified lines
35import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";35import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
36import { useSettings } from "../hooks/useSettings";36import { useSettings } from "../hooks/useSettings";
37import { formatShortTimestamp } from "../timestampFormat";37import { formatShortTimestamp } from "../timestampFormat";
38import { resolveVcsTerms } from "../vcsPresentation";
38import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";39import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
39import { ToggleGroup, Toggle } from "./ui/toggle-group";40import { ToggleGroup, Toggle } from "./ui/toggle-group";
40 41
@@ -201,7 +202,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 160 unmodified lines
201 environmentId: activeThread?.environmentId ?? null,202 environmentId: activeThread?.environmentId ?? null,
202 cwd: activeCwd ?? null,203 cwd: activeCwd ?? null,
203 });204 });
204 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;205 const isVcsRepo = gitStatusQuery.data?.isRepo ?? true;
206 const vcsTerms = resolveVcsTerms(gitStatusQuery.data?.kind);
205 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =207 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
206 useTurnDiffSummaries(activeThread);208 useTurnDiffSummaries(activeThread);
207 const orderedTurnDiffSummaries = useMemo(209 const orderedTurnDiffSummaries = useMemo(
@@ -278,7 +280,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 70 unmodified lines
278 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,280 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
279 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,281 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
280 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,282 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
281 enabled: isGitRepo,283 enabled: isVcsRepo,
282 }),284 }),
283 );285 );
284 const selectedTurnCheckpointDiff = selectedTurn286 const selectedTurnCheckpointDiff = selectedTurn
@@ -561,9 +563,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 276 unmodified lines
561 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">563 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
562 Select a thread to inspect turn diffs.564 Select a thread to inspect turn diffs.
563 </div>565 </div>
564 ) : !isGitRepo ? (566 ) : !isVcsRepo ? (
565 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">567 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
566 Turn diffs are unavailable because this project is not a git repository.568 Turn diffs are unavailable because this project is not a {vcsTerms.repositoryNoun}.
567 </div>569 </div>
568 ) : orderedTurnDiffSummaries.length === 0 ? (570 ) : orderedTurnDiffSummaries.length === 0 ? (
569 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">571 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
apps/web/src/components/GitActionsControl.browser.tsx+1−0
@@ -113,6 +113,7 @@ vi.mock("~/lib/gitStatusState", () => ({ · 112 unmodified lines
113 resetGitStatusStateForTests: () => undefined,113 resetGitStatusStateForTests: () => undefined,
114 useGitStatus: vi.fn(() => ({114 useGitStatus: vi.fn(() => ({
115 data: {115 data: {
116 kind: "git",
116 isRepo: true,117 isRepo: true,
117 sourceControlProvider: {118 sourceControlProvider: {
118 kind: "github",119 kind: "github",
apps/web/src/components/GitActionsControl.logic.test.ts+1−0
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
13 13
14function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {14function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
15 return {15 return {
16 kind: "git",
16 isRepo: true,17 isRepo: true,
17 hasPrimaryRemote: true,18 hasPrimaryRemote: true,
18 isDefaultRef: false,19 isDefaultRef: false,
apps/web/src/components/GitActionsControl.tsx+25−1
@@ -54,6 +54,7 @@ import { readEnvironmentApi } from "~/environmentApi"; · 53 unmodified lines
54import { readLocalApi } from "~/localApi";54import { readLocalApi } from "~/localApi";
55import { useStore } from "~/store";55import { useStore } from "~/store";
56import { createThreadSelectorByRef } from "~/storeSelectors";56import { createThreadSelectorByRef } from "~/storeSelectors";
57import { resolveVcsActionPresentation, resolveVcsTerms } from "~/vcsPresentation";
57 58
58interface GitActionsControlProps {59interface GitActionsControlProps {
59 gitCwd: string | null;60 gitCwd: string | null;
@@ -324,8 +325,12 @@ export default function GitActionsControl({ · 264 unmodified lines
324 });325 });
325 // Default to true while loading so we don't flash init controls.326 // Default to true while loading so we don't flash init controls.
326 const isRepo = gitStatus?.isRepo ?? true;327 const isRepo = gitStatus?.isRepo ?? true;
328 const vcsKind = gitStatus?.kind ?? null;
329 const vcsTerms = resolveVcsTerms(vcsKind);
330 const vcsActionPresentation = resolveVcsActionPresentation(vcsKind);
331 const supportsGitWorkflowActions = !isRepo || vcsActionPresentation.supportsGitWorkflowActions;
327 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;332 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;
328 const gitStatusForActions = gitStatus;333 const gitStatusForActions = supportsGitWorkflowActions ? gitStatus : null;
329 334
330 const allFiles = gitStatusForActions?.workingTree.files ?? [];335 const allFiles = gitStatusForActions?.workingTree.files ?? [];
331 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));336 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));
@@ -874,6 +879,25 @@ export default function GitActionsControl({ · 542 unmodified lines
874 >879 >
875 {initMutation.isPending ? "Initializing..." : "Initialize Git"}880 {initMutation.isPending ? "Initializing..." : "Initialize Git"}
876 </Button>881 </Button>
882 ) : !supportsGitWorkflowActions ? (
883 <Popover>
884 <PopoverTrigger
885 openOnHover
886 render={
887 <Button
888 aria-disabled="true"
889 className="cursor-not-allowed opacity-70"
890 size="xs"
891 variant="outline"
892 />
893 }
894 >
895 {vcsTerms.systemName} {vcsTerms.repositoryNoun}
896 </PopoverTrigger>
897 <PopoverPopup tooltipStyle side="bottom" align="end">
898 {vcsActionPresentation.unsupportedGitWorkflowDescription}
899 </PopoverPopup>
900 </Popover>
877 ) : (901 ) : (
878 <Group aria-label="Git actions" className="shrink-0">902 <Group aria-label="Git actions" className="shrink-0">
879 {quickActionDisabledReason ? (903 {quickActionDisabledReason ? (
apps/web/src/components/chat/ChatHeader.tsx+12−7
@@ -17,6 +17,8 @@ import { Toggle } from "../ui/toggle"; · 16 unmodified lines
17import { SidebarTrigger } from "../ui/sidebar";17import { SidebarTrigger } from "../ui/sidebar";
18import { OpenInPicker } from "./OpenInPicker";18import { OpenInPicker } from "./OpenInPicker";
19import { usePrimaryEnvironmentId } from "../../environments/primary";19import { usePrimaryEnvironmentId } from "../../environments/primary";
20import { resolveVcsTerms } from "../../vcsPresentation";
21import type { VcsDriverKind } from "@t3tools/contracts";
20 22
21interface ChatHeaderProps {23interface ChatHeaderProps {
22 activeThreadEnvironmentId: EnvironmentId;24 activeThreadEnvironmentId: EnvironmentId;
@@ -24,7 +26,8 @@ interface ChatHeaderProps { · 1 unmodified line
24 draftId?: DraftId;26 draftId?: DraftId;
25 activeThreadTitle: string;27 activeThreadTitle: string;
26 activeProjectName: string | undefined;28 activeProjectName: string | undefined;
27 isGitRepo: boolean;29 isVcsRepo: boolean;
30 vcsKind: VcsDriverKind | null;
28 openInCwd: string | null;31 openInCwd: string | null;
29 activeProjectScripts: ProjectScript[] | undefined;32 activeProjectScripts: ProjectScript[] | undefined;
30 preferredScriptId: string | null;33 preferredScriptId: string | null;
@@ -50,7 +53,8 @@ export const ChatHeader = memo(function ChatHeader({ · 19 unmodified lines
50 draftId,53 draftId,
51 activeThreadTitle,54 activeThreadTitle,
52 activeProjectName,55 activeProjectName,
53 isGitRepo,56 isVcsRepo,
57 vcsKind,
54 openInCwd,58 openInCwd,
55 activeProjectScripts,59 activeProjectScripts,
56 preferredScriptId,60 preferredScriptId,
@@ -72,6 +76,7 @@ export const ChatHeader = memo(function ChatHeader({ · 15 unmodified lines
72 const primaryEnvironmentId = usePrimaryEnvironmentId();76 const primaryEnvironmentId = usePrimaryEnvironmentId();
73 const isRemoteEnvironment =77 const isRemoteEnvironment =
74 primaryEnvironmentId !== null && activeThreadEnvironmentId !== primaryEnvironmentId;78 primaryEnvironmentId !== null && activeThreadEnvironmentId !== primaryEnvironmentId;
79 const vcsTerms = resolveVcsTerms(vcsKind);
75 80
76 return (81 return (
77 <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2">82 <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2">
@@ -88,9 +93,9 @@ export const ChatHeader = memo(function ChatHeader({ · 10 unmodified lines
88 <span className="min-w-0 truncate">{activeProjectName}</span>93 <span className="min-w-0 truncate">{activeProjectName}</span>
89 </Badge>94 </Badge>
90 )}95 )}
91 {activeProjectName && !isGitRepo && (96 {activeProjectName && !isVcsRepo && (
92 <Badge variant="outline" className="shrink-0 text-[10px] text-amber-700">97 <Badge variant="outline" className="shrink-0 text-[10px] text-amber-700">
93 No Git98 No repository
94 </Badge>99 </Badge>
95 )}100 )}
96 </div>101 </div>
@@ -154,15 +159,15 @@ export const ChatHeader = memo(function ChatHeader({ · 57 unmodified lines
154 aria-label="Toggle diff panel"159 aria-label="Toggle diff panel"
155 variant="outline"160 variant="outline"
156 size="xs"161 size="xs"
157 disabled={!isGitRepo && !diffOpen}162 disabled={!isVcsRepo && !diffOpen}
158 >163 >
159 <DiffIcon className="size-3" />164 <DiffIcon className="size-3" />
160 </Toggle>165 </Toggle>
161 }166 }
162 />167 />
163 <TooltipPopup side="bottom">168 <TooltipPopup side="bottom">
164 {!isGitRepo && !diffOpen169 {!isVcsRepo && !diffOpen
165 ? "Diff panel is unavailable because this project is not a git repository."170 ? `Diff panel is unavailable because this project is not a supported ${vcsTerms.repositoryNoun}.`
166 : diffToggleShortcutLabel171 : diffToggleShortcutLabel
167 ? `Toggle diff panel (${diffToggleShortcutLabel})`172 ? `Toggle diff panel (${diffToggleShortcutLabel})`
168 : "Toggle diff panel"}173 : "Toggle diff panel"}
apps/web/src/lib/gitStatusState.test.ts+1−0
@@ -40,6 +40,7 @@ const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const; · 39 unmodified lines
40const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const;40const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const;
41 41
42const BASE_STATUS: VcsStatusResult = {42const BASE_STATUS: VcsStatusResult = {
43 kind: "git",
43 isRepo: true,44 isRepo: true,
44 hasPrimaryRemote: true,45 hasPrimaryRemote: true,
45 isDefaultRef: false,46 isDefaultRef: false,
apps/web/src/lib/providerReactQuery.ts+1−1
@@ -64,7 +64,7 @@ function normalizeCheckpointErrorMessage(error: unknown): string { · 63 unmodified lines
64 64
65 const lower = message.toLowerCase();65 const lower = message.toLowerCase();
66 if (lower.includes("not a git repository")) {66 if (lower.includes("not a git repository")) {
67 return "Turn diffs are unavailable because this project is not a git repository.";67 return "Turn diffs are unavailable because this project is not a repository.";
68 }68 }
69 69
70 if (70 if (
apps/web/src/localApi.test.ts+1−0
@@ -262,6 +262,7 @@ const baseServerConfig: ServerConfig = { · 261 unmodified lines
262};262};
263 263
264const baseGitStatus: VcsStatusResult = {264const baseGitStatus: VcsStatusResult = {
265 kind: "git",
265 isRepo: true,266 isRepo: true,
266 hasPrimaryRemote: true,267 hasPrimaryRemote: true,
267 isDefaultRef: false,268 isDefaultRef: false,
apps/web/src/rpc/wsRpcClient.test.ts+1−0
@@ -19,6 +19,7 @@ import { createWsRpcClient } from "./wsRpcClient"; · 18 unmodified lines
19import { type WsTransport } from "./wsTransport";19import { type WsTransport } from "./wsTransport";
20 20
21const baseLocalStatus: VcsStatusLocalResult = {21const baseLocalStatus: VcsStatusLocalResult = {
22 kind: "git",
22 isRepo: true,23 isRepo: true,
23 hasPrimaryRemote: true,24 hasPrimaryRemote: true,
24 isDefaultRef: false,25 isDefaultRef: false,
apps/web/src/vcsPresentation.ts+67−0
@@ -0,0 +1,67 @@
1import type { VcsDriverKind } from "@t3tools/contracts";
2
3export interface VcsTerms {
4 readonly systemName: string;
5 readonly repositoryNoun: string;
6 readonly refNoun: string;
7 readonly refNounPlural: string;
8 readonly currentRefFallback: string;
9}
10
11export interface VcsActionPresentation {
12 readonly supportsGitWorkflowActions: boolean;
13 readonly unsupportedGitWorkflowDescription: string;
14}
15
16const gitLikeTerms: VcsTerms = {
17 systemName: "Git",
18 repositoryNoun: "repository",
19 refNoun: "branch",
20 refNounPlural: "branches",
21 currentRefFallback: "checkout",
22};
23
24const unknownTerms: VcsTerms = {
25 systemName: "VCS",
26 repositoryNoun: "repository",
27 refNoun: "ref",
28 refNounPlural: "refs",
29 currentRefFallback: "checkout",
30};
31
32export function resolveVcsTerms(kind: VcsDriverKind | null | undefined): VcsTerms {
33 if (kind === "jj") {
34 return {
35 systemName: "JJ",
36 repositoryNoun: "workspace",
37 refNoun: "bookmark",
38 refNounPlural: "bookmarks",
39 currentRefFallback: "working copy",
40 };
41 }
42
43 if (kind === "sapling") {
44 return {
45 ...gitLikeTerms,
46 systemName: "Sapling",
47 };
48 }
49
50 if (kind === "git") {
51 return gitLikeTerms;
52 }
53
54 return unknownTerms;
55}
56
57export function resolveVcsActionPresentation(
58 kind: VcsDriverKind | null | undefined,
59): VcsActionPresentation {
60 const terms = resolveVcsTerms(kind);
61 const supportsGitWorkflowActions = kind === undefined || kind === null || kind === "git";
62
63 return {
64 supportsGitWorkflowActions,
65 unsupportedGitWorkflowDescription: `Git commit, push, and PR actions are not available for this ${terms.systemName} ${terms.repositoryNoun}.`,
66 };
67}
packages/contracts/src/git.ts+2−0
@@ -1,6 +1,7 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";2import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
3import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts";3import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts";
4import { VcsDriverKind } from "./vcs.ts";
4 5
5const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;6const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
6const GIT_LIST_BRANCHES_MAX_LIMIT = 200;7const GIT_LIST_BRANCHES_MAX_LIMIT = 200;
@@ -194,6 +195,7 @@ const VcsStatusChangeRequest = Schema.Struct({ · 187 unmodified lines
194});195});
195 196
196const VcsStatusLocalShape = {197const VcsStatusLocalShape = {
198 kind: VcsDriverKind,
197 isRepo: Schema.Boolean,199 isRepo: Schema.Boolean,
198 sourceControlProvider: Schema.optional(SourceControlProviderInfo),200 sourceControlProvider: Schema.optional(SourceControlProviderInfo),
199 hasPrimaryRemote: Schema.Boolean,201 hasPrimaryRemote: Schema.Boolean,
packages/shared/src/git.test.ts+2−0
@@ -81,6 +81,7 @@ describe("applyGitStatusStreamEvent", () => { · 80 unmodified lines
81 };81 };
82 82
83 expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({83 expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({
84 kind: "unknown",
84 isRepo: true,85 isRepo: true,
85 hasPrimaryRemote: false,86 hasPrimaryRemote: false,
86 isDefaultRef: false,87 isDefaultRef: false,
@@ -96,6 +97,7 @@ describe("applyGitStatusStreamEvent", () => { · 9 unmodified lines
96 97
97 it("preserves local-only fields when applying a remote update", () => {98 it("preserves local-only fields when applying a remote update", () => {
98 const current: VcsStatusResult = {99 const current: VcsStatusResult = {
100 kind: "git",
99 isRepo: true,101 isRepo: true,
100 sourceControlProvider: {102 sourceControlProvider: {
101 kind: "github",103 kind: "github",
packages/shared/src/git.ts+2−0
@@ -226,6 +226,7 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { · 225 unmodified lines
226 226
227function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult {227function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult {
228 return {228 return {
229 kind: status.kind,
229 isRepo: status.isRepo,230 isRepo: status.isRepo,
230 ...(status.sourceControlProvider231 ...(status.sourceControlProvider
231 ? { sourceControlProvider: status.sourceControlProvider }232 ? { sourceControlProvider: status.sourceControlProvider }
@@ -251,6 +252,7 @@ export function applyGitStatusStreamEvent( · 19 unmodified lines
251 if (current === null) {252 if (current === null) {
252 return mergeGitStatusParts(253 return mergeGitStatusParts(
253 {254 {
255 kind: "unknown",
254 isRepo: true,256 isRepo: true,
255 hasPrimaryRemote: false,257 hasPrimaryRemote: false,
256 isDefaultRef: false,258 isDefaultRef: false,

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t