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
331331 refreshLocalStatus: () =>
332332 Effect.succeed({
333333 isRepo: true,
334+ kind: "git",
334335 hasPrimaryRemote: false,
335336 isDefaultRef: true,
336337 refName: "main",
apps/server/src/git/GitManager.ts+1−0
@@ -704,6 +704,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 703 unmodified lines
704704 : null;
705705
706706 return {
707+ kind: details.isRepo ? ("git" as const) : ("unknown" as const),
707708 isRepo: details.isRepo,
708709 ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}),
709710 hasPrimaryRemote: details.hasOriginRemote,
apps/server/src/git/GitWorkflowService.ts+67−3
@@ -3,6 +3,7 @@ import { Context, Effect, Layer } from "effect"; · 2 unmodified lines
33 import {
44 GitManagerError,
55 GitCommandError,
6+ type VcsDriverKind,
67 type VcsSwitchRefInput,
78 type VcsSwitchRefResult,
89 type VcsCreateRefInput,
@@ -29,6 +30,7 @@ import { · 20 unmodified lines
2930 import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts";
3031 import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";
3132 import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
33+import { mergeGitStatusParts } from "@t3tools/shared/git";
3234
3335 export interface GitWorkflowServiceShape {
3436 readonly status: (
@@ -92,6 +94,18 @@ const unsupportedGitCommand = (operation: string, cwd: string, detail: string) = · 57 unmodified lines
9294 detail,
9395 });
9496
97+const emptyWorkingTree = { files: [], insertions: 0, deletions: 0 } as const;
98+
99+const 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+
95109 export const make = Effect.fn("makeGitWorkflowService")(function* () {
96110 const registry = yield* VcsDriverRegistry;
97111 const git = yield* GitVcsDriver;
@@ -153,10 +167,60 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { · 55 unmodified lines
153167 (input: Input) =>
154168 ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input)));
155169
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+
156220 return GitWorkflowService.of({
157− status: routeGitManager("GitWorkflowService.status", gitManager.status),
158− localStatus: routeGitManager("GitWorkflowService.localStatus", gitManager.localStatus),
159− remoteStatus: routeGitManager("GitWorkflowService.remoteStatus", gitManager.remoteStatus),
221+ status,
222+ localStatus,
223+ remoteStatus,
160224 invalidateLocalStatus: gitManager.invalidateLocalStatus,
161225 invalidateRemoteStatus: gitManager.invalidateRemoteStatus,
162226 invalidateStatus: gitManager.invalidateStatus,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+1−0
@@ -291,6 +291,7 @@ describe("CheckpointReactor", () => { · 290 unmodified lines
291291 }).pipe(
292292 Effect.as({
293293 isRepo: true,
294+ kind: "git",
294295 hasPrimaryRemote: false,
295296 isDefaultRef: true,
296297 refName: "main",
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+1−0
@@ -240,6 +240,7 @@ describe("ProviderCommandReactor", () => { · 239 unmodified lines
240240 const refreshStatus = vi.fn((_: string) =>
241241 Effect.succeed({
242242 isRepo: true,
243+ kind: "git" as const,
243244 hasPrimaryRemote: true,
244245 isDefaultRef: false,
245246 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
23732373 localStatus: () =>
23742374 Effect.succeed({
23752375 isRepo: true,
2376+ kind: "git",
23762377 hasPrimaryRemote: true,
23772378 isDefaultRef: true,
23782379 refName: "main",
@@ -2389,6 +2390,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 10 unmodified lines
23892390 status: () =>
23902391 Effect.succeed({
23912392 isRepo: true,
2393+ kind: "git",
23922394 hasPrimaryRemote: true,
23932395 isDefaultRef: true,
23942396 refName: "main",
@@ -2643,6 +2645,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 248 unmodified lines
26432645 localStatus: () =>
26442646 Effect.succeed({
26452647 isRepo: true,
2648+ kind: "git",
26462649 hasPrimaryRemote: true,
26472650 isDefaultRef: true,
26482651 refName: "main",
@@ -2664,6 +2667,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 15 unmodified lines
26642667 statusCalls += 1;
26652668 return {
26662669 isRepo: true,
2670+ kind: "git",
26672671 hasPrimaryRemote: true,
26682672 isDefaultRef: true,
26692673 refName: "main",
@@ -2720,6 +2724,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 50 unmodified lines
27202724 localStatus: () =>
27212725 Effect.succeed({
27222726 isRepo: true,
2727+ kind: "git",
27232728 hasPrimaryRemote: true,
27242729 isDefaultRef: false,
27252730 refName: "feature/demo",
@@ -2741,6 +2746,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 15 unmodified lines
27412746 statusCalls += 1;
27422747 return {
27432748 isRepo: true,
2749+ kind: "git",
27442750 hasPrimaryRemote: true,
27452751 isDefaultRef: false,
27462752 refName: "feature/demo",
@@ -2792,6 +2798,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 45 unmodified lines
27922798 localStatus: () =>
27932799 Effect.succeed({
27942800 isRepo: true,
2801+ kind: "git",
27952802 hasPrimaryRemote: true,
27962803 isDefaultRef: true,
27972804 refName: "main",
@@ -2835,6 +2842,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 37 unmodified lines
28352842 localStatus: () =>
28362843 Effect.succeed({
28372844 isRepo: true,
2845+ kind: "git",
28382846 hasPrimaryRemote: true,
28392847 isDefaultRef: false,
28402848 refName: "feature/demo",
@@ -2911,6 +2919,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 70 unmodified lines
29112919 Effect.andThen(
29122920 Effect.succeed({
29132921 isRepo: true,
2922+ kind: "git" as const,
29142923 hasPrimaryRemote: true,
29152924 isDefaultRef: false,
29162925 refName: "feature/demo",
@@ -3577,6 +3586,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 660 unmodified lines
35773586 const refreshStatus = vi.fn((_: string) =>
35783587 Effect.succeed({
35793588 isRepo: true,
3589+ kind: "git" as const,
35803590 hasPrimaryRemote: true,
35813591 isDefaultRef: false,
35823592 refName: "t3code/bootstrap-refName",
apps/server/src/vcs/GitVcsDriver.ts+2−10
@@ -1,4 +1,4 @@
1−import { Context, DateTime, Effect, Layer, Option } from "effect";
1+import { Context, Effect, Layer } from "effect";
22 import { ChildProcessSpawner } from "effect/unstable/process";
33
44 import {
@@ -20,6 +20,7 @@ import { · 15 unmodified lines
2020 } from "@t3tools/contracts";
2121 import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts";
2222 import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts";
23+import { nowFreshness } from "./VcsFreshness.ts";
2324 import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts";
2425
2526 export interface ExecuteGitInput {
@@ -209,15 +210,6 @@ const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ · 183 unmodified lines
209210 "core.untrackedCache=false",
210211 ] as const;
211212
212−const 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−
221213 function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
222214 const parts = input.split("\0");
223215 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
13141314 const status: GitVcsDriverShape["status"] = (input) =>
13151315 statusDetails(input.cwd).pipe(
13161316 Effect.map((details) => ({
1317+ kind: "git" as const,
13171318 isRepo: details.isRepo,
13181319 hasPrimaryRemote: details.hasOriginRemote,
13191320 isDefaultRef: details.isDefaultBranch,
apps/server/src/vcs/JjVcsDriver.test.ts+191−0
@@ -0,0 +1,191 @@
1+import { spawnSync } from "node:child_process";
2+
3+import * as NodeServices from "@effect/platform-node/NodeServices";
4+import { assert, it } from "@effect/vitest";
5+import { Effect, FileSystem, Layer, Path, PlatformError } from "effect";
6+import { ChildProcessSpawner } from "effect/unstable/process";
7+import { describe } from "vitest";
8+
9+import type { VcsError } from "@t3tools/contracts";
10+import type { VcsProcessInput, VcsProcessOutput } from "./VcsProcess.ts";
11+import { VcsProcess, layer as VcsProcessLayer } from "./VcsProcess.ts";
12+import * as JjVcsDriver from "./JjVcsDriver.ts";
13+import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts";
14+
15+const JjContractLayer = JjVcsDriver.vcsLayer.pipe(
16+ Layer.provideMerge(VcsProcessLayer),
17+ Layer.provideMerge(NodeServices.layer),
18+);
19+
20+const commandCalls = (calls: ReadonlyArray<VcsProcessInput>) =>
21+ calls.map((call) => [call.command].concat(call.args));
22+
23+const processOutput = (stdout: string, exitCode = 0, stderr = ""): VcsProcessOutput => ({
24+ exitCode: ChildProcessSpawner.ExitCode(exitCode),
25+ stdout,
26+ stderr,
27+ stdoutTruncated: false,
28+ stderrTruncated: false,
29+});
30+
31+const hasJj = spawnSync("jj", ["--version"], { stdio: "ignore" }).status === 0;
32+
33+const 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+
45+type JjContractError = PlatformError.PlatformError | VcsError;
46+
47+if (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+
76+describe("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 @@
1+import { mkdtemp, rm } from "node:fs/promises";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+
5+import { Effect, Layer } from "effect";
6+
7+import { VcsOutputDecodeError, VcsProcessExitError } from "@t3tools/contracts";
8+import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts";
9+import { nowFreshness } from "./VcsFreshness.ts";
10+import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts";
11+
12+const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
13+const CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
14+
15+function 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+
26+function 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+
35+function 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+
65+const 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+
97+const 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+
112+const 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+
120+function 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+
130+const 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+
136+const removeTempGitDir = (gitDir: string) =>
137+ Effect.promise(() => rm(gitDir, { force: true, recursive: true })).pipe(Effect.ignore);
138+
139+export 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+
314+export const makeVcsDriver = Effect.fn("makeJjVcsDriver")(function* () {
315+ const driver = yield* makeVcsDriverShape();
316+ return VcsDriver.of(driver);
317+});
318+
319+export 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
77 import { VcsProjectConfig } from "./VcsProjectConfig.ts";
88 import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts";
99
10−const processOutput = (stdout: string): VcsProcessOutput => ({
11− exitCode: ChildProcessSpawner.ExitCode(0),
10+const commandCalls = (calls: ReadonlyArray<VcsProcessInput>) =>
11+ calls.map((call) => [call.command].concat(call.args));
12+
13+const processOutput = (stdout: string, exitCode = 0, stderr = ""): VcsProcessOutput => ({
14+ exitCode: ChildProcessSpawner.ExitCode(exitCode),
1215 stdout,
13− stderr: "",
16+ stderr,
1417 stdoutTruncated: false,
1518 stderrTruncated: false,
1619 });
@@ -62,4 +65,76 @@ describe("VcsDriverRegistry", () => { · 45 unmodified lines
6265 );
6366 }).pipe(Effect.provide(layer));
6467 });
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+ });
65140 });
apps/server/src/vcs/VcsDriverRegistry.ts+9−1
@@ -3,6 +3,7 @@ import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; · 2 unmodified lines
33 import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts";
44 import { VcsUnsupportedOperationError } from "@t3tools/contracts";
55 import * as GitVcsDriver from "./GitVcsDriver.ts";
6+import * as JjVcsDriver from "./JjVcsDriver.ts";
67 import * as VcsProjectConfig from "./VcsProjectConfig.ts";
78 import type { VcsDriverShape } from "./VcsDriver.ts";
89
@@ -65,8 +66,10 @@ function parseDetectionCacheKey(key: string): { · 56 unmodified lines
6566 export const make = Effect.fn("makeVcsDriverRegistry")(function* () {
6667 const projectConfig = yield* VcsProjectConfig.VcsProjectConfig;
6768 const git = yield* GitVcsDriver.makeVcsDriverShape();
69+ const jj = yield* JjVcsDriver.makeVcsDriverShape();
6870 const drivers: Partial<Record<VcsDriverKind, VcsDriverShape>> = {
6971 git,
72+ jj,
7073 };
7174
7275 const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* (
@@ -103,7 +106,12 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { · 30 unmodified lines
103106 return yield* detectWithDriver(requestedKind, driver, input.cwd);
104107 }
105108
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);
107115 });
108116
109117 const detectionCache = yield* Cache.makeWith<string, VcsDriverHandle | null, VcsError>(
apps/server/src/vcs/VcsFreshness.ts+10−0
@@ -0,0 +1,10 @@
1+import { DateTime, Effect, Option } from "effect";
2+
3+export 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
1616 import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts";
1717
1818 const baseLocalStatus: VcsStatusLocalResult = {
19+ kind: "git",
1920 isRepo: true,
2021 sourceControlProvider: {
2122 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
5656
5757 yield* input.fixture.createRepo(cwd);
5858 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);
5961 const identity = yield* driver.detectRepository(cwd);
6062 assert.equal(identity?.kind, input.kind);
61− assert.isTrue(identity?.rootPath.endsWith(cwd));
63+ assert.isTrue(identity?.rootPath.endsWith(realCwd));
6264 assert.equal(identity?.freshness.source, "live-local");
6365 assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt));
6466 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
2323 import { parsePullRequestReference } from "../pullRequestReference";
2424 import { useStore } from "../store";
2525 import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
26+import { resolveVcsTerms } from "../vcsPresentation";
2627 import {
2728 deriveLocalBranchNameFromRemoteRef,
2829 resolveBranchSelectionTarget,
@@ -66,10 +67,11 @@ function getBranchTriggerLabel(input: { · 37 unmodified lines
6667 activeWorktreePath: string | null;
6768 effectiveEnvMode: "local" | "worktree";
6869 resolvedActiveBranch: string | null;
70+ refNoun: string;
6971 }): string {
70− const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input;
72+ const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, refNoun } = input;
7173 if (!resolvedActiveBranch) {
72− return "Select ref";
74+ return `Select ${refNoun}`;
7375 }
7476 if (effectiveEnvMode === "worktree" && !activeWorktreePath) {
7577 return `From ${resolvedActiveBranch}`;
@@ -201,6 +203,7 @@ export function BranchToolbarBranchSelector({ · 125 unmodified lines
201203 const deferredBranchQuery = useDeferredValue(branchQuery);
202204
203205 const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd });
206+ const vcsTerms = resolveVcsTerms(branchStatusQuery.data?.kind);
204207 const trimmedBranchQuery = branchQuery.trim();
205208 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
206209
@@ -289,11 +292,11 @@ export function BranchToolbarBranchSelector({ · 82 unmodified lines
289292 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;
290293 const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0;
291294 const branchStatusText = isBranchesSearchPending
292− ? "Loading refs..."
295+ ? `Loading ${vcsTerms.refNounPlural}...`
293296 : isFetchingNextPage
294− ? "Loading more refs..."
297+ ? `Loading more ${vcsTerms.refNounPlural}...`
295298 : hasNextPage
296− ? `Showing ${refs.length} of ${totalBranchCount} refs`
299+ ? `Showing ${refs.length} of ${totalBranchCount} ${vcsTerms.refNounPlural}`
297300 : null;
298301
299302 // ---------------------------------------------------------------------------
@@ -357,7 +360,7 @@ export function BranchToolbarBranchSelector({ · 57 unmodified lines
357360 toastManager.add(
358361 stackedThreadToast({
359362 type: "error",
360− title: "Failed to switch ref.",
363+ title: `Failed to switch ${vcsTerms.refNoun}.`,
361364 description: toBranchActionErrorMessage(error),
362365 }),
363366 );
@@ -389,7 +392,7 @@ export function BranchToolbarBranchSelector({ · 25 unmodified lines
389392 toastManager.add(
390393 stackedThreadToast({
391394 type: "error",
392− title: "Failed to create and switch ref.",
395+ title: `Failed to create and switch ${vcsTerms.refNoun}.`,
393396 description: toBranchActionErrorMessage(error),
394397 }),
395398 );
@@ -488,6 +491,7 @@ export function BranchToolbarBranchSelector({ · 92 unmodified lines
488491 activeWorktreePath,
489492 effectiveEnvMode,
490493 resolvedActiveBranch,
494+ refNoun: vcsTerms.refNoun,
491495 });
492496
493497 function renderPickerItem(itemValue: string, index: number) {
@@ -524,7 +528,9 @@ export function BranchToolbarBranchSelector({ · 30 unmodified lines
524528 value={itemValue}
525529 onClick={() => createRef(trimmedBranchQuery)}
526530 >
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>
528534 </ComboboxItem>
529535 );
530536 }
apps/web/src/components/ChatView.tsx+7−5
@@ -1458,7 +1458,8 @@ export default function ChatView(props: ChatViewProps) { · 1457 unmodified lines
14581458 ? terminalLaunchContext
14591459 : (storeServerTerminalLaunchContext ?? null);
14601460 // 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;
14621463 const terminalShortcutLabelOptions = useMemo(
14631464 () => ({
14641465 context: {
@@ -2136,7 +2137,7 @@ export default function ChatView(props: ChatViewProps) { · 671 unmodified lines
21362137 : (activeThread?.branch ?? null);
21372138 const sendEnvMode = resolveSendEnvMode({
21382139 requestedEnvMode: envMode,
2139− isGitRepo,
2140+ isGitRepo: isVcsRepo,
21402141 });
21412142
21422143 useEffect(() => {
@@ -3297,7 +3298,8 @@ export default function ChatView(props: ChatViewProps) { · 1154 unmodified lines
32973298 {...(routeKind === "draft" && draftId ? { draftId } : {})}
32983299 activeThreadTitle={activeThread.title}
32993300 activeProjectName={activeProject?.name}
3300− isGitRepo={isGitRepo}
3301+ isVcsRepo={isVcsRepo}
3302+ vcsKind={vcsKind}
33013303 openInCwd={gitCwd}
33023304 activeProjectScripts={activeProject?.scripts}
33033305 preferredScriptId={
@@ -3377,7 +3379,7 @@ export default function ChatView(props: ChatViewProps) { · 73 unmodified lines
33773379 <div
33783380 className={cn(
33793381 "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− isGitRepo
3382+ isVcsRepo
33813383 ? "pb-[calc(env(safe-area-inset-bottom)+0.25rem)]"
33823384 : "pb-[calc(env(safe-area-inset-bottom)+0.75rem)] sm:pb-[calc(env(safe-area-inset-bottom)+1rem)]",
33833385 )}
@@ -3450,7 +3452,7 @@ export default function ChatView(props: ChatViewProps) { · 66 unmodified lines
34503452 setThreadError={setThreadError}
34513453 onExpandImage={onExpandTimelineImage}
34523454 />
3453− {isGitRepo && (
3455+ {isVcsRepo && (
34543456 <BranchToolbar
34553457 environmentId={activeThread.environmentId}
34563458 threadId={activeThread.id}
apps/web/src/components/DiffPanel.tsx+6−4
@@ -35,6 +35,7 @@ import { createThreadSelectorByRef } from "../storeSelectors"; · 34 unmodified lines
3535 import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
3636 import { useSettings } from "../hooks/useSettings";
3737 import { formatShortTimestamp } from "../timestampFormat";
38+import { resolveVcsTerms } from "../vcsPresentation";
3839 import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
3940 import { ToggleGroup, Toggle } from "./ui/toggle-group";
4041
@@ -201,7 +202,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 160 unmodified lines
201202 environmentId: activeThread?.environmentId ?? null,
202203 cwd: activeCwd ?? null,
203204 });
204− const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
205+ const isVcsRepo = gitStatusQuery.data?.isRepo ?? true;
206+ const vcsTerms = resolveVcsTerms(gitStatusQuery.data?.kind);
205207 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
206208 useTurnDiffSummaries(activeThread);
207209 const orderedTurnDiffSummaries = useMemo(
@@ -278,7 +280,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 70 unmodified lines
278280 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
279281 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
280282 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
281− enabled: isGitRepo,
283+ enabled: isVcsRepo,
282284 }),
283285 );
284286 const selectedTurnCheckpointDiff = selectedTurn
@@ -561,9 +563,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 276 unmodified lines
561563 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
562564 Select a thread to inspect turn diffs.
563565 </div>
564− ) : !isGitRepo ? (
566+ ) : !isVcsRepo ? (
565567 <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}.
567569 </div>
568570 ) : orderedTurnDiffSummaries.length === 0 ? (
569571 <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
113113 resetGitStatusStateForTests: () => undefined,
114114 useGitStatus: vi.fn(() => ({
115115 data: {
116+ kind: "git",
116117 isRepo: true,
117118 sourceControlProvider: {
118119 kind: "github",
apps/web/src/components/GitActionsControl.logic.test.ts+1−0
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313
1414 function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
1515 return {
16+ kind: "git",
1617 isRepo: true,
1718 hasPrimaryRemote: true,
1819 isDefaultRef: false,
apps/web/src/components/GitActionsControl.tsx+25−1
@@ -54,6 +54,7 @@ import { readEnvironmentApi } from "~/environmentApi"; · 53 unmodified lines
5454 import { readLocalApi } from "~/localApi";
5555 import { useStore } from "~/store";
5656 import { createThreadSelectorByRef } from "~/storeSelectors";
57+import { resolveVcsActionPresentation, resolveVcsTerms } from "~/vcsPresentation";
5758
5859 interface GitActionsControlProps {
5960 gitCwd: string | null;
@@ -324,8 +325,12 @@ export default function GitActionsControl({ · 264 unmodified lines
324325 });
325326 // Default to true while loading so we don't flash init controls.
326327 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;
327332 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;
328− const gitStatusForActions = gitStatus;
333+ const gitStatusForActions = supportsGitWorkflowActions ? gitStatus : null;
329334
330335 const allFiles = gitStatusForActions?.workingTree.files ?? [];
331336 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));
@@ -874,6 +879,25 @@ export default function GitActionsControl({ · 542 unmodified lines
874879 >
875880 {initMutation.isPending ? "Initializing..." : "Initialize Git"}
876881 </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>
877901 ) : (
878902 <Group aria-label="Git actions" className="shrink-0">
879903 {quickActionDisabledReason ? (
apps/web/src/components/chat/ChatHeader.tsx+12−7
@@ -17,6 +17,8 @@ import { Toggle } from "../ui/toggle"; · 16 unmodified lines
1717 import { SidebarTrigger } from "../ui/sidebar";
1818 import { OpenInPicker } from "./OpenInPicker";
1919 import { usePrimaryEnvironmentId } from "../../environments/primary";
20+import { resolveVcsTerms } from "../../vcsPresentation";
21+import type { VcsDriverKind } from "@t3tools/contracts";
2022
2123 interface ChatHeaderProps {
2224 activeThreadEnvironmentId: EnvironmentId;
@@ -24,7 +26,8 @@ interface ChatHeaderProps { · 1 unmodified line
2426 draftId?: DraftId;
2527 activeThreadTitle: string;
2628 activeProjectName: string | undefined;
27− isGitRepo: boolean;
29+ isVcsRepo: boolean;
30+ vcsKind: VcsDriverKind | null;
2831 openInCwd: string | null;
2932 activeProjectScripts: ProjectScript[] | undefined;
3033 preferredScriptId: string | null;
@@ -50,7 +53,8 @@ export const ChatHeader = memo(function ChatHeader({ · 19 unmodified lines
5053 draftId,
5154 activeThreadTitle,
5255 activeProjectName,
53− isGitRepo,
56+ isVcsRepo,
57+ vcsKind,
5458 openInCwd,
5559 activeProjectScripts,
5660 preferredScriptId,
@@ -72,6 +76,7 @@ export const ChatHeader = memo(function ChatHeader({ · 15 unmodified lines
7276 const primaryEnvironmentId = usePrimaryEnvironmentId();
7377 const isRemoteEnvironment =
7478 primaryEnvironmentId !== null && activeThreadEnvironmentId !== primaryEnvironmentId;
79+ const vcsTerms = resolveVcsTerms(vcsKind);
7580
7681 return (
7782 <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
8893 <span className="min-w-0 truncate">{activeProjectName}</span>
8994 </Badge>
9095 )}
91− {activeProjectName && !isGitRepo && (
96+ {activeProjectName && !isVcsRepo && (
9297 <Badge variant="outline" className="shrink-0 text-[10px] text-amber-700">
93− No Git
98+ No repository
9499 </Badge>
95100 )}
96101 </div>
@@ -154,15 +159,15 @@ export const ChatHeader = memo(function ChatHeader({ · 57 unmodified lines
154159 aria-label="Toggle diff panel"
155160 variant="outline"
156161 size="xs"
157− disabled={!isGitRepo && !diffOpen}
162+ disabled={!isVcsRepo && !diffOpen}
158163 >
159164 <DiffIcon className="size-3" />
160165 </Toggle>
161166 }
162167 />
163168 <TooltipPopup side="bottom">
164− {!isGitRepo && !diffOpen
165− ? "Diff panel is unavailable because this project is not a git repository."
169+ {!isVcsRepo && !diffOpen
170+ ? `Diff panel is unavailable because this project is not a supported ${vcsTerms.repositoryNoun}.`
166171 : diffToggleShortcutLabel
167172 ? `Toggle diff panel (${diffToggleShortcutLabel})`
168173 : "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
4040 const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const;
4141
4242 const BASE_STATUS: VcsStatusResult = {
43+ kind: "git",
4344 isRepo: true,
4445 hasPrimaryRemote: true,
4546 isDefaultRef: false,
apps/web/src/lib/providerReactQuery.ts+1−1
@@ -64,7 +64,7 @@ function normalizeCheckpointErrorMessage(error: unknown): string { · 63 unmodified lines
6464
6565 const lower = message.toLowerCase();
6666 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.";
6868 }
6969
7070 if (
apps/web/src/localApi.test.ts+1−0
@@ -262,6 +262,7 @@ const baseServerConfig: ServerConfig = { · 261 unmodified lines
262262 };
263263
264264 const baseGitStatus: VcsStatusResult = {
265+ kind: "git",
265266 isRepo: true,
266267 hasPrimaryRemote: true,
267268 isDefaultRef: false,
apps/web/src/rpc/wsRpcClient.test.ts+1−0
@@ -19,6 +19,7 @@ import { createWsRpcClient } from "./wsRpcClient"; · 18 unmodified lines
1919 import { type WsTransport } from "./wsTransport";
2020
2121 const baseLocalStatus: VcsStatusLocalResult = {
22+ kind: "git",
2223 isRepo: true,
2324 hasPrimaryRemote: true,
2425 isDefaultRef: false,
apps/web/src/vcsPresentation.ts+67−0
@@ -0,0 +1,67 @@
1+import type { VcsDriverKind } from "@t3tools/contracts";
2+
3+export interface VcsTerms {
4+ readonly systemName: string;
5+ readonly repositoryNoun: string;
6+ readonly refNoun: string;
7+ readonly refNounPlural: string;
8+ readonly currentRefFallback: string;
9+}
10+
11+export interface VcsActionPresentation {
12+ readonly supportsGitWorkflowActions: boolean;
13+ readonly unsupportedGitWorkflowDescription: string;
14+}
15+
16+const gitLikeTerms: VcsTerms = {
17+ systemName: "Git",
18+ repositoryNoun: "repository",
19+ refNoun: "branch",
20+ refNounPlural: "branches",
21+ currentRefFallback: "checkout",
22+};
23+
24+const unknownTerms: VcsTerms = {
25+ systemName: "VCS",
26+ repositoryNoun: "repository",
27+ refNoun: "ref",
28+ refNounPlural: "refs",
29+ currentRefFallback: "checkout",
30+};
31+
32+export 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+
57+export 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 @@
11 import { Schema } from "effect";
22 import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
33 import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts";
4+import { VcsDriverKind } from "./vcs.ts";
45
56 const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
67 const GIT_LIST_BRANCHES_MAX_LIMIT = 200;
@@ -194,6 +195,7 @@ const VcsStatusChangeRequest = Schema.Struct({ · 187 unmodified lines
194195 });
195196
196197 const VcsStatusLocalShape = {
198+ kind: VcsDriverKind,
197199 isRepo: Schema.Boolean,
198200 sourceControlProvider: Schema.optional(SourceControlProviderInfo),
199201 hasPrimaryRemote: Schema.Boolean,
packages/shared/src/git.test.ts+2−0
@@ -81,6 +81,7 @@ describe("applyGitStatusStreamEvent", () => { · 80 unmodified lines
8181 };
8282
8383 expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({
84+ kind: "unknown",
8485 isRepo: true,
8586 hasPrimaryRemote: false,
8687 isDefaultRef: false,
@@ -96,6 +97,7 @@ describe("applyGitStatusStreamEvent", () => { · 9 unmodified lines
9697
9798 it("preserves local-only fields when applying a remote update", () => {
9899 const current: VcsStatusResult = {
100+ kind: "git",
99101 isRepo: true,
100102 sourceControlProvider: {
101103 kind: "github",
packages/shared/src/git.ts+2−0
@@ -226,6 +226,7 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { · 225 unmodified lines
226226
227227 function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult {
228228 return {
229+ kind: status.kind,
229230 isRepo: status.isRepo,
230231 ...(status.sourceControlProvider
231232 ? { sourceControlProvider: status.sourceControlProvider }
@@ -251,6 +252,7 @@ export function applyGitStatusStreamEvent( · 19 unmodified lines
251252 if (current === null) {
252253 return mergeGitStatusParts(
253254 {
255+ kind: "unknown",
254256 isRepo: true,
255257 hasPrimaryRemote: false,
256258 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