- Detect and route jj repositories alongside git - Thread VCS kind through status and UI labels - Add jj driver contract coverage and shared freshness helper
Unified · Split
| @@ -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", |
| @@ -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, |
| @@ -3,6 +3,7 @@ import { Context, Effect, Layer } from "effect"; · 2 unmodified lines | |||
| 3 | import { | 3 | import { |
| 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 | |||
| 29 | import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; | 30 | import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; |
| 30 | import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; | 31 | import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; |
| 31 | import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; | 32 | import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; |
| 33 | import { mergeGitStatusParts } from "@t3tools/shared/git"; | ||
| 32 | 34 | ||
| 33 | export interface GitWorkflowServiceShape { | 35 | export 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 | ||
| 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 | |||
| 95 | export const make = Effect.fn("makeGitWorkflowService")(function* () { | 109 | export 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, |
| @@ -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", |
| @@ -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", |
| @@ -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", |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { Context, DateTime, Effect, Layer, Option } from "effect"; | 1 | import { Context, Effect, Layer } from "effect"; |
| 2 | import { ChildProcessSpawner } from "effect/unstable/process"; | 2 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| @@ -20,6 +20,7 @@ import { · 15 unmodified lines | |||
| 20 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| 21 | import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; | 21 | import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; |
| 22 | import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; | 22 | import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; |
| 23 | import { nowFreshness } from "./VcsFreshness.ts"; | ||
| 23 | import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; | 24 | import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; |
| 24 | 25 | ||
| 25 | export interface ExecuteGitInput { | 26 | export 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 | ||
| 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 | |||
| 221 | function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { | 213 | function 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 []; |
| @@ -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, |
| @@ -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 | }); | ||
| @@ -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()); | ||
| @@ -7,10 +7,13 @@ import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "./VcsPr · 6 unmodified lines | |||
| 7 | import { VcsProjectConfig } from "./VcsProjectConfig.ts"; | 7 | import { VcsProjectConfig } from "./VcsProjectConfig.ts"; |
| 8 | import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts"; | 8 | import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts"; |
| 9 | 9 | ||
| 10 | const processOutput = (stdout: string): VcsProcessOutput => ({ | 10 | const commandCalls = (calls: ReadonlyArray<VcsProcessInput>) => |
| 11 | exitCode: ChildProcessSpawner.ExitCode(0), | 11 | calls.map((call) => [call.command].concat(call.args)); |
| 12 | |||
| 13 | const 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 | }); |
| @@ -3,6 +3,7 @@ import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; · 2 unmodified lines | |||
| 3 | import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts"; | 3 | import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts"; |
| 4 | import { VcsUnsupportedOperationError } from "@t3tools/contracts"; | 4 | import { VcsUnsupportedOperationError } from "@t3tools/contracts"; |
| 5 | import * as GitVcsDriver from "./GitVcsDriver.ts"; | 5 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 6 | import * as JjVcsDriver from "./JjVcsDriver.ts"; | ||
| 6 | import * as VcsProjectConfig from "./VcsProjectConfig.ts"; | 7 | import * as VcsProjectConfig from "./VcsProjectConfig.ts"; |
| 7 | import type { VcsDriverShape } from "./VcsDriver.ts"; | 8 | import type { VcsDriverShape } from "./VcsDriver.ts"; |
| 8 | 9 | ||
| @@ -65,8 +66,10 @@ function parseDetectionCacheKey(key: string): { · 56 unmodified lines | |||
| 65 | export const make = Effect.fn("makeVcsDriverRegistry")(function* () { | 66 | export 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>( |
| @@ -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 | }); | ||
| @@ -16,6 +16,7 @@ import { · 15 unmodified lines | |||
| 16 | import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts"; | 16 | import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts"; |
| 17 | 17 | ||
| 18 | const baseLocalStatus: VcsStatusLocalResult = { | 18 | const baseLocalStatus: VcsStatusLocalResult = { |
| 19 | kind: "git", | ||
| 19 | isRepo: true, | 20 | isRepo: true, |
| 20 | sourceControlProvider: { | 21 | sourceControlProvider: { |
| 21 | kind: "github", | 22 | kind: "github", |
| @@ -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())); |
| @@ -23,6 +23,7 @@ import { cn } from "../lib/utils"; · 22 unmodified lines | |||
| 23 | import { parsePullRequestReference } from "../pullRequestReference"; | 23 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 24 | import { useStore } from "../store"; | 24 | import { useStore } from "../store"; |
| 25 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; | 25 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; |
| 26 | import { resolveVcsTerms } from "../vcsPresentation"; | ||
| 26 | import { | 27 | import { |
| 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 = isBranchesSearchPending | 294 | const branchStatusText = isBranchesSearchPending |
| 292 | ? "Loading refs..." | 295 | ? `Loading ${vcsTerms.refNounPlural}...` |
| 293 | : isFetchingNextPage | 296 | : isFetchingNextPage |
| 294 | ? "Loading more refs..." | 297 | ? `Loading more ${vcsTerms.refNounPlural}...` |
| 295 | : hasNextPage | 298 | : 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 "{trimmedBranchQuery}"</span> | 531 | <span className="truncate"> |
| 532 | Create new {vcsTerms.refNoun} "{trimmedBranchQuery}" | ||
| 533 | </span> | ||
| 528 | </ComboboxItem> | 534 | </ComboboxItem> |
| 529 | ); | 535 | ); |
| 530 | } | 536 | } |
| @@ -1458,7 +1458,8 @@ export default function ChatView(props: ChatViewProps) { · 1457 unmodified lines | |||
| 1458 | ? terminalLaunchContext | 1458 | ? 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 | <div | 3379 | <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 | isGitRepo | 3382 | 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 | <BranchToolbar | 3456 | <BranchToolbar |
| 3455 | environmentId={activeThread.environmentId} | 3457 | environmentId={activeThread.environmentId} |
| 3456 | threadId={activeThread.id} | 3458 | threadId={activeThread.id} |
| @@ -35,6 +35,7 @@ import { createThreadSelectorByRef } from "../storeSelectors"; · 34 unmodified lines | |||
| 35 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; | 35 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; |
| 36 | import { useSettings } from "../hooks/useSettings"; | 36 | import { useSettings } from "../hooks/useSettings"; |
| 37 | import { formatShortTimestamp } from "../timestampFormat"; | 37 | import { formatShortTimestamp } from "../timestampFormat"; |
| 38 | import { resolveVcsTerms } from "../vcsPresentation"; | ||
| 38 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; | 39 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; |
| 39 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; | 40 | import { 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 = selectedTurn | 286 | 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"> |
| @@ -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", |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | |||
| 13 | 13 | ||
| 14 | function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult { | 14 | function 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, |
| @@ -54,6 +54,7 @@ import { readEnvironmentApi } from "~/environmentApi"; · 53 unmodified lines | |||
| 54 | import { readLocalApi } from "~/localApi"; | 54 | import { readLocalApi } from "~/localApi"; |
| 55 | import { useStore } from "~/store"; | 55 | import { useStore } from "~/store"; |
| 56 | import { createThreadSelectorByRef } from "~/storeSelectors"; | 56 | import { createThreadSelectorByRef } from "~/storeSelectors"; |
| 57 | import { resolveVcsActionPresentation, resolveVcsTerms } from "~/vcsPresentation"; | ||
| 57 | 58 | ||
| 58 | interface GitActionsControlProps { | 59 | interface 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 ? ( |
| @@ -17,6 +17,8 @@ import { Toggle } from "../ui/toggle"; · 16 unmodified lines | |||
| 17 | import { SidebarTrigger } from "../ui/sidebar"; | 17 | import { SidebarTrigger } from "../ui/sidebar"; |
| 18 | import { OpenInPicker } from "./OpenInPicker"; | 18 | import { OpenInPicker } from "./OpenInPicker"; |
| 19 | import { usePrimaryEnvironmentId } from "../../environments/primary"; | 19 | import { usePrimaryEnvironmentId } from "../../environments/primary"; |
| 20 | import { resolveVcsTerms } from "../../vcsPresentation"; | ||
| 21 | import type { VcsDriverKind } from "@t3tools/contracts"; | ||
| 20 | 22 | ||
| 21 | interface ChatHeaderProps { | 23 | interface 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 Git | 98 | 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 && !diffOpen | 169 | {!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 | : diffToggleShortcutLabel | 171 | : diffToggleShortcutLabel |
| 167 | ? `Toggle diff panel (${diffToggleShortcutLabel})` | 172 | ? `Toggle diff panel (${diffToggleShortcutLabel})` |
| 168 | : "Toggle diff panel"} | 173 | : "Toggle diff panel"} |
| @@ -40,6 +40,7 @@ const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const; · 39 unmodified lines | |||
| 40 | const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const; | 40 | const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const; |
| 41 | 41 | ||
| 42 | const BASE_STATUS: VcsStatusResult = { | 42 | const BASE_STATUS: VcsStatusResult = { |
| 43 | kind: "git", | ||
| 43 | isRepo: true, | 44 | isRepo: true, |
| 44 | hasPrimaryRemote: true, | 45 | hasPrimaryRemote: true, |
| 45 | isDefaultRef: false, | 46 | isDefaultRef: false, |
| @@ -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 ( |
| @@ -262,6 +262,7 @@ const baseServerConfig: ServerConfig = { · 261 unmodified lines | |||
| 262 | }; | 262 | }; |
| 263 | 263 | ||
| 264 | const baseGitStatus: VcsStatusResult = { | 264 | const baseGitStatus: VcsStatusResult = { |
| 265 | kind: "git", | ||
| 265 | isRepo: true, | 266 | isRepo: true, |
| 266 | hasPrimaryRemote: true, | 267 | hasPrimaryRemote: true, |
| 267 | isDefaultRef: false, | 268 | isDefaultRef: false, |
| @@ -19,6 +19,7 @@ import { createWsRpcClient } from "./wsRpcClient"; · 18 unmodified lines | |||
| 19 | import { type WsTransport } from "./wsTransport"; | 19 | import { type WsTransport } from "./wsTransport"; |
| 20 | 20 | ||
| 21 | const baseLocalStatus: VcsStatusLocalResult = { | 21 | const baseLocalStatus: VcsStatusLocalResult = { |
| 22 | kind: "git", | ||
| 22 | isRepo: true, | 23 | isRepo: true, |
| 23 | hasPrimaryRemote: true, | 24 | hasPrimaryRemote: true, |
| 24 | isDefaultRef: false, | 25 | isDefaultRef: false, |
| @@ -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 | } | ||
| @@ -1,6 +1,7 @@ | |||
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 2 | import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; | 3 | import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; |
| 4 | import { VcsDriverKind } from "./vcs.ts"; | ||
| 4 | 5 | ||
| 5 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; | 6 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 6 | const GIT_LIST_BRANCHES_MAX_LIMIT = 200; | 7 | const GIT_LIST_BRANCHES_MAX_LIMIT = 200; |
| @@ -194,6 +195,7 @@ const VcsStatusChangeRequest = Schema.Struct({ · 187 unmodified lines | |||
| 194 | }); | 195 | }); |
| 195 | 196 | ||
| 196 | const VcsStatusLocalShape = { | 197 | const 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, |
| @@ -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", |
| @@ -226,6 +226,7 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { · 225 unmodified lines | |||
| 226 | 226 | ||
| 227 | function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { | 227 | function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { |
| 228 | return { | 228 | return { |
| 229 | kind: status.kind, | ||
| 229 | isRepo: status.isRepo, | 230 | isRepo: status.isRepo, |
| 230 | ...(status.sourceControlProvider | 231 | ...(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, |