diff --git a/.github/actions/setup-jj/action.yml b/.github/actions/setup-jj/action.yml new file mode 100644 index 0000000000..d54462aa5b --- /dev/null +++ b/.github/actions/setup-jj/action.yml @@ -0,0 +1,49 @@ +name: Setup Jujutsu +description: Install the jj CLI from the official jj-vcs release artifacts. + +inputs: + version: + description: Jujutsu release tag to install. + required: false + default: v0.40.0 + +runs: + using: composite + steps: + - name: Install jj + shell: bash + env: + JJ_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + case "${RUNNER_OS}-${RUNNER_ARCH}" in + Linux-X64) + asset="jj-${JJ_VERSION}-x86_64-unknown-linux-musl.tar.gz" + ;; + Linux-ARM64) + asset="jj-${JJ_VERSION}-aarch64-unknown-linux-musl.tar.gz" + ;; + macOS-X64) + asset="jj-${JJ_VERSION}-x86_64-apple-darwin.tar.gz" + ;; + macOS-ARM64) + asset="jj-${JJ_VERSION}-aarch64-apple-darwin.tar.gz" + ;; + *) + echo "Unsupported runner for jj install: ${RUNNER_OS}-${RUNNER_ARCH}" >&2 + exit 1 + ;; + esac + + install_dir="${RUNNER_TEMP}/jj-${JJ_VERSION}" + mkdir -p "$install_dir" + + curl -fsSL \ + "https://github.com/jj-vcs/jj/releases/download/${JJ_VERSION}/${asset}" \ + -o "${RUNNER_TEMP}/${asset}" + tar -xzf "${RUNNER_TEMP}/${asset}" -C "$install_dir" + + chmod +x "${install_dir}/jj" + echo "$install_dir" >> "$GITHUB_PATH" + "${install_dir}/jj" --version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3329b1dad..ef90a78be0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,9 @@ jobs: with: node-version-file: package.json + - name: Setup Jujutsu + uses: ./.github/actions/setup-jj + - name: Cache Bun and Turbo uses: actions/cache@v5 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4409c54c8e..e06f9c3dce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,6 +95,9 @@ jobs: with: node-version-file: package.json + - name: Setup Jujutsu + uses: ./.github/actions/setup-jj + - name: Install dependencies run: bun install --frozen-lockfile diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 8945294555..6ef5211b23 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -331,6 +331,7 @@ export const makeOrchestrationIntegrationHarness = ( refreshLocalStatus: () => Effect.succeed({ isRepo: true, + kind: "git", hasPrimaryRemote: false, isDefaultRef: true, refName: "main", diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index b05384c685..0ca767fdf6 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -869,6 +869,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(status).toEqual({ isRepo: false, + kind: "unknown", hasPrimaryRemote: false, isDefaultRef: false, refName: null, @@ -899,6 +900,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(status).toEqual({ isRepo: false, + kind: "unknown", hasPrimaryRemote: false, isDefaultRef: false, refName: null, @@ -3186,6 +3188,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect.objectContaining({ kind: "hook_finished", hookName: "pre-commit", + exitCode: 0, }), expect.objectContaining({ kind: "action_finished", diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index b77ee5169b..b0a36bbfa2 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -724,6 +724,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { : null; return { + kind: details.isRepo ? ("git" as const) : ("unknown" as const), isRepo: details.isRepo, ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}), hasPrimaryRemote: details.hasOriginRemote, diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index bc0624beaf..663d1d0f43 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -26,6 +26,7 @@ describe("GitWorkflowService", () => { const status = yield* workflow.localStatus({ cwd: "/not-a-repo" }); assert.deepStrictEqual(status, { + kind: "unknown", isRepo: false, hasPrimaryRemote: false, isDefaultRef: false, @@ -52,6 +53,7 @@ describe("GitWorkflowService", () => { const status = yield* workflow.status({ cwd: "/not-a-repo" }); assert.deepStrictEqual(status, { + kind: "unknown", isRepo: false, hasPrimaryRemote: false, isDefaultRef: false, diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 70ab6eecf1..c8e65e3ab0 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -3,6 +3,7 @@ import { Context, Effect, Layer } from "effect"; import { GitManagerError, GitCommandError, + type VcsDriverKind, type VcsSwitchRefInput, type VcsSwitchRefResult, type VcsCreateRefInput, @@ -29,6 +30,7 @@ import { import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; +import { mergeGitStatusParts } from "@t3tools/shared/git"; export interface GitWorkflowServiceShape { readonly status: ( @@ -91,31 +93,7 @@ const unsupportedGitCommand = (operation: string, cwd: string, detail: string) = detail, }); -function nonRepositoryLocalStatus(): VcsStatusLocalResult { - return { - isRepo: false, - hasPrimaryRemote: false, - isDefaultRef: false, - refName: null, - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - }; -} - -function nonRepositoryStatus(): VcsStatusResult { - return { - ...nonRepositoryLocalStatus(), - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - aheadOfDefaultCount: 0, - pr: null, - }; -} +const emptyWorkingTree = { files: [], insertions: 0, deletions: 0 } as const; function nonRepositoryListRefs(): VcsListRefsResult { return { @@ -127,6 +105,16 @@ function nonRepositoryListRefs(): VcsListRefsResult { }; } +const nonGitLocalStatus = (kind: VcsDriverKind, isRepo: boolean): VcsStatusLocalResult => ({ + kind, + isRepo, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: emptyWorkingTree, +}); + export const make = Effect.fn("makeGitWorkflowService")(function* () { const registry = yield* VcsDriverRegistry; const git = yield* GitVcsDriver; @@ -180,33 +168,6 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { } }); - const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")( - function* (operation: string, cwd: string) { - const handle = yield* registry - .detect({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitWorkflow( - operation, - cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); - if (!handle) { - return false; - } - if (handle.kind !== "git") { - return yield* unsupportedGitWorkflow( - operation, - cwd, - `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, - ); - } - return true; - }, - ); - const detectGitRepositoryForCommand = Effect.fn( "GitWorkflowService.detectGitRepositoryForCommand", )(function* (operation: string, cwd: string) { @@ -242,27 +203,60 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { (input: Input) => ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); - return GitWorkflowService.of({ - status: (input) => - detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( - Effect.flatMap((isGitRepository) => - isGitRepository ? gitManager.status(input) : Effect.succeed(nonRepositoryStatus()), - ), - ), - localStatus: (input) => - detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe( - Effect.flatMap((isGitRepository) => - isGitRepository - ? gitManager.localStatus(input) - : Effect.succeed(nonRepositoryLocalStatus()), + const localStatus: GitWorkflowServiceShape["localStatus"] = Effect.fn( + "GitWorkflowService.localStatus", + )(function* (input) { + const handle = yield* registry + .detect({ cwd: input.cwd }) + .pipe( + Effect.mapError((error) => + unsupportedGitWorkflow( + "GitWorkflowService.localStatus", + input.cwd, + error instanceof Error ? error.message : String(error), + ), ), - ), - remoteStatus: (input) => - detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( - Effect.flatMap((isGitRepository) => - isGitRepository ? gitManager.remoteStatus(input) : Effect.succeed(null), + ); + if (!handle) { + return nonGitLocalStatus("unknown", false); + } + if (handle.kind === "git") { + return yield* gitManager.localStatus(input); + } + return nonGitLocalStatus(handle.kind, true); + }); + + const remoteStatus: GitWorkflowServiceShape["remoteStatus"] = Effect.fn( + "GitWorkflowService.remoteStatus", + )(function* (input) { + const handle = yield* registry + .detect({ cwd: input.cwd }) + .pipe( + Effect.mapError((error) => + unsupportedGitWorkflow( + "GitWorkflowService.remoteStatus", + input.cwd, + error instanceof Error ? error.message : String(error), + ), ), - ), + ); + if (handle?.kind === "git") { + return yield* gitManager.remoteStatus(input); + } + return null; + }); + + const status: GitWorkflowServiceShape["status"] = Effect.fn("GitWorkflowService.status")( + function* (input) { + const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]); + return mergeGitStatusParts(local, remote); + }, + ); + + return GitWorkflowService.of({ + status, + localStatus, + remoteStatus, invalidateLocalStatus: gitManager.invalidateLocalStatus, invalidateRemoteStatus: gitManager.invalidateRemoteStatus, invalidateStatus: gitManager.invalidateStatus, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index ad5fb59bd1..fe260ffa99 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -291,6 +291,7 @@ describe("CheckpointReactor", () => { }).pipe( Effect.as({ isRepo: true, + kind: "git", hasPrimaryRemote: false, isDefaultRef: true, refName: "main", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 09252571c3..800d4aa24e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -240,6 +240,7 @@ describe("ProviderCommandReactor", () => { const refreshStatus = vi.fn((_: string) => Effect.succeed({ isRepo: true, + kind: "git" as const, hasPrimaryRemote: true, isDefaultRef: false, refName: "renamed-branch", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a1064752e2..72f3d77574 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -54,6 +54,17 @@ import { vi } from "vitest"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const testGitRepository = (rootPath: string) => ({ + kind: "git" as const, + rootPath, + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, +}); + import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; @@ -2373,6 +2384,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest({ layers: { + vcsDriver: { + detectRepository: () => Effect.succeed(testGitRepository("/tmp/repo")), + isInsideWorkTree: () => Effect.succeed(true), + }, gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, @@ -2380,6 +2395,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, + kind: "git", hasPrimaryRemote: true, isDefaultRef: true, refName: "main", @@ -2396,6 +2412,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { status: () => Effect.succeed({ isRepo: true, + kind: "git", hasPrimaryRemote: true, isDefaultRef: true, refName: "main", @@ -2509,9 +2526,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { createRef: (input) => Effect.succeed({ refName: input.refName }), switchRef: (input) => Effect.succeed({ refName: input.refName }), }, - vcsDriver: { - isInsideWorkTree: () => Effect.succeed(true), - }, }, }); @@ -2652,6 +2666,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, + kind: "git", hasPrimaryRemote: true, isDefaultRef: true, refName: "main", @@ -2673,6 +2688,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { statusCalls += 1; return { isRepo: true, + kind: "git", hasPrimaryRemote: true, isDefaultRef: true, refName: "main", @@ -2729,6 +2745,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, + kind: "git", hasPrimaryRemote: true, isDefaultRef: false, refName: "feature/demo", @@ -2750,6 +2767,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { statusCalls += 1; return { isRepo: true, + kind: "git", hasPrimaryRemote: true, isDefaultRef: false, refName: "feature/demo", @@ -2787,6 +2805,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest({ layers: { + vcsDriver: { + detectRepository: () => Effect.succeed(testGitRepository("/tmp/repo")), + }, gitVcsDriver: { pullCurrentBranch: () => Effect.succeed({ @@ -2801,6 +2822,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, + kind: "git", hasPrimaryRemote: true, isDefaultRef: true, refName: "main", @@ -2840,6 +2862,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { layers: { vcsDriver: { isInsideWorkTree: () => Effect.succeed(true), + detectRepository: () => Effect.succeed(testGitRepository("/tmp/repo")), }, gitManager: { invalidateLocalStatus: () => Effect.void, @@ -2847,6 +2870,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, + kind: "git", hasPrimaryRemote: true, isDefaultRef: false, refName: "feature/demo", @@ -2916,6 +2940,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { layers: { vcsDriver: { isInsideWorkTree: () => Effect.succeed(true), + detectRepository: () => Effect.succeed(testGitRepository("/tmp/repo")), }, gitManager: { invalidateLocalStatus: () => Effect.void, @@ -2926,6 +2951,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.andThen( Effect.succeed({ isRepo: true, + kind: "git" as const, hasPrimaryRemote: true, isDefaultRef: false, refName: "feature/demo", @@ -3592,6 +3618,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const refreshStatus = vi.fn((_: string) => Effect.succeed({ isRepo: true, + kind: "git" as const, hasPrimaryRemote: true, isDefaultRef: false, refName: "t3code/bootstrap-refName", diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 501edff988..dcb6a10828 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -73,7 +73,7 @@ Logged in to github.com account juliusmarminge (keyring) })), [ { kind: "git", implemented: true, status: "available" }, - { kind: "jj", implemented: false, status: "missing" }, + { kind: "jj", implemented: true, status: "missing" }, ], ); assert.deepStrictEqual( diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 1e94a09001..facaa2d2d3 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -60,7 +60,7 @@ const VCS_PROBES: ReadonlyArray = [ label: "Jujutsu", executable: "jj", versionArgs: ["--version"], - implemented: false, + implemented: true, installHint: "Install Jujutsu with `brew install jj` or from https://github.com/jj-vcs/jj.", }, ]; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 661490b71a..a40ffc632a 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1,4 +1,4 @@ -import { Context, DateTime, Effect, Layer, Option } from "effect"; +import { Context, Effect, Layer, Option } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -20,6 +20,7 @@ import { } from "@t3tools/contracts"; import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; +import { nowFreshness } from "./VcsFreshness.ts"; import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; export interface ExecuteGitInput { @@ -220,15 +221,6 @@ const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ "core.untrackedCache=false", ] as const; -const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () { - const now = yield* DateTime.now; - return { - source: "live-local" as const, - observedAt: now, - expiresAt: Option.none(), - }; -}); - function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { const parts = input.split("\0"); if (parts.length === 0) return []; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index dd71d53f90..887f363786 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -449,7 +449,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( if (event === "child_exit") { hookStartByChildKey.delete(childKey); - const code = traceRecord.success.exitCode; + const code = traceRecord.success.code; const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null; const now = yield* DateTime.now; const durationMs = started @@ -1341,6 +1341,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const status: GitVcsDriverShape["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ + kind: "git" as const, isRepo: details.isRepo, hasPrimaryRemote: details.hasOriginRemote, isDefaultRef: details.isDefaultBranch, diff --git a/apps/server/src/vcs/JjVcsDriver.test.ts b/apps/server/src/vcs/JjVcsDriver.test.ts new file mode 100644 index 0000000000..83f8fe0cc2 --- /dev/null +++ b/apps/server/src/vcs/JjVcsDriver.test.ts @@ -0,0 +1,193 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { describe } from "vitest"; + +import type { VcsError } from "@t3tools/contracts"; +import type { VcsProcessInput, VcsProcessOutput } from "./VcsProcess.ts"; +import { VcsProcess, layer as VcsProcessLayer } from "./VcsProcess.ts"; +import * as JjVcsDriver from "./JjVcsDriver.ts"; +import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts"; + +const JjContractLayer = JjVcsDriver.vcsLayer.pipe( + Layer.provideMerge(VcsProcessLayer), + Layer.provideMerge(NodeServices.layer), +); + +const commandCalls = (calls: ReadonlyArray) => + calls.map((call) => [call.command].concat(call.args)); + +const processOutput = (stdout: string, exitCode = 0, stderr = ""): VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(exitCode), + stdout, + stderr, + stdoutTruncated: false, + stderrTruncated: false, +}); + +const runJj = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const process = yield* VcsProcess; + yield* process.run({ + operation: "JjVcsDriver.contract.jj", + command: "jj", + cwd, + args, + timeoutMs: 10_000, + }); + }); + +type JjContractError = PlatformError.PlatformError | VcsError; + +runVcsDriverContractSuite({ + name: "JJ", + kind: "jj", + layer: JjContractLayer, + fixture: { + createRepo: (cwd) => runJj(cwd, ["git", "init", "--colocate"]), + writeFile: (cwd, relativePath, contents) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true }); + yield* fileSystem.writeFileString(absolutePath, contents); + }), + ignorePath: (cwd, pattern) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.writeFileString(path.join(cwd, ".gitignore"), `${pattern}\n`); + }), + }, +}); + +describe("JjVcsDriver", () => { + it.effect("detects repository identity with jj root", () => { + const calls: VcsProcessInput[] = []; + + return Effect.gen(function* () { + const driver = yield* JjVcsDriver.makeVcsDriverShape(); + const identity = yield* driver.detectRepository("/repo/src"); + + assert.equal(identity?.kind, "jj"); + assert.equal(identity?.rootPath, "/repo"); + assert.equal(identity?.metadataPath, "/repo/.jj"); + assert.deepStrictEqual(commandCalls(calls), [["jj", "--no-pager", "root"]]); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(VcsProcess)({ + run: (input) => + Effect.sync(() => { + calls.push(input); + return processOutput("/repo\n"); + }), + }), + NodeServices.layer, + ), + ), + ); + }); + + it.effect("lists workspace files using jj file list", () => { + let observedInput: VcsProcessInput | null = null; + + return Effect.gen(function* () { + const driver = yield* JjVcsDriver.makeVcsDriverShape(); + const result = yield* driver.listWorkspaceFiles("/repo"); + + assert.deepStrictEqual(result.paths, ["README.md", "src/index.ts"]); + assert.deepStrictEqual(observedInput?.args, ["--no-pager", "file", "list"]); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(VcsProcess)({ + run: (input) => + Effect.sync(() => { + observedInput = input; + return processOutput("README.md\nsrc/index.ts\n"); + }), + }), + NodeServices.layer, + ), + ), + ); + }); + + it.effect("filters paths with the git ignore oracle", () => { + const calls: VcsProcessInput[] = []; + + return Effect.gen(function* () { + const driver = yield* JjVcsDriver.makeVcsDriverShape(); + const result = yield* driver.filterIgnoredPaths("/repo", [ + "keep.ts", + "debug.log", + "src/index.ts", + ]); + + assert.deepStrictEqual(result, ["keep.ts", "src/index.ts"]); + assert.equal(calls[0]?.command, "git"); + assert.deepStrictEqual(calls[0]?.args.slice(-2), ["init", "--bare"]); + assert.equal(calls[1]?.command, "git"); + assert.deepStrictEqual(calls[1]?.args.slice(-4), [ + "check-ignore", + "--no-index", + "-z", + "--stdin", + ]); + assert.equal(calls[1]?.stdin, "keep.ts\0debug.log\0src/index.ts\0"); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(VcsProcess)({ + run: (input) => + Effect.sync(() => { + calls.push(input); + if (input.command === "git" && input.args.includes("check-ignore")) { + return processOutput("debug.log\0"); + } + return processOutput(""); + }), + }), + NodeServices.layer, + ), + ), + ); + }); + + it.effect("forwards execute env to the VCS process", () => { + let observedEnv: NodeJS.ProcessEnv | undefined; + + return Effect.gen(function* () { + const driver = yield* JjVcsDriver.makeVcsDriverShape(); + + yield* driver.execute({ + operation: "JjVcsDriver.test.env", + cwd: "/repo", + args: ["status"], + env: { + JJ_CONFIG: "/tmp/t3-jj-config.toml", + }, + }); + + assert.deepStrictEqual(observedEnv, { + JJ_CONFIG: "/tmp/t3-jj-config.toml", + }); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(VcsProcess)({ + run: (input) => + Effect.sync(() => { + observedEnv = input.env; + return processOutput(""); + }), + }), + NodeServices.layer, + ), + ), + ); + }); +}); diff --git a/apps/server/src/vcs/JjVcsDriver.ts b/apps/server/src/vcs/JjVcsDriver.ts new file mode 100644 index 0000000000..449275de6e --- /dev/null +++ b/apps/server/src/vcs/JjVcsDriver.ts @@ -0,0 +1,517 @@ +import { Context, Effect, FileSystem, Layer, Option } from "effect"; + +import { VcsOutputDecodeError, VcsProcessExitError, type VcsError } from "@t3tools/contracts"; +import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; +import { nowFreshness } from "./VcsFreshness.ts"; +import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; + +export interface JjVcsDriverShape extends VcsDriverShape { + readonly capabilities: VcsDriverShape["capabilities"] & { + readonly kind: "jj"; + readonly supportsBookmarks: true; + readonly supportsAtomicSnapshot: true; + readonly supportsWorktrees: false; + readonly ignoreClassifier: "git-compatible-fallback"; + }; + readonly currentChange: (cwd: string) => Effect.Effect; + readonly listBookmarks: (cwd: string) => Effect.Effect, VcsError>; + readonly listWorkspaces: (cwd: string) => Effect.Effect, VcsError>; +} + +export interface JjCurrentChange { + readonly changeId: string; + readonly commitId: string | null; + readonly description: string | null; +} + +export interface JjBookmark { + readonly name: string; + readonly target: string | null; +} + +export interface JjWorkspace { + readonly name: string; + readonly path: string | null; +} + +export class JjVcsDriver extends Context.Service()( + "t3/vcs/JjVcsDriver", +) {} + +const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; + +function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { + const parts = input.split("\0"); + if (parts.length === 0) return []; + + if (truncated && parts[parts.length - 1]?.length) { + parts.pop(); + } + + return parts.filter((value) => value.length > 0); +} + +function splitLineSeparatedPaths(input: string, truncated: boolean): string[] { + const lines = input.split(/\r?\n/g); + if (truncated && lines[lines.length - 1]?.length) { + lines.pop(); + } + + return lines.map((line) => line.trim()).filter((line) => line.length > 0); +} + +function parseJjRemoteList(output: string): Array<{ name: string; url: string }> { + return output + .split(/\r?\n/g) + .map((line) => line.trim()) + .flatMap((line) => { + if (line.length === 0) { + return []; + } + + const [name, ...urlParts] = line.split(/\s+/g); + const url = urlParts.join(" ").trim(); + return name && url ? [{ name, url }] : []; + }); +} + +function parseNullRecord(record: string): string[] { + return record.split("\0").map((value) => value.trim()); +} + +function decodeJjCurrentChange(raw: string, cwd: string): JjCurrentChange | null { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return null; + } + + const [changeId, commitId, description] = parseNullRecord(trimmed); + if (!changeId) { + throw new VcsOutputDecodeError({ + operation: "JjVcsDriver.currentChange", + command: "jj log", + cwd, + detail: "jj current change output did not include a change id", + }); + } + + return { + changeId, + commitId: commitId || null, + description: description || null, + }; +} + +function decodeJjBookmarkList(raw: string): ReadonlyArray { + return raw + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const [name, target] = parseNullRecord(line); + return { + name: name ?? line, + target: target || null, + }; + }); +} + +function decodeJjWorkspaceList(raw: string): ReadonlyArray { + return raw + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const [name, path] = parseNullRecord(line); + return { + name: name ?? line, + path: path || null, + }; + }); +} + +function chunkPathsForCheckIgnore(relativePaths: ReadonlyArray): string[][] { + const chunks: string[][] = []; + let chunk: string[] = []; + let chunkBytes = 0; + + for (const relativePath of relativePaths) { + const relativePathBytes = Buffer.byteLength(relativePath) + 1; + if (chunk.length > 0 && chunkBytes + relativePathBytes > CHECK_IGNORE_MAX_STDIN_BYTES) { + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + } + + chunk.push(relativePath); + chunkBytes += relativePathBytes; + + if (chunkBytes >= CHECK_IGNORE_MAX_STDIN_BYTES) { + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + } + } + + if (chunk.length > 0) { + chunks.push(chunk); + } + + return chunks; +} + +const processCommand = ( + process: VcsProcessShape, + command: string, + operation: string, + cwd: string, + args: ReadonlyArray, + options?: { + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly truncateOutputAtMaxBytes?: boolean; + }, +) => + process.run({ + operation, + command, + args, + cwd, + ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}), + ...(options?.env !== undefined ? { env: options.env } : {}), + ...(options?.allowNonZeroExit !== undefined + ? { allowNonZeroExit: options.allowNonZeroExit } + : {}), + ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), + ...(options?.truncateOutputAtMaxBytes !== undefined + ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } + : {}), + }); + +const jjCommand = ( + process: VcsProcessShape, + operation: string, + cwd: string, + args: ReadonlyArray, + options?: { + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly truncateOutputAtMaxBytes?: boolean; + }, +) => processCommand(process, "jj", operation, cwd, ["--no-pager", ...args], options); + +const gitCommand = ( + process: VcsProcessShape, + operation: string, + cwd: string, + args: ReadonlyArray, + options?: Parameters[5], +) => processCommand(process, "git", operation, cwd, args, options); + +function fileSystemError(operation: string, cwd: string, detail: string, cause: unknown) { + return new VcsOutputDecodeError({ + operation, + command: "git check-ignore", + cwd, + detail, + cause, + }); +} + +const makeScopedTempGitDir = (fileSystem: FileSystem.FileSystem, operation: string, cwd: string) => + fileSystem + .makeTempDirectoryScoped({ prefix: "t3-jj-check-ignore-" }) + .pipe( + Effect.mapError((cause) => + fileSystemError(operation, cwd, "failed to create temp git dir", cause), + ), + ); + +export const makeVcsDriverShape = Effect.fn("makeJjVcsDriverShape")(function* () { + const process = yield* VcsProcess; + const fileSystem = yield* FileSystem.FileSystem; + const capabilities = { + kind: "jj" as const, + supportsWorktrees: false as const, + supportsBookmarks: true as const, + supportsAtomicSnapshot: true as const, + supportsPushDefaultRemote: false as const, + ignoreClassifier: "git-compatible-fallback" as const, + }; + + const isInsideWorkTree: VcsDriverShape["isInsideWorkTree"] = (cwd) => + jjCommand(process, "JjVcsDriver.isInsideWorkTree", cwd, ["root"], { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }).pipe( + Effect.map((result) => result.exitCode === 0 && result.stdout.trim().length > 0), + Effect.catch(() => Effect.succeed(false)), + ); + + const execute: VcsDriverShape["execute"] = (input) => + jjCommand(process, input.operation, input.cwd, input.args, { + ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), + ...(input.env !== undefined ? { env: input.env } : {}), + ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), + ...(input.truncateOutputAtMaxBytes !== undefined + ? { truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes } + : {}), + }); + + const initRepository: VcsDriverShape["initRepository"] = (input) => + jjCommand(process, "JjVcsDriver.initRepository", input.cwd, ["git", "init"]); + + const detectRepository: VcsDriverShape["detectRepository"] = Effect.fn("detectRepository")( + function* (cwd) { + const root = yield* jjCommand(process, "JjVcsDriver.detectRepository.root", cwd, ["root"], { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }).pipe(Effect.catch(() => Effect.succeed(null))); + if (!root || root.exitCode !== 0) { + return null; + } + + const rootPath = root.stdout.trim(); + if (rootPath.length === 0) { + return null; + } + + return { + kind: "jj" as const, + rootPath, + metadataPath: `${rootPath.replace(/[\\/]$/g, "")}/.jj`, + freshness: yield* nowFreshness(), + }; + }, + ); + + const listWorkspaceFiles: VcsDriverShape["listWorkspaceFiles"] = (cwd) => + jjCommand(process, "JjVcsDriver.listWorkspaceFiles", cwd, ["file", "list"], { + allowNonZeroExit: true, + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }).pipe( + Effect.flatMap((result) => + result.exitCode === 0 + ? Effect.gen(function* () { + return { + paths: splitLineSeparatedPaths(result.stdout, result.stdoutTruncated), + truncated: result.stdoutTruncated, + freshness: yield* nowFreshness(), + }; + }) + : Effect.fail( + new VcsProcessExitError({ + operation: "JjVcsDriver.listWorkspaceFiles", + command: "jj file list", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "jj file list failed", + }), + ), + ), + ); + + const listRemotes: VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")(function* (cwd) { + const result = yield* jjCommand( + process, + "JjVcsDriver.listRemotes", + cwd, + ["git", "remote", "list"], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + }, + ); + + if (result.exitCode !== 0) { + return { + remotes: [], + freshness: yield* nowFreshness(), + }; + } + + return { + remotes: parseJjRemoteList(result.stdout).map((remote) => ({ + name: remote.name, + url: remote.url, + pushUrl: Option.none(), + isPrimary: remote.name === "origin", + })), + freshness: yield* nowFreshness(), + }; + }); + + const currentChange: JjVcsDriverShape["currentChange"] = (cwd) => + jjCommand( + process, + "JjVcsDriver.currentChange", + cwd, + [ + "log", + "-r", + "@", + "--no-graph", + "--template", + 'change_id ++ "\\0" ++ commit_id ++ "\\0" ++ description.first_line()', + ], + { + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + }, + ).pipe(Effect.map((result) => decodeJjCurrentChange(result.stdout, cwd))); + + const listBookmarks: JjVcsDriverShape["listBookmarks"] = (cwd) => + jjCommand( + process, + "JjVcsDriver.listBookmarks", + cwd, + ["bookmark", "list", "--template", 'name ++ "\\0" ++ target.commit_id() ++ "\\n"'], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 256 * 1024, + }, + ).pipe( + Effect.map((result) => (result.exitCode === 0 ? decodeJjBookmarkList(result.stdout) : [])), + ); + + const listWorkspaces: JjVcsDriverShape["listWorkspaces"] = (cwd) => + jjCommand( + process, + "JjVcsDriver.listWorkspaces", + cwd, + ["workspace", "list", "--template", 'name ++ "\\0" ++ root ++ "\\n"'], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 256 * 1024, + }, + ).pipe( + Effect.map((result) => (result.exitCode === 0 ? decodeJjWorkspaceList(result.stdout) : [])), + ); + + const filterIgnoredPaths: VcsDriverShape["filterIgnoredPaths"] = Effect.fn("filterIgnoredPaths")( + function* (cwd, relativePaths) { + if (relativePaths.length === 0) { + return relativePaths; + } + + const operation = "JjVcsDriver.filterIgnoredPaths"; + const ignoredPaths = new Set(); + + yield* Effect.scoped( + Effect.gen(function* () { + const gitDir = yield* makeScopedTempGitDir(fileSystem, operation, cwd); + const initResult = yield* gitCommand( + process, + operation, + cwd, + ["--git-dir", gitDir, "init", "--bare"], + { + allowNonZeroExit: true, + }, + ); + if (initResult.exitCode !== 0) { + return yield* new VcsProcessExitError({ + operation, + command: "git init --bare", + cwd, + exitCode: initResult.exitCode, + detail: initResult.stderr.trim() || "git init --bare failed", + }); + } + + for (const chunk of chunkPathsForCheckIgnore(relativePaths)) { + const result = yield* gitCommand( + process, + operation, + cwd, + [ + "--git-dir", + gitDir, + "--work-tree", + cwd, + "check-ignore", + "--no-index", + "-z", + "--stdin", + ], + { + stdin: `${chunk.join("\0")}\0`, + allowNonZeroExit: true, + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ); + + if (result.exitCode !== 0 && result.exitCode !== 1) { + return yield* new VcsProcessExitError({ + operation, + command: "git check-ignore", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git check-ignore failed", + }); + } + + for (const ignoredPath of splitNullSeparatedPaths( + result.stdout, + result.stdoutTruncated, + )) { + ignoredPaths.add(ignoredPath); + } + } + }), + ); + + if (ignoredPaths.size === 0) { + return relativePaths; + } + + return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); + }, + ); + + return { + capabilities, + execute, + initRepository, + detectRepository, + isInsideWorkTree, + listWorkspaceFiles, + listRemotes, + filterIgnoredPaths, + currentChange, + listBookmarks, + listWorkspaces, + } satisfies JjVcsDriverShape; +}); + +export const makeJjVcsDriver = Effect.fn("makeJjVcsDriver")(function* () { + const driver = yield* makeVcsDriverShape(); + return JjVcsDriver.of(driver); +}); + +export const makeVcsDriver = Effect.fn("makeJjGenericVcsDriver")(function* () { + const driver = yield* makeVcsDriverShape(); + return VcsDriver.of(driver); +}); + +export const layer = Layer.effect(JjVcsDriver, makeJjVcsDriver()); +export const vcsLayer = Layer.effect(VcsDriver, makeVcsDriver()); diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 8e482c46b8..485dfc06c0 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -7,10 +8,13 @@ import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "./VcsPr import { VcsProjectConfig } from "./VcsProjectConfig.ts"; import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts"; -const processOutput = (stdout: string): VcsProcessOutput => ({ - exitCode: ChildProcessSpawner.ExitCode(0), +const commandCalls = (calls: ReadonlyArray) => + calls.map((call) => [call.command].concat(call.args)); + +const processOutput = (stdout: string, exitCode = 0, stderr = ""): VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(exitCode), stdout, - stderr: "", + stderr, stdoutTruncated: false, stderrTruncated: false, }); @@ -28,6 +32,7 @@ describe("VcsDriverRegistry", () => { run: () => Effect.succeed(processOutput("")), }), ), + Layer.provide(NodeServices.layer), ); return Effect.gen(function* () { @@ -65,6 +70,7 @@ describe("VcsDriverRegistry", () => { }), }), ), + Layer.provide(NodeServices.layer), ); return Effect.gen(function* () { @@ -84,4 +90,75 @@ describe("VcsDriverRegistry", () => { ); }).pipe(Effect.provide(layer)); }); + + it.effect("resolves an explicitly requested jj repository", () => { + const calls: VcsProcessInput[] = []; + const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe( + Layer.provide( + Layer.mock(VcsProjectConfig)({ + resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), + }), + ), + Layer.provide( + Layer.mock(VcsProcess)({ + run: (input) => + Effect.sync(() => { + calls.push(input); + const command = input.args.join(" "); + if (input.command === "jj" && command === "--no-pager root") { + return processOutput("/repo\n"); + } + return processOutput("", 1, "not found"); + }), + }), + ), + Layer.provide(NodeServices.layer), + ); + + return Effect.gen(function* () { + const registry = yield* VcsDriverRegistry; + const handle = yield* registry.resolve({ cwd: "/repo", requestedKind: "jj" }); + + assert.equal(handle.kind, "jj"); + assert.equal(handle.repository.rootPath, "/repo"); + assert.deepStrictEqual(commandCalls(calls), [["jj", "--no-pager", "root"]]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("prefers jj auto-detection before git for colocated repositories", () => { + const calls: VcsProcessInput[] = []; + const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe( + Layer.provide( + Layer.mock(VcsProjectConfig)({ + resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), + }), + ), + Layer.provide( + Layer.mock(VcsProcess)({ + run: (input) => + Effect.sync(() => { + calls.push(input); + const command = input.args.join(" "); + if (input.command === "jj" && command === "--no-pager root") { + return processOutput("/repo\n"); + } + if (input.command === "git") { + return processOutput("true\n"); + } + return processOutput("", 1, "not found"); + }), + }), + ), + Layer.provide(NodeServices.layer), + ); + + return Effect.gen(function* () { + const registry = yield* VcsDriverRegistry; + const handle = yield* registry.resolve({ cwd: "/repo" }); + + assert.equal(handle.kind, "jj"); + assert.equal(handle.repository.rootPath, "/repo"); + assert.deepStrictEqual(commandCalls(calls), [["jj", "--no-pager", "root"]]); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index 7798d63a08..bdfeeab2e2 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -3,6 +3,7 @@ import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts"; import { VcsUnsupportedOperationError } from "@t3tools/contracts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as JjVcsDriver from "./JjVcsDriver.ts"; import * as VcsProjectConfig from "./VcsProjectConfig.ts"; import type { VcsDriverShape } from "./VcsDriver.ts"; @@ -66,8 +67,10 @@ function parseDetectionCacheKey(key: string): { export const make = Effect.fn("makeVcsDriverRegistry")(function* () { const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; const git = yield* GitVcsDriver.makeVcsDriverShape(); + const jj = yield* JjVcsDriver.makeVcsDriverShape(); const drivers: Partial> = { git, + jj, }; const get: VcsDriverRegistryShape["get"] = (kind) => { @@ -107,6 +110,11 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { return yield* detectWithDriver(requestedKind, driver, input.cwd); } + const jjDetected = yield* detectWithDriver("jj", jj, input.cwd); + if (jjDetected) { + return jjDetected; + } + return yield* detectWithDriver("git", git, input.cwd); }); diff --git a/apps/server/src/vcs/VcsFreshness.ts b/apps/server/src/vcs/VcsFreshness.ts new file mode 100644 index 0000000000..f3f9853b0c --- /dev/null +++ b/apps/server/src/vcs/VcsFreshness.ts @@ -0,0 +1,10 @@ +import { DateTime, Effect, Option } from "effect"; + +export const nowFreshness = Effect.fn("VcsFreshness.nowFreshness")(function* () { + const now = yield* DateTime.now; + return { + source: "live-local" as const, + observedAt: now, + expiresAt: Option.none(), + }; +}); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index e8bcf4bdf0..474014eedf 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -16,6 +16,7 @@ import { import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts"; const baseLocalStatus: VcsStatusLocalResult = { + kind: "git", isRepo: true, sourceControlProvider: { kind: "github", @@ -49,29 +50,30 @@ function makeTestLayer(state: { localInvalidationCalls: number; remoteInvalidationCalls: number; }) { - return VcsStatusBroadcasterLayer.pipe( - Layer.provide( - Layer.mock(GitWorkflowService)({ - localStatus: () => - Effect.sync(() => { - state.localStatusCalls += 1; - return state.currentLocalStatus; - }), - remoteStatus: () => - Effect.sync(() => { - state.remoteStatusCalls += 1; - return state.currentRemoteStatus; - }), - invalidateLocalStatus: () => - Effect.sync(() => { - state.localInvalidationCalls += 1; - }), - invalidateRemoteStatus: () => - Effect.sync(() => { - state.remoteInvalidationCalls += 1; - }), + const gitWorkflowLayer = Layer.mock(GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; }), - ), + }); + + return VcsStatusBroadcasterLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(gitWorkflowLayer), ); } @@ -188,31 +190,31 @@ describe("VcsStatusBroadcaster", () => { localInvalidationCalls: 0, remoteInvalidationCalls: 0, }; + const gitWorkflowLayer = Layer.mock(GitWorkflowService)({ + localStatus: (input) => + Effect.sync(() => { + seenCwds.push(input.cwd); + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: (input) => + Effect.sync(() => { + seenCwds.push(input.cwd); + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + } satisfies Partial); const testLayer = VcsStatusBroadcasterLayer.pipe( - Layer.provide( - Layer.mock(GitWorkflowService)({ - localStatus: (input) => - Effect.sync(() => { - seenCwds.push(input.cwd); - state.localStatusCalls += 1; - return state.currentLocalStatus; - }), - remoteStatus: (input) => - Effect.sync(() => { - seenCwds.push(input.cwd); - state.remoteStatusCalls += 1; - return state.currentRemoteStatus; - }), - invalidateLocalStatus: () => - Effect.sync(() => { - state.localInvalidationCalls += 1; - }), - invalidateRemoteStatus: () => - Effect.sync(() => { - state.remoteInvalidationCalls += 1; - }), - } satisfies Partial), - ), + Layer.provideMerge(NodeServices.layer), + Layer.provide(gitWorkflowLayer), ); return Effect.gen(function* () { @@ -235,7 +237,7 @@ describe("VcsStatusBroadcaster", () => { assert.deepStrictEqual(seenCwds, [realPath, realPath]); assert.equal(state.localStatusCalls, 1); assert.equal(state.remoteStatusCalls, 1); - }).pipe(Effect.provide(Layer.mergeAll(testLayer, NodeServices.layer))); + }).pipe(Effect.provide(testLayer)); }); it.effect("streams a local snapshot first and remote updates later", () => { @@ -289,40 +291,40 @@ describe("VcsStatusBroadcaster", () => { }; let remoteInterruptedDeferred: Deferred.Deferred | null = null; let remoteStartedDeferred: Deferred.Deferred | null = null; + const gitWorkflowLayer = Layer.mock(GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + }).pipe( + Effect.andThen( + remoteStartedDeferred + ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Effect.andThen(Effect.never as Effect.Effect), + Effect.onInterrupt(() => + remoteInterruptedDeferred + ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + } satisfies Partial); const testLayer = VcsStatusBroadcasterLayer.pipe( - Layer.provide( - Layer.mock(GitWorkflowService)({ - localStatus: () => - Effect.sync(() => { - state.localStatusCalls += 1; - return state.currentLocalStatus; - }), - remoteStatus: () => - Effect.sync(() => { - state.remoteStatusCalls += 1; - }).pipe( - Effect.andThen( - remoteStartedDeferred - ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) - : Effect.void, - ), - Effect.andThen(Effect.never as Effect.Effect), - Effect.onInterrupt(() => - remoteInterruptedDeferred - ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) - : Effect.void, - ), - ), - invalidateLocalStatus: () => - Effect.sync(() => { - state.localInvalidationCalls += 1; - }), - invalidateRemoteStatus: () => - Effect.sync(() => { - state.remoteInvalidationCalls += 1; - }), - } satisfies Partial), - ), + Layer.provideMerge(NodeServices.layer), + Layer.provide(gitWorkflowLayer), ); return Effect.gen(function* () { diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 028d3f1a1d..b6901e851f 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -1,9 +1,8 @@ -import { realpathSync } from "node:fs"; - import { Duration, Effect, Exit, + FileSystem, Fiber, Layer, PubSub, @@ -69,18 +68,11 @@ function fingerprintStatusPart(status: unknown): string { return JSON.stringify(status); } -function normalizeCwd(cwd: string): string { - try { - return realpathSync.native(cwd); - } catch { - return cwd; - } -} - export const layer = Layer.effect( VcsStatusBroadcaster, Effect.gen(function* () { const workflow = yield* GitWorkflowService; + const fileSystem = yield* FileSystem.FileSystem; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), (pubsub) => PubSub.shutdown(pubsub), @@ -91,6 +83,9 @@ export const layer = Layer.effect( const cacheRef = yield* Ref.make(new Map()); const pollersRef = yield* SynchronizedRef.make(new Map()); + const normalizeCwd = (cwd: string) => + fileSystem.realPath(cwd).pipe(Effect.catch(() => Effect.succeed(cwd))); + const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( cwd: string, ) { @@ -198,7 +193,7 @@ export const layer = Layer.effect( const getStatus: VcsStatusBroadcasterShape["getStatus"] = Effect.fn( "VcsStatusBroadcaster.getStatus", )(function* (input) { - const cwd = normalizeCwd(input.cwd); + const cwd = yield* normalizeCwd(input.cwd); const [local, remote] = yield* Effect.all([ getOrLoadLocalStatus(cwd), getOrLoadRemoteStatus(cwd), @@ -209,7 +204,7 @@ export const layer = Layer.effect( const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshLocalStatus", )(function* (rawCwd) { - const cwd = normalizeCwd(rawCwd); + const cwd = yield* normalizeCwd(rawCwd); yield* workflow.invalidateLocalStatus(cwd); const local = yield* workflow.localStatus({ cwd }); return yield* updateCachedLocalStatus(cwd, local, { publish: true }); @@ -226,7 +221,7 @@ export const layer = Layer.effect( const refreshStatus: VcsStatusBroadcasterShape["refreshStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd) { - const cwd = normalizeCwd(rawCwd); + const cwd = yield* normalizeCwd(rawCwd); const [local, remote] = yield* Effect.all([ refreshLocalStatus(cwd), refreshRemoteStatus(cwd), @@ -312,7 +307,7 @@ export const layer = Layer.effect( const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input) => Stream.unwrap( Effect.gen(function* () { - const cwd = normalizeCwd(input.cwd); + const cwd = yield* normalizeCwd(input.cwd); const subscription = yield* PubSub.subscribe(changesPubSub); const initialLocal = yield* getOrLoadLocalStatus(cwd); const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null; diff --git a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts index c0e195558b..ef0846e6a4 100644 --- a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts +++ b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts @@ -56,9 +56,11 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp yield* input.fixture.createRepo(cwd); yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); + const fileSystem = yield* FileSystem.FileSystem; + const realCwd = yield* fileSystem.realPath(cwd); const identity = yield* driver.detectRepository(cwd); assert.equal(identity?.kind, input.kind); - assert.isTrue(identity?.rootPath.endsWith(cwd)); + assert.isTrue(identity?.rootPath.endsWith(realCwd)); assert.equal(identity?.freshness.source, "live-local"); assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt)); assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none())); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 2e30edfc02..e04c43e161 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -24,6 +24,7 @@ import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; import { useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; +import { resolveVcsTerms } from "../vcsPresentation"; import { deriveLocalBranchNameFromRemoteRef, resolveBranchSelectionTarget, @@ -67,10 +68,11 @@ function getBranchTriggerLabel(input: { activeWorktreePath: string | null; effectiveEnvMode: "local" | "worktree"; resolvedActiveBranch: string | null; + refNoun: string; }): string { - const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; + const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, refNoun } = input; if (!resolvedActiveBranch) { - return "Select ref"; + return `Select ${refNoun}`; } if (effectiveEnvMode === "worktree" && !activeWorktreePath) { return `From ${resolvedActiveBranch}`; @@ -202,6 +204,7 @@ export function BranchToolbarBranchSelector({ const deferredBranchQuery = useDeferredValue(branchQuery); const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd }); + const vcsTerms = resolveVcsTerms(branchStatusQuery.data?.kind); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); @@ -295,11 +298,11 @@ export function BranchToolbarBranchSelector({ const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; const branchStatusText = isBranchesSearchPending - ? "Loading refs..." + ? `Loading ${vcsTerms.refNounPlural}...` : isFetchingNextPage - ? "Loading more refs..." + ? `Loading more ${vcsTerms.refNounPlural}...` : hasNextPage - ? `Showing ${refs.length} of ${totalBranchCount} refs` + ? `Showing ${refs.length} of ${totalBranchCount} ${vcsTerms.refNounPlural}` : null; // --------------------------------------------------------------------------- @@ -363,7 +366,7 @@ export function BranchToolbarBranchSelector({ toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to switch ref.", + title: `Failed to switch ${vcsTerms.refNoun}.`, description: toBranchActionErrorMessage(error), }), ); @@ -395,7 +398,7 @@ export function BranchToolbarBranchSelector({ toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to create and switch ref.", + title: `Failed to create and switch ${vcsTerms.refNoun}.`, description: toBranchActionErrorMessage(error), }), ); @@ -494,6 +497,7 @@ export function BranchToolbarBranchSelector({ activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, + refNoun: vcsTerms.refNoun, }); function renderPickerItem(itemValue: string, index: number) { @@ -535,7 +539,9 @@ export function BranchToolbarBranchSelector({ value={itemValue} onClick={() => createRef(trimmedBranchQuery)} > - Create new ref "{trimmedBranchQuery}" + + Create new {vcsTerms.refNoun} "{trimmedBranchQuery}" + ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 40cd1b4210..adec425eb6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1458,7 +1458,8 @@ export default function ChatView(props: ChatViewProps) { ? terminalLaunchContext : (storeServerTerminalLaunchContext ?? null); // Default true while loading to avoid toolbar flicker. - const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + const vcsKind = gitStatusQuery.data?.kind ?? null; + const isVcsRepo = gitStatusQuery.data?.isRepo ?? true; const terminalShortcutLabelOptions = useMemo( () => ({ context: { @@ -2136,7 +2137,7 @@ export default function ChatView(props: ChatViewProps) { : (activeThread?.branch ?? null); const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, - isGitRepo, + isGitRepo: isVcsRepo, }); useEffect(() => { @@ -3297,7 +3298,8 @@ export default function ChatView(props: ChatViewProps) { {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} activeProjectName={activeProject?.name} - isGitRepo={isGitRepo} + isVcsRepo={isVcsRepo} + vcsKind={vcsKind} openInCwd={gitCwd} activeProjectScripts={activeProject?.scripts} preferredScriptId={ @@ -3377,7 +3379,7 @@ export default function ChatView(props: ChatViewProps) {
- {isGitRepo && ( + {isVcsRepo && ( Select a thread to inspect turn diffs.
- ) : !isGitRepo ? ( + ) : !isVcsRepo ? (
- Turn diffs are unavailable because this project is not a git repository. + Turn diffs are unavailable because this project is not a {vcsTerms.repositoryNoun}.
) : orderedTurnDiffSummaries.length === 0 ? (
diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index 787e034bd1..c4f00462a3 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -113,6 +113,7 @@ vi.mock("~/lib/gitStatusState", () => ({ resetGitStatusStateForTests: () => undefined, useGitStatus: vi.fn(() => ({ data: { + kind: "git", isRepo: true, sourceControlProvider: { kind: "github", diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index ac026e36b0..6e2e26e9e9 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -13,6 +13,7 @@ import { function status(overrides: Partial = {}): VcsStatusResult { return { + kind: "git", isRepo: true, hasPrimaryRemote: true, isDefaultRef: false, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index b4fae35929..d944d2a6fe 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -54,6 +54,7 @@ import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useStore } from "~/store"; import { createThreadSelectorByRef } from "~/storeSelectors"; +import { resolveVcsActionPresentation, resolveVcsTerms } from "~/vcsPresentation"; interface GitActionsControlProps { gitCwd: string | null; @@ -343,8 +344,12 @@ export default function GitActionsControl({ const SourceControlIcon = sourceControlPresentation.Icon; // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; + const vcsKind = gitStatus?.kind ?? null; + const vcsTerms = resolveVcsTerms(vcsKind); + const vcsActionPresentation = resolveVcsActionPresentation(vcsKind); + const supportsGitWorkflowActions = !isRepo || vcsActionPresentation.supportsGitWorkflowActions; const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; - const gitStatusForActions = gitStatus; + const gitStatusForActions = supportsGitWorkflowActions ? gitStatus : null; const allFiles = gitStatusForActions?.workingTree.files ?? []; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); @@ -895,6 +900,25 @@ export default function GitActionsControl({ > {initMutation.isPending ? "Initializing..." : "Initialize Git"} + ) : !supportsGitWorkflowActions ? ( + + + } + > + {vcsTerms.systemName} {vcsTerms.repositoryNoun} + + + {vcsActionPresentation.unsupportedGitWorkflowDescription} + + ) : ( {quickActionDisabledReason ? ( diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 5d7c929247..ea7ec21cab 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -17,6 +17,8 @@ import { Toggle } from "../ui/toggle"; import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; import { usePrimaryEnvironmentId } from "../../environments/primary"; +import { resolveVcsTerms } from "../../vcsPresentation"; +import type { VcsDriverKind } from "@t3tools/contracts"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -24,7 +26,8 @@ interface ChatHeaderProps { draftId?: DraftId; activeThreadTitle: string; activeProjectName: string | undefined; - isGitRepo: boolean; + isVcsRepo: boolean; + vcsKind: VcsDriverKind | null; openInCwd: string | null; activeProjectScripts: ProjectScript[] | undefined; preferredScriptId: string | null; @@ -50,7 +53,8 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, activeProjectName, - isGitRepo, + isVcsRepo, + vcsKind, openInCwd, activeProjectScripts, preferredScriptId, @@ -72,6 +76,7 @@ export const ChatHeader = memo(function ChatHeader({ const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteEnvironment = primaryEnvironmentId !== null && activeThreadEnvironmentId !== primaryEnvironmentId; + const vcsTerms = resolveVcsTerms(vcsKind); return (
@@ -88,9 +93,9 @@ export const ChatHeader = memo(function ChatHeader({ {activeProjectName} )} - {activeProjectName && !isGitRepo && ( + {activeProjectName && !isVcsRepo && ( - No Git + No repository )}
@@ -154,15 +159,15 @@ export const ChatHeader = memo(function ChatHeader({ aria-label="Toggle diff panel" variant="outline" size="xs" - disabled={!isGitRepo && !diffOpen} + disabled={!isVcsRepo && !diffOpen} > } /> - {!isGitRepo && !diffOpen - ? "Diff panel is unavailable because this project is not a git repository." + {!isVcsRepo && !diffOpen + ? `Diff panel is unavailable because this project is not a supported ${vcsTerms.repositoryNoun}.` : diffToggleShortcutLabel ? `Toggle diff panel (${diffToggleShortcutLabel})` : "Toggle diff panel"} diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index 40766563fb..bae83c86bc 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -40,6 +40,7 @@ const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const; const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const; const BASE_STATUS: VcsStatusResult = { + kind: "git", isRepo: true, hasPrimaryRemote: true, isDefaultRef: false, diff --git a/apps/web/src/lib/providerReactQuery.ts b/apps/web/src/lib/providerReactQuery.ts index 20007fc8fb..fd0013cfa4 100644 --- a/apps/web/src/lib/providerReactQuery.ts +++ b/apps/web/src/lib/providerReactQuery.ts @@ -64,7 +64,7 @@ function normalizeCheckpointErrorMessage(error: unknown): string { const lower = message.toLowerCase(); if (lower.includes("not a git repository")) { - return "Turn diffs are unavailable because this project is not a git repository."; + return "Turn diffs are unavailable because this project is not a repository."; } if ( diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index b627286199..c45c07f9ca 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -262,6 +262,7 @@ const baseServerConfig: ServerConfig = { }; const baseGitStatus: VcsStatusResult = { + kind: "git", isRepo: true, hasPrimaryRemote: true, isDefaultRef: false, diff --git a/apps/web/src/rpc/wsRpcClient.test.ts b/apps/web/src/rpc/wsRpcClient.test.ts index f1c0d06bc0..7f4d82e406 100644 --- a/apps/web/src/rpc/wsRpcClient.test.ts +++ b/apps/web/src/rpc/wsRpcClient.test.ts @@ -19,6 +19,7 @@ import { createWsRpcClient } from "./wsRpcClient"; import { type WsTransport } from "./wsTransport"; const baseLocalStatus: VcsStatusLocalResult = { + kind: "git", isRepo: true, hasPrimaryRemote: true, isDefaultRef: false, diff --git a/apps/web/src/vcsPresentation.ts b/apps/web/src/vcsPresentation.ts new file mode 100644 index 0000000000..7599d4a78e --- /dev/null +++ b/apps/web/src/vcsPresentation.ts @@ -0,0 +1,60 @@ +import type { VcsDriverKind } from "@t3tools/contracts"; + +export interface VcsTerms { + readonly systemName: string; + readonly repositoryNoun: string; + readonly refNoun: string; + readonly refNounPlural: string; + readonly currentRefFallback: string; +} + +export interface VcsActionPresentation { + readonly supportsGitWorkflowActions: boolean; + readonly unsupportedGitWorkflowDescription: string; +} + +const gitLikeTerms: VcsTerms = { + systemName: "Git", + repositoryNoun: "repository", + refNoun: "branch", + refNounPlural: "branches", + currentRefFallback: "checkout", +}; + +const unknownTerms: VcsTerms = { + systemName: "VCS", + repositoryNoun: "repository", + refNoun: "ref", + refNounPlural: "refs", + currentRefFallback: "checkout", +}; + +export function resolveVcsTerms(kind: VcsDriverKind | null | undefined): VcsTerms { + if (kind === "jj") { + return { + systemName: "JJ", + repositoryNoun: "workspace", + refNoun: "bookmark", + refNounPlural: "bookmarks", + currentRefFallback: "working copy", + }; + } + + if (kind === "git") { + return gitLikeTerms; + } + + return unknownTerms; +} + +export function resolveVcsActionPresentation( + kind: VcsDriverKind | null | undefined, +): VcsActionPresentation { + const terms = resolveVcsTerms(kind); + const supportsGitWorkflowActions = kind === undefined || kind === null || kind === "git"; + + return { + supportsGitWorkflowActions, + unsupportedGitWorkflowDescription: `Git commit, push, and PR actions are not available for this ${terms.systemName} ${terms.repositoryNoun}.`, + }; +} diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index b5fa114d78..d7afa5d104 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -196,6 +196,7 @@ const VcsStatusChangeRequest = Schema.Struct({ }); const VcsStatusLocalShape = { + kind: VcsDriverKind, isRepo: Schema.Boolean, sourceControlProvider: Schema.optional(SourceControlProviderInfo), hasPrimaryRemote: Schema.Boolean, diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 5eb9fd5124..d8e21cf6a1 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -81,6 +81,7 @@ describe("applyGitStatusStreamEvent", () => { }; expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({ + kind: "unknown", isRepo: true, hasPrimaryRemote: false, isDefaultRef: false, @@ -96,6 +97,7 @@ describe("applyGitStatusStreamEvent", () => { it("preserves local-only fields when applying a remote update", () => { const current: VcsStatusResult = { + kind: "git", isRepo: true, sourceControlProvider: { kind: "github", diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 1ad17f52bd..0104e4e834 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -230,6 +230,7 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { return { + kind: status.kind, isRepo: status.isRepo, ...(status.sourceControlProvider ? { sourceControlProvider: status.sourceControlProvider } @@ -255,6 +256,7 @@ export function applyGitStatusStreamEvent( if (current === null) { return mergeGitStatusParts( { + kind: "unknown", isRepo: true, hasPrimaryRemote: false, isDefaultRef: false,