diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 100b9beadba..da22794951f 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -69,6 +69,10 @@ export class GitWorkflowService extends Context.Service< readonly cwd: string; readonly remoteName: string; }) => Effect.Effect; + readonly remoteExists: (input: { + readonly cwd: string; + readonly remoteName: string; + }) => Effect.Effect; readonly resolveRemoteTrackingCommit: (input: { readonly cwd: string; readonly refName: string; @@ -303,6 +307,10 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( Effect.andThen(git.fetchRemote(input)), ), + remoteExists: (input) => + ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe( + Effect.andThen(git.remoteExists(input)), + ), resolveRemoteTrackingCommit: (input) => ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe( Effect.andThen(git.resolveRemoteTrackingCommit(input)), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8628aeef314..a403e228b06 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7134,6 +7134,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { pr: null, }), ); + const remoteExists = vi.fn( + (_: Parameters[0]) => + Effect.sync(() => { + bootstrapGitOperations.push("remote-exists"); + return true; + }), + ); const fetchRemote = vi.fn( (_: Parameters[0]) => Effect.sync(() => { @@ -7181,6 +7188,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitVcsDriver: { + remoteExists, fetchRemote, resolveRemoteTrackingCommit, createWorktree, @@ -7271,6 +7279,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { fallbackRemoteName: "origin", }); assert.deepEqual(bootstrapGitOperations, [ + "remote-exists", "fetch", "resolve-remote-commit", "create-worktree", @@ -7299,6 +7308,110 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "falls back to the local base branch when startFromOrigin is set but no origin remote exists", + () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const remoteExists = vi.fn( + (_: Parameters[0]) => + Effect.succeed(false), + ); + const fetchRemote = vi.fn( + (_: Parameters[0]) => Effect.void, + ); + const resolveRemoteTrackingCommit = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + commitSha: "0123456789abcdef0123456789abcdef01234567", + remoteRefName: "origin/main", + }), + ); + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + remoteExists, + fetchRemote, + resolveRemoteTrackingCommit, + 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"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-no-origin"), + threadId: ThreadId.make("thread-bootstrap-no-origin"), + message: { + messageId: MessageId.make("msg-bootstrap-no-origin"), + 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", + startFromOrigin: true, + }, + }, + createdAt, + }), + ), + ); + + assert.deepEqual(remoteExists.mock.calls[0]?.[0], { + cwd: "/tmp/project", + remoteName: "origin", + }); + assert.equal(fetchRemote.mock.calls.length, 0); + assert.equal(resolveRemoteTrackingCommit.mock.calls.length, 0); + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "main", + newRefName: "t3code/bootstrap-refName", + baseRefName: "main", + path: null, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 192efe5a7d0..f256a7dd4e1 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -168,6 +168,11 @@ export interface GitFetchRemoteInput { remoteName: string; } +export interface GitRemoteExistsInput { + cwd: string; + remoteName: string; +} + export interface GitResolveRemoteTrackingCommitInput { cwd: string; refName: string; @@ -243,6 +248,7 @@ export class GitVcsDriver extends Context.Service< readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; + readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect; readonly resolveRemoteTrackingCommit: ( input: GitResolveRemoteTrackingCommitInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index abcb10a8c9a..d39817c0ee1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1284,11 +1284,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ).pipe(Effect.map((result) => result.exitCode === 0)); - const originRemoteExists = (cwd: string): Effect.Effect => - executeGit("GitVcsDriver.originRemoteExists", cwd, ["remote", "get-url", "origin"], { + const remoteExists: GitVcsDriver.GitVcsDriver["Service"]["remoteExists"] = (input) => + executeGit("GitVcsDriver.remoteExists", input.cwd, ["remote", "get-url", input.remoteName], { allowNonZeroExit: true, }).pipe(Effect.map((result) => result.exitCode === 0)); + const originRemoteExists = (cwd: string): Effect.Effect => + remoteExists({ cwd, remoteName: "origin" }); + const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( Effect.map(parseRemoteNamesInGitOrder), @@ -3071,6 +3074,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), resolvePrimaryRemoteName, fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), + remoteExists, resolveRemoteTrackingCommit, fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), fetchRemoteTrackingBranch: (input) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fc65602679a..a04fce3fd2c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -908,7 +908,16 @@ const makeWsRpcLayer = ( if (bootstrap?.prepareWorktree) { let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; - if (bootstrap.prepareWorktree.startFromOrigin) { + // "Start from origin" is a stored default; repos without an + // origin remote fall back to the local base branch instead of + // failing the whole bootstrap on `git fetch origin`. + 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",