- 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 | 331 | refreshLocalStatus: () => |
| 332 | 332 | Effect.succeed({ |
| 333 | 333 | isRepo: true, |
| 334 | + kind: "git", | |
| 334 | 335 | hasPrimaryRemote: false, |
| 335 | 336 | isDefaultRef: true, |
| 336 | 337 | refName: "main", |
| @@ -704,6 +704,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 703 unmodified lines | ||
| 704 | 704 | : null; |
| 705 | 705 | |
| 706 | 706 | return { |
| 707 | + kind: details.isRepo ? ("git" as const) : ("unknown" as const), | |
| 707 | 708 | isRepo: details.isRepo, |
| 708 | 709 | ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}), |
| 709 | 710 | hasPrimaryRemote: details.hasOriginRemote, |
| @@ -3,6 +3,7 @@ import { Context, Effect, Layer } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { |
| 4 | 4 | GitManagerError, |
| 5 | 5 | GitCommandError, |
| 6 | + type VcsDriverKind, | |
| 6 | 7 | type VcsSwitchRefInput, |
| 7 | 8 | type VcsSwitchRefResult, |
| 8 | 9 | type VcsCreateRefInput, |
| @@ -29,6 +30,7 @@ import { · 20 unmodified lines | ||
| 29 | 30 | import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; |
| 30 | 31 | import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; |
| 31 | 32 | import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; |
| 33 | +import { mergeGitStatusParts } from "@t3tools/shared/git"; | |
| 32 | 34 | |
| 33 | 35 | export interface GitWorkflowServiceShape { |
| 34 | 36 | readonly status: ( |
| @@ -92,6 +94,18 @@ const unsupportedGitCommand = (operation: string, cwd: string, detail: string) = · 57 unmodified lines | ||
| 92 | 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 | 109 | export const make = Effect.fn("makeGitWorkflowService")(function* () { |
| 96 | 110 | const registry = yield* VcsDriverRegistry; |
| 97 | 111 | const git = yield* GitVcsDriver; |
| @@ -153,10 +167,60 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { · 55 unmodified lines | ||
| 153 | 167 | (input: Input) => |
| 154 | 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 | 220 | 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, | |
| 160 | 224 | invalidateLocalStatus: gitManager.invalidateLocalStatus, |
| 161 | 225 | invalidateRemoteStatus: gitManager.invalidateRemoteStatus, |
| 162 | 226 | invalidateStatus: gitManager.invalidateStatus, |
| @@ -291,6 +291,7 @@ describe("CheckpointReactor", () => { · 290 unmodified lines | ||
| 291 | 291 | }).pipe( |
| 292 | 292 | Effect.as({ |
| 293 | 293 | isRepo: true, |
| 294 | + kind: "git", | |
| 294 | 295 | hasPrimaryRemote: false, |
| 295 | 296 | isDefaultRef: true, |
| 296 | 297 | refName: "main", |
| @@ -240,6 +240,7 @@ describe("ProviderCommandReactor", () => { · 239 unmodified lines | ||
| 240 | 240 | const refreshStatus = vi.fn((_: string) => |
| 241 | 241 | Effect.succeed({ |
| 242 | 242 | isRepo: true, |
| 243 | + kind: "git" as const, | |
| 243 | 244 | hasPrimaryRemote: true, |
| 244 | 245 | isDefaultRef: false, |
| 245 | 246 | refName: "renamed-branch", |
| @@ -2373,6 +2373,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 2372 unmodified lines | ||
| 2373 | 2373 | localStatus: () => |
| 2374 | 2374 | Effect.succeed({ |
| 2375 | 2375 | isRepo: true, |
| 2376 | + kind: "git", | |
| 2376 | 2377 | hasPrimaryRemote: true, |
| 2377 | 2378 | isDefaultRef: true, |
| 2378 | 2379 | refName: "main", |
| @@ -2389,6 +2390,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 10 unmodified lines | ||
| 2389 | 2390 | status: () => |
| 2390 | 2391 | Effect.succeed({ |
| 2391 | 2392 | isRepo: true, |
| 2393 | + kind: "git", | |
| 2392 | 2394 | hasPrimaryRemote: true, |
| 2393 | 2395 | isDefaultRef: true, |
| 2394 | 2396 | refName: "main", |
| @@ -2643,6 +2645,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 248 unmodified lines | ||
| 2643 | 2645 | localStatus: () => |
| 2644 | 2646 | Effect.succeed({ |
| 2645 | 2647 | isRepo: true, |
| 2648 | + kind: "git", | |
| 2646 | 2649 | hasPrimaryRemote: true, |
| 2647 | 2650 | isDefaultRef: true, |
| 2648 | 2651 | refName: "main", |
| @@ -2664,6 +2667,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 15 unmodified lines | ||
| 2664 | 2667 | statusCalls += 1; |
| 2665 | 2668 | return { |
| 2666 | 2669 | isRepo: true, |
| 2670 | + kind: "git", | |
| 2667 | 2671 | hasPrimaryRemote: true, |
| 2668 | 2672 | isDefaultRef: true, |
| 2669 | 2673 | refName: "main", |
| @@ -2720,6 +2724,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 50 unmodified lines | ||
| 2720 | 2724 | localStatus: () => |
| 2721 | 2725 | Effect.succeed({ |
| 2722 | 2726 | isRepo: true, |
| 2727 | + kind: "git", | |
| 2723 | 2728 | hasPrimaryRemote: true, |
| 2724 | 2729 | isDefaultRef: false, |
| 2725 | 2730 | refName: "feature/demo", |
| @@ -2741,6 +2746,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 15 unmodified lines | ||
| 2741 | 2746 | statusCalls += 1; |
| 2742 | 2747 | return { |
| 2743 | 2748 | isRepo: true, |
| 2749 | + kind: "git", | |
| 2744 | 2750 | hasPrimaryRemote: true, |
| 2745 | 2751 | isDefaultRef: false, |
| 2746 | 2752 | refName: "feature/demo", |
| @@ -2792,6 +2798,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 45 unmodified lines | ||
| 2792 | 2798 | localStatus: () => |
| 2793 | 2799 | Effect.succeed({ |
| 2794 | 2800 | isRepo: true, |
| 2801 | + kind: "git", | |
| 2795 | 2802 | hasPrimaryRemote: true, |
| 2796 | 2803 | isDefaultRef: true, |
| 2797 | 2804 | refName: "main", |
| @@ -2835,6 +2842,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 37 unmodified lines | ||
| 2835 | 2842 | localStatus: () => |
| 2836 | 2843 | Effect.succeed({ |
| 2837 | 2844 | isRepo: true, |
| 2845 | + kind: "git", | |
| 2838 | 2846 | hasPrimaryRemote: true, |
| 2839 | 2847 | isDefaultRef: false, |
| 2840 | 2848 | refName: "feature/demo", |
| @@ -2911,6 +2919,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 70 unmodified lines | ||
| 2911 | 2919 | Effect.andThen( |
| 2912 | 2920 | Effect.succeed({ |
| 2913 | 2921 | isRepo: true, |
| 2922 | + kind: "git" as const, | |
| 2914 | 2923 | hasPrimaryRemote: true, |
| 2915 | 2924 | isDefaultRef: false, |
| 2916 | 2925 | refName: "feature/demo", |
| @@ -3577,6 +3586,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 660 unmodified lines | ||
| 3577 | 3586 | const refreshStatus = vi.fn((_: string) => |
| 3578 | 3587 | Effect.succeed({ |
| 3579 | 3588 | isRepo: true, |
| 3589 | + kind: "git" as const, | |
| 3580 | 3590 | hasPrimaryRemote: true, |
| 3581 | 3591 | isDefaultRef: false, |
| 3582 | 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 | 2 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 3 | 3 | |
| 4 | 4 | import { |
| @@ -20,6 +20,7 @@ import { · 15 unmodified lines | ||
| 20 | 20 | } from "@t3tools/contracts"; |
| 21 | 21 | import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; |
| 22 | 22 | import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; |
| 23 | +import { nowFreshness } from "./VcsFreshness.ts"; | |
| 23 | 24 | import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; |
| 24 | 25 | |
| 25 | 26 | export interface ExecuteGitInput { |
| @@ -209,15 +210,6 @@ const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ · 183 unmodified lines | ||
| 209 | 210 | "core.untrackedCache=false", |
| 210 | 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 | 213 | function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { |
| 222 | 214 | const parts = input.split("\0"); |
| 223 | 215 | if (parts.length === 0) return []; |
| @@ -1314,6 +1314,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 1313 unmodified lines | ||
| 1314 | 1314 | const status: GitVcsDriverShape["status"] = (input) => |
| 1315 | 1315 | statusDetails(input.cwd).pipe( |
| 1316 | 1316 | Effect.map((details) => ({ |
| 1317 | + kind: "git" as const, | |
| 1317 | 1318 | isRepo: details.isRepo, |
| 1318 | 1319 | hasPrimaryRemote: details.hasOriginRemote, |
| 1319 | 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 | 7 | import { VcsProjectConfig } from "./VcsProjectConfig.ts"; |
| 8 | 8 | import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts"; |
| 9 | 9 | |
| 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), | |
| 12 | 15 | stdout, |
| 13 | − stderr: "", | |
| 16 | + stderr, | |
| 14 | 17 | stdoutTruncated: false, |
| 15 | 18 | stderrTruncated: false, |
| 16 | 19 | }); |
| @@ -62,4 +65,76 @@ describe("VcsDriverRegistry", () => { · 45 unmodified lines | ||
| 62 | 65 | ); |
| 63 | 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 | 3 | import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts"; |
| 4 | 4 | import { VcsUnsupportedOperationError } from "@t3tools/contracts"; |
| 5 | 5 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 6 | +import * as JjVcsDriver from "./JjVcsDriver.ts"; | |
| 6 | 7 | import * as VcsProjectConfig from "./VcsProjectConfig.ts"; |
| 7 | 8 | import type { VcsDriverShape } from "./VcsDriver.ts"; |
| 8 | 9 | |
| @@ -65,8 +66,10 @@ function parseDetectionCacheKey(key: string): { · 56 unmodified lines | ||
| 65 | 66 | export const make = Effect.fn("makeVcsDriverRegistry")(function* () { |
| 66 | 67 | const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; |
| 67 | 68 | const git = yield* GitVcsDriver.makeVcsDriverShape(); |
| 69 | + const jj = yield* JjVcsDriver.makeVcsDriverShape(); | |
| 68 | 70 | const drivers: Partial<Record<VcsDriverKind, VcsDriverShape>> = { |
| 69 | 71 | git, |
| 72 | + jj, | |
| 70 | 73 | }; |
| 71 | 74 | |
| 72 | 75 | const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* ( |
| @@ -103,7 +106,12 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { · 30 unmodified lines | ||
| 103 | 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 | 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 | 16 | import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts"; |
| 17 | 17 | |
| 18 | 18 | const baseLocalStatus: VcsStatusLocalResult = { |
| 19 | + kind: "git", | |
| 19 | 20 | isRepo: true, |
| 20 | 21 | sourceControlProvider: { |
| 21 | 22 | kind: "github", |
| @@ -56,9 +56,11 @@ export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInp · 55 unmodified lines | ||
| 56 | 56 | |
| 57 | 57 | yield* input.fixture.createRepo(cwd); |
| 58 | 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 | 61 | const identity = yield* driver.detectRepository(cwd); |
| 60 | 62 | assert.equal(identity?.kind, input.kind); |
| 61 | − assert.isTrue(identity?.rootPath.endsWith(cwd)); | |
| 63 | + assert.isTrue(identity?.rootPath.endsWith(realCwd)); | |
| 62 | 64 | assert.equal(identity?.freshness.source, "live-local"); |
| 63 | 65 | assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt)); |
| 64 | 66 | assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none())); |
| @@ -23,6 +23,7 @@ import { cn } from "../lib/utils"; · 22 unmodified lines | ||
| 23 | 23 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 24 | 24 | import { useStore } from "../store"; |
| 25 | 25 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; |
| 26 | +import { resolveVcsTerms } from "../vcsPresentation"; | |
| 26 | 27 | import { |
| 27 | 28 | deriveLocalBranchNameFromRemoteRef, |
| 28 | 29 | resolveBranchSelectionTarget, |
| @@ -66,10 +67,11 @@ function getBranchTriggerLabel(input: { · 37 unmodified lines | ||
| 66 | 67 | activeWorktreePath: string | null; |
| 67 | 68 | effectiveEnvMode: "local" | "worktree"; |
| 68 | 69 | resolvedActiveBranch: string | null; |
| 70 | + refNoun: string; | |
| 69 | 71 | }): string { |
| 70 | − const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; | |
| 72 | + const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, refNoun } = input; | |
| 71 | 73 | if (!resolvedActiveBranch) { |
| 72 | − return "Select ref"; | |
| 74 | + return `Select ${refNoun}`; | |
| 73 | 75 | } |
| 74 | 76 | if (effectiveEnvMode === "worktree" && !activeWorktreePath) { |
| 75 | 77 | return `From ${resolvedActiveBranch}`; |
| @@ -201,6 +203,7 @@ export function BranchToolbarBranchSelector({ · 125 unmodified lines | ||
| 201 | 203 | const deferredBranchQuery = useDeferredValue(branchQuery); |
| 202 | 204 | |
| 203 | 205 | const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd }); |
| 206 | + const vcsTerms = resolveVcsTerms(branchStatusQuery.data?.kind); | |
| 204 | 207 | const trimmedBranchQuery = branchQuery.trim(); |
| 205 | 208 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); |
| 206 | 209 | |
| @@ -289,11 +292,11 @@ export function BranchToolbarBranchSelector({ · 82 unmodified lines | ||
| 289 | 292 | const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; |
| 290 | 293 | const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; |
| 291 | 294 | const branchStatusText = isBranchesSearchPending |
| 292 | − ? "Loading refs..." | |
| 295 | + ? `Loading ${vcsTerms.refNounPlural}...` | |
| 293 | 296 | : isFetchingNextPage |
| 294 | − ? "Loading more refs..." | |
| 297 | + ? `Loading more ${vcsTerms.refNounPlural}...` | |
| 295 | 298 | : hasNextPage |
| 296 | − ? `Showing ${refs.length} of ${totalBranchCount} refs` | |
| 299 | + ? `Showing ${refs.length} of ${totalBranchCount} ${vcsTerms.refNounPlural}` | |
| 297 | 300 | : null; |
| 298 | 301 | |
| 299 | 302 | // --------------------------------------------------------------------------- |
| @@ -357,7 +360,7 @@ export function BranchToolbarBranchSelector({ · 57 unmodified lines | ||
| 357 | 360 | toastManager.add( |
| 358 | 361 | stackedThreadToast({ |
| 359 | 362 | type: "error", |
| 360 | − title: "Failed to switch ref.", | |
| 363 | + title: `Failed to switch ${vcsTerms.refNoun}.`, | |
| 361 | 364 | description: toBranchActionErrorMessage(error), |
| 362 | 365 | }), |
| 363 | 366 | ); |
| @@ -389,7 +392,7 @@ export function BranchToolbarBranchSelector({ · 25 unmodified lines | ||
| 389 | 392 | toastManager.add( |
| 390 | 393 | stackedThreadToast({ |
| 391 | 394 | type: "error", |
| 392 | − title: "Failed to create and switch ref.", | |
| 395 | + title: `Failed to create and switch ${vcsTerms.refNoun}.`, | |
| 393 | 396 | description: toBranchActionErrorMessage(error), |
| 394 | 397 | }), |
| 395 | 398 | ); |
| @@ -488,6 +491,7 @@ export function BranchToolbarBranchSelector({ · 92 unmodified lines | ||
| 488 | 491 | activeWorktreePath, |
| 489 | 492 | effectiveEnvMode, |
| 490 | 493 | resolvedActiveBranch, |
| 494 | + refNoun: vcsTerms.refNoun, | |
| 491 | 495 | }); |
| 492 | 496 | |
| 493 | 497 | function renderPickerItem(itemValue: string, index: number) { |
| @@ -524,7 +528,9 @@ export function BranchToolbarBranchSelector({ · 30 unmodified lines | ||
| 524 | 528 | value={itemValue} |
| 525 | 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 | 534 | </ComboboxItem> |
| 529 | 535 | ); |
| 530 | 536 | } |
| @@ -1458,7 +1458,8 @@ export default function ChatView(props: ChatViewProps) { · 1457 unmodified lines | ||
| 1458 | 1458 | ? terminalLaunchContext |
| 1459 | 1459 | : (storeServerTerminalLaunchContext ?? null); |
| 1460 | 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 | 1463 | const terminalShortcutLabelOptions = useMemo( |
| 1463 | 1464 | () => ({ |
| 1464 | 1465 | context: { |
| @@ -2136,7 +2137,7 @@ export default function ChatView(props: ChatViewProps) { · 671 unmodified lines | ||
| 2136 | 2137 | : (activeThread?.branch ?? null); |
| 2137 | 2138 | const sendEnvMode = resolveSendEnvMode({ |
| 2138 | 2139 | requestedEnvMode: envMode, |
| 2139 | − isGitRepo, | |
| 2140 | + isGitRepo: isVcsRepo, | |
| 2140 | 2141 | }); |
| 2141 | 2142 | |
| 2142 | 2143 | useEffect(() => { |
| @@ -3297,7 +3298,8 @@ export default function ChatView(props: ChatViewProps) { · 1154 unmodified lines | ||
| 3297 | 3298 | {...(routeKind === "draft" && draftId ? { draftId } : {})} |
| 3298 | 3299 | activeThreadTitle={activeThread.title} |
| 3299 | 3300 | activeProjectName={activeProject?.name} |
| 3300 | − isGitRepo={isGitRepo} | |
| 3301 | + isVcsRepo={isVcsRepo} | |
| 3302 | + vcsKind={vcsKind} | |
| 3301 | 3303 | openInCwd={gitCwd} |
| 3302 | 3304 | activeProjectScripts={activeProject?.scripts} |
| 3303 | 3305 | preferredScriptId={ |
| @@ -3377,7 +3379,7 @@ export default function ChatView(props: ChatViewProps) { · 73 unmodified lines | ||
| 3377 | 3379 | <div |
| 3378 | 3380 | className={cn( |
| 3379 | 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 | 3383 | ? "pb-[calc(env(safe-area-inset-bottom)+0.25rem)]" |
| 3382 | 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 | 3452 | setThreadError={setThreadError} |
| 3451 | 3453 | onExpandImage={onExpandTimelineImage} |
| 3452 | 3454 | /> |
| 3453 | − {isGitRepo && ( | |
| 3455 | + {isVcsRepo && ( | |
| 3454 | 3456 | <BranchToolbar |
| 3455 | 3457 | environmentId={activeThread.environmentId} |
| 3456 | 3458 | threadId={activeThread.id} |
| @@ -35,6 +35,7 @@ import { createThreadSelectorByRef } from "../storeSelectors"; · 34 unmodified lines | ||
| 35 | 35 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; |
| 36 | 36 | import { useSettings } from "../hooks/useSettings"; |
| 37 | 37 | import { formatShortTimestamp } from "../timestampFormat"; |
| 38 | +import { resolveVcsTerms } from "../vcsPresentation"; | |
| 38 | 39 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; |
| 39 | 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 | 202 | environmentId: activeThread?.environmentId ?? null, |
| 202 | 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 | 207 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 206 | 208 | useTurnDiffSummaries(activeThread); |
| 207 | 209 | const orderedTurnDiffSummaries = useMemo( |
| @@ -278,7 +280,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 70 unmodified lines | ||
| 278 | 280 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, |
| 279 | 281 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, |
| 280 | 282 | cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, |
| 281 | − enabled: isGitRepo, | |
| 283 | + enabled: isVcsRepo, | |
| 282 | 284 | }), |
| 283 | 285 | ); |
| 284 | 286 | const selectedTurnCheckpointDiff = selectedTurn |
| @@ -561,9 +563,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 276 unmodified lines | ||
| 561 | 563 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 562 | 564 | Select a thread to inspect turn diffs. |
| 563 | 565 | </div> |
| 564 | − ) : !isGitRepo ? ( | |
| 566 | + ) : !isVcsRepo ? ( | |
| 565 | 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 | 569 | </div> |
| 568 | 570 | ) : orderedTurnDiffSummaries.length === 0 ? ( |
| 569 | 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 | 113 | resetGitStatusStateForTests: () => undefined, |
| 114 | 114 | useGitStatus: vi.fn(() => ({ |
| 115 | 115 | data: { |
| 116 | + kind: "git", | |
| 116 | 117 | isRepo: true, |
| 117 | 118 | sourceControlProvider: { |
| 118 | 119 | kind: "github", |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | |
| 14 | 14 | function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult { |
| 15 | 15 | return { |
| 16 | + kind: "git", | |
| 16 | 17 | isRepo: true, |
| 17 | 18 | hasPrimaryRemote: true, |
| 18 | 19 | isDefaultRef: false, |
| @@ -54,6 +54,7 @@ import { readEnvironmentApi } from "~/environmentApi"; · 53 unmodified lines | ||
| 54 | 54 | import { readLocalApi } from "~/localApi"; |
| 55 | 55 | import { useStore } from "~/store"; |
| 56 | 56 | import { createThreadSelectorByRef } from "~/storeSelectors"; |
| 57 | +import { resolveVcsActionPresentation, resolveVcsTerms } from "~/vcsPresentation"; | |
| 57 | 58 | |
| 58 | 59 | interface GitActionsControlProps { |
| 59 | 60 | gitCwd: string | null; |
| @@ -324,8 +325,12 @@ export default function GitActionsControl({ · 264 unmodified lines | ||
| 324 | 325 | }); |
| 325 | 326 | // Default to true while loading so we don't flash init controls. |
| 326 | 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 | 332 | const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; |
| 328 | − const gitStatusForActions = gitStatus; | |
| 333 | + const gitStatusForActions = supportsGitWorkflowActions ? gitStatus : null; | |
| 329 | 334 | |
| 330 | 335 | const allFiles = gitStatusForActions?.workingTree.files ?? []; |
| 331 | 336 | const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); |
| @@ -874,6 +879,25 @@ export default function GitActionsControl({ · 542 unmodified lines | ||
| 874 | 879 | > |
| 875 | 880 | {initMutation.isPending ? "Initializing..." : "Initialize Git"} |
| 876 | 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 | 902 | <Group aria-label="Git actions" className="shrink-0"> |
| 879 | 903 | {quickActionDisabledReason ? ( |
| @@ -17,6 +17,8 @@ import { Toggle } from "../ui/toggle"; · 16 unmodified lines | ||
| 17 | 17 | import { SidebarTrigger } from "../ui/sidebar"; |
| 18 | 18 | import { OpenInPicker } from "./OpenInPicker"; |
| 19 | 19 | import { usePrimaryEnvironmentId } from "../../environments/primary"; |
| 20 | +import { resolveVcsTerms } from "../../vcsPresentation"; | |
| 21 | +import type { VcsDriverKind } from "@t3tools/contracts"; | |
| 20 | 22 | |
| 21 | 23 | interface ChatHeaderProps { |
| 22 | 24 | activeThreadEnvironmentId: EnvironmentId; |
| @@ -24,7 +26,8 @@ interface ChatHeaderProps { · 1 unmodified line | ||
| 24 | 26 | draftId?: DraftId; |
| 25 | 27 | activeThreadTitle: string; |
| 26 | 28 | activeProjectName: string | undefined; |
| 27 | − isGitRepo: boolean; | |
| 29 | + isVcsRepo: boolean; | |
| 30 | + vcsKind: VcsDriverKind | null; | |
| 28 | 31 | openInCwd: string | null; |
| 29 | 32 | activeProjectScripts: ProjectScript[] | undefined; |
| 30 | 33 | preferredScriptId: string | null; |
| @@ -50,7 +53,8 @@ export const ChatHeader = memo(function ChatHeader({ · 19 unmodified lines | ||
| 50 | 53 | draftId, |
| 51 | 54 | activeThreadTitle, |
| 52 | 55 | activeProjectName, |
| 53 | − isGitRepo, | |
| 56 | + isVcsRepo, | |
| 57 | + vcsKind, | |
| 54 | 58 | openInCwd, |
| 55 | 59 | activeProjectScripts, |
| 56 | 60 | preferredScriptId, |
| @@ -72,6 +76,7 @@ export const ChatHeader = memo(function ChatHeader({ · 15 unmodified lines | ||
| 72 | 76 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 73 | 77 | const isRemoteEnvironment = |
| 74 | 78 | primaryEnvironmentId !== null && activeThreadEnvironmentId !== primaryEnvironmentId; |
| 79 | + const vcsTerms = resolveVcsTerms(vcsKind); | |
| 75 | 80 | |
| 76 | 81 | return ( |
| 77 | 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 | 93 | <span className="min-w-0 truncate">{activeProjectName}</span> |
| 89 | 94 | </Badge> |
| 90 | 95 | )} |
| 91 | − {activeProjectName && !isGitRepo && ( | |
| 96 | + {activeProjectName && !isVcsRepo && ( | |
| 92 | 97 | <Badge variant="outline" className="shrink-0 text-[10px] text-amber-700"> |
| 93 | − No Git | |
| 98 | + No repository | |
| 94 | 99 | </Badge> |
| 95 | 100 | )} |
| 96 | 101 | </div> |
| @@ -154,15 +159,15 @@ export const ChatHeader = memo(function ChatHeader({ · 57 unmodified lines | ||
| 154 | 159 | aria-label="Toggle diff panel" |
| 155 | 160 | variant="outline" |
| 156 | 161 | size="xs" |
| 157 | − disabled={!isGitRepo && !diffOpen} | |
| 162 | + disabled={!isVcsRepo && !diffOpen} | |
| 158 | 163 | > |
| 159 | 164 | <DiffIcon className="size-3" /> |
| 160 | 165 | </Toggle> |
| 161 | 166 | } |
| 162 | 167 | /> |
| 163 | 168 | <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}.` | |
| 166 | 171 | : diffToggleShortcutLabel |
| 167 | 172 | ? `Toggle diff panel (${diffToggleShortcutLabel})` |
| 168 | 173 | : "Toggle diff panel"} |
| @@ -40,6 +40,7 @@ const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const; · 39 unmodified lines | ||
| 40 | 40 | const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const; |
| 41 | 41 | |
| 42 | 42 | const BASE_STATUS: VcsStatusResult = { |
| 43 | + kind: "git", | |
| 43 | 44 | isRepo: true, |
| 44 | 45 | hasPrimaryRemote: true, |
| 45 | 46 | isDefaultRef: false, |
| @@ -64,7 +64,7 @@ function normalizeCheckpointErrorMessage(error: unknown): string { · 63 unmodified lines | ||
| 64 | 64 | |
| 65 | 65 | const lower = message.toLowerCase(); |
| 66 | 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 | 70 | if ( |
| @@ -262,6 +262,7 @@ const baseServerConfig: ServerConfig = { · 261 unmodified lines | ||
| 262 | 262 | }; |
| 263 | 263 | |
| 264 | 264 | const baseGitStatus: VcsStatusResult = { |
| 265 | + kind: "git", | |
| 265 | 266 | isRepo: true, |
| 266 | 267 | hasPrimaryRemote: true, |
| 267 | 268 | isDefaultRef: false, |
| @@ -19,6 +19,7 @@ import { createWsRpcClient } from "./wsRpcClient"; · 18 unmodified lines | ||
| 19 | 19 | import { type WsTransport } from "./wsTransport"; |
| 20 | 20 | |
| 21 | 21 | const baseLocalStatus: VcsStatusLocalResult = { |
| 22 | + kind: "git", | |
| 22 | 23 | isRepo: true, |
| 23 | 24 | hasPrimaryRemote: true, |
| 24 | 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 | 1 | import { Schema } from "effect"; |
| 2 | 2 | import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | 3 | import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; |
| 4 | +import { VcsDriverKind } from "./vcs.ts"; | |
| 4 | 5 | |
| 5 | 6 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 6 | 7 | const GIT_LIST_BRANCHES_MAX_LIMIT = 200; |
| @@ -194,6 +195,7 @@ const VcsStatusChangeRequest = Schema.Struct({ · 187 unmodified lines | ||
| 194 | 195 | }); |
| 195 | 196 | |
| 196 | 197 | const VcsStatusLocalShape = { |
| 198 | + kind: VcsDriverKind, | |
| 197 | 199 | isRepo: Schema.Boolean, |
| 198 | 200 | sourceControlProvider: Schema.optional(SourceControlProviderInfo), |
| 199 | 201 | hasPrimaryRemote: Schema.Boolean, |
| @@ -81,6 +81,7 @@ describe("applyGitStatusStreamEvent", () => { · 80 unmodified lines | ||
| 81 | 81 | }; |
| 82 | 82 | |
| 83 | 83 | expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({ |
| 84 | + kind: "unknown", | |
| 84 | 85 | isRepo: true, |
| 85 | 86 | hasPrimaryRemote: false, |
| 86 | 87 | isDefaultRef: false, |
| @@ -96,6 +97,7 @@ describe("applyGitStatusStreamEvent", () => { · 9 unmodified lines | ||
| 96 | 97 | |
| 97 | 98 | it("preserves local-only fields when applying a remote update", () => { |
| 98 | 99 | const current: VcsStatusResult = { |
| 100 | + kind: "git", | |
| 99 | 101 | isRepo: true, |
| 100 | 102 | sourceControlProvider: { |
| 101 | 103 | kind: "github", |
| @@ -226,6 +226,7 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { · 225 unmodified lines | ||
| 226 | 226 | |
| 227 | 227 | function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { |
| 228 | 228 | return { |
| 229 | + kind: status.kind, | |
| 229 | 230 | isRepo: status.isRepo, |
| 230 | 231 | ...(status.sourceControlProvider |
| 231 | 232 | ? { sourceControlProvider: status.sourceControlProvider } |
| @@ -251,6 +252,7 @@ export function applyGitStatusStreamEvent( · 19 unmodified lines | ||
| 251 | 252 | if (current === null) { |
| 252 | 253 | return mergeGitStatusParts( |
| 253 | 254 | { |
| 255 | + kind: "unknown", | |
| 254 | 256 | isRepo: true, |
| 255 | 257 | hasPrimaryRemote: false, |
| 256 | 258 | isDefaultRef: false, |