diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 2ea14b951fe2..5e7545eafdf8 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,6 +1,8 @@ import { assert, describe, expect, it, vi } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { VcsRepositoryDetectionError } from "@t3tools/contracts"; @@ -24,6 +26,35 @@ function makeLayer(input: { } describe("GitWorkflowService", () => { + it.effect("reports a non-Git VCS repository as not a Git repository", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const isRepository = yield* workflow.isRepository("/jj-repo"); + + assert.equal(isRepository, false); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => + Effect.succeed({ + kind: "jj", + repository: { + kind: "jj", + rootPath: "/jj-repo", + metadataPath: "/jj-repo/.jj", + freshness: { + source: "live-local", + observedAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }, + driver: {} as VcsDriverRegistry.VcsDriverHandle["driver"], + }), + }), + ), + ), + ); + it.effect("returns an empty local status when no VCS repository is detected", () => Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index c9b4a4cca365..13da34d84050 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -35,6 +35,11 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; export class GitWorkflowService extends Context.Service< GitWorkflowService, { + readonly isRepository: (cwd: string) => Effect.Effect; + readonly hasCommit: (input: { + readonly cwd: string; + readonly refName: string; + }) => Effect.Effect; readonly status: ( input: VcsStatusInput, ) => Effect.Effect; @@ -262,6 +267,31 @@ export const make = Effect.gen(function* () { ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); return GitWorkflowService.of({ + isRepository: (cwd) => + registry.detect({ cwd }).pipe( + Effect.map((handle) => handle?.kind === "git"), + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "GitWorkflowService.isRepository", + cwd, + detail: "Failed to detect a VCS repository for this Git workflow.", + cause, + }), + ), + ), + hasCommit: (input) => + ensureGitCommand("GitWorkflowService.hasCommit", input.cwd).pipe( + Effect.andThen( + git.execute({ + operation: "GitWorkflowService.hasCommit", + cwd: input.cwd, + args: ["rev-parse", "--verify", `${input.refName}^{commit}`], + allowNonZeroExit: true, + }), + ), + Effect.map((result) => result.exitCode === 0), + ), status: (input) => detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( Effect.flatMap((isGitRepository) => diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index aeec8329ea31..2b06db602619 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -85,6 +85,13 @@ import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const SUCCESSFUL_GIT_EXECUTION = { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}; const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationThreadDetailSnapshot), ); @@ -9292,7 +9299,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), remoteExists, fetchRemote, remoteBranchExists, @@ -9456,7 +9467,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), remoteExists, fetchRemote, remoteBranchExists, @@ -9538,6 +9553,167 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("falls back to the project checkout when worktree mode targets a non-repository", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.die(new Error("createWorktree must not run for a non-repository")), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-non-repo"), + threadId: ThreadId.make("thread-bootstrap-non-repo"), + message: { + messageId: MessageId.make("msg-bootstrap-non-repo"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 2); + assert.equal(createWorktree.mock.calls.length, 0); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.turn.start"], + ); + const finalCommand = dispatchedCommands[1]; + assertTrue(finalCommand?.type === "thread.turn.start"); + if (finalCommand?.type === "thread.turn.start") { + assert.equal(finalCommand.bootstrap, undefined); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("falls back to the project checkout when the worktree base has no commit", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.die(new Error("createWorktree must not run without a base commit")), + ); + + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, + gitVcsDriver: { + execute: () => + Effect.succeed({ + ...SUCCESSFUL_GIT_EXECUTION, + exitCode: ChildProcessSpawner.ExitCode(128), + stderr: "fatal: Needed a single revision", + }), + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-unborn-head"), + threadId: ThreadId.make("thread-bootstrap-unborn-head"), + message: { + messageId: MessageId.make("msg-bootstrap-unborn-head"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 2); + assert.equal(createWorktree.mock.calls.length, 0); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.turn.start"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; @@ -9568,7 +9744,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), createWorktree, }, orchestrationEngine: { @@ -9673,7 +9853,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), createWorktree, }, orchestrationEngine: { @@ -9778,7 +9962,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const config = yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), createWorktree, }, orchestrationEngine: { @@ -9987,7 +10175,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), createWorktree, }, orchestrationEngine: { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 35f41d58e319..f5c0d71db13c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1077,6 +1077,49 @@ const makeWsRpcLayer = ( }); const bootstrapProgram = Effect.gen(function* () { + const prepareWorktree = bootstrap?.prepareWorktree; + let shouldPrepareWorktree = prepareWorktree + ? yield* gitWorkflow.isRepository(prepareWorktree.projectCwd) + : false; + let worktreeBaseRef = prepareWorktree?.baseBranch ?? null; + + if (prepareWorktree && shouldPrepareWorktree) { + // "Start from origin" is a stored default; repos without the + // requested remote branch fall back to the local base branch. + const startFromOrigin = + prepareWorktree.startFromOrigin === true && + (yield* gitWorkflow.remoteExists({ + cwd: prepareWorktree.projectCwd, + remoteName: "origin", + })); + if (startFromOrigin) { + yield* gitWorkflow.fetchRemote({ + cwd: prepareWorktree.projectCwd, + remoteName: "origin", + }); + const remoteBaseExists = yield* gitWorkflow.remoteBranchExists({ + cwd: prepareWorktree.projectCwd, + refName: prepareWorktree.baseBranch, + remoteName: "origin", + }); + if (remoteBaseExists) { + const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ + cwd: prepareWorktree.projectCwd, + refName: prepareWorktree.baseBranch, + fallbackRemoteName: "origin", + }); + worktreeBaseRef = resolvedRemoteBase.commitSha; + } + } + + const resolvedWorktreeBaseRef = worktreeBaseRef ?? prepareWorktree.baseBranch; + shouldPrepareWorktree = yield* gitWorkflow.hasCommit({ + cwd: prepareWorktree.projectCwd, + refName: resolvedWorktreeBaseRef, + }); + worktreeBaseRef = resolvedWorktreeBaseRef; + } + if (bootstrap?.createThread) { const created = yield* dispatchFromClient({ type: "thread.create", @@ -1099,40 +1142,12 @@ const makeWsRpcLayer = ( createdThread = true; } - if (bootstrap?.prepareWorktree) { - let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; - // "Start from origin" is a stored default; repos without the - // requested remote branch fall back to the local base branch. - const startFromOrigin = - bootstrap.prepareWorktree.startFromOrigin === true && - (yield* gitWorkflow.remoteExists({ - cwd: bootstrap.prepareWorktree.projectCwd, - remoteName: "origin", - })); - if (startFromOrigin) { - yield* gitWorkflow.fetchRemote({ - cwd: bootstrap.prepareWorktree.projectCwd, - remoteName: "origin", - }); - const remoteBaseExists = yield* gitWorkflow.remoteBranchExists({ - cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, - remoteName: "origin", - }); - if (remoteBaseExists) { - const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ - cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, - fallbackRemoteName: "origin", - }); - worktreeBaseRef = resolvedRemoteBase.commitSha; - } - } + if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) { const worktree = yield* gitWorkflow.createWorktree({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, refName: worktreeBaseRef, - newRefName: bootstrap.prepareWorktree.branch, - baseRefName: bootstrap.prepareWorktree.baseBranch, + newRefName: prepareWorktree.branch, + baseRefName: prepareWorktree.baseBranch, path: null, }); targetWorktreePath = worktree.worktree.path;