| author | |
| committer | |
| log | 331c6dce7f6745863752b1b423fc76d24014deec |
| tree | 47b28933c5e42cc60f431c8f1df40792402ed205 |
| parent | 4f5834ba72c5905a318c00456dd21271b2fa9d6f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>5 files changed, 143 insertions(+), 3 deletions(-)
apps/server/src/git/GitWorkflowService.ts+8| ... | @@ -69,6 +69,10 @@ export class GitWorkflowService extends Context.Service< | ... | @@ -69,6 +69,10 @@ export class GitWorkflowService extends Context.Service< |
| 69 | readonly cwd: string; | 69 | readonly cwd: string; |
| 70 | readonly remoteName: string; | 70 | readonly remoteName: string; |
| 71 | }) => Effect.Effect<void, GitCommandError>; | 71 | }) => Effect.Effect<void, GitCommandError>; |
| 72 | readonly remoteExists: (input: { | ||
| 73 | readonly cwd: string; | ||
| 74 | readonly remoteName: string; | ||
| 75 | }) => Effect.Effect<boolean, GitCommandError>; | ||
| 72 | readonly resolveRemoteTrackingCommit: (input: { | 76 | readonly resolveRemoteTrackingCommit: (input: { |
| 73 | readonly cwd: string; | 77 | readonly cwd: string; |
| 74 | readonly refName: string; | 78 | readonly refName: string; |
| ... | @@ -303,6 +307,10 @@ export const make = Effect.gen(function* () { | ... | @@ -303,6 +307,10 @@ export const make = Effect.gen(function* () { |
| 303 | ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( | 307 | ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( |
| 304 | Effect.andThen(git.fetchRemote(input)), | 308 | Effect.andThen(git.fetchRemote(input)), |
| 305 | ), | 309 | ), |
| 310 | remoteExists: (input) => | ||
| 311 | ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe( | ||
| 312 | Effect.andThen(git.remoteExists(input)), | ||
| 313 | ), | ||
| 306 | resolveRemoteTrackingCommit: (input) => | 314 | resolveRemoteTrackingCommit: (input) => |
| 307 | ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe( | 315 | ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe( |
| 308 | Effect.andThen(git.resolveRemoteTrackingCommit(input)), | 316 | Effect.andThen(git.resolveRemoteTrackingCommit(input)), |
apps/server/src/server.test.ts+113| ... | @@ -7134,6 +7134,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -7134,6 +7134,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 7134 | pr: null, | 7134 | pr: null, |
| 7135 | }), | 7135 | }), |
| 7136 | ); | 7136 | ); |
| 7137 | const remoteExists = vi.fn( | ||
| 7138 | (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["remoteExists"]>[0]) => | ||
| 7139 | Effect.sync(() => { | ||
| 7140 | bootstrapGitOperations.push("remote-exists"); | ||
| 7141 | return true; | ||
| 7142 | }), | ||
| 7143 | ); | ||
| 7137 | const fetchRemote = vi.fn( | 7144 | const fetchRemote = vi.fn( |
| 7138 | (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"]>[0]) => | 7145 | (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"]>[0]) => |
| 7139 | Effect.sync(() => { | 7146 | Effect.sync(() => { |
| ... | @@ -7181,6 +7188,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -7181,6 +7188,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 7181 | yield* buildAppUnderTest({ | 7188 | yield* buildAppUnderTest({ |
| 7182 | layers: { | 7189 | layers: { |
| 7183 | gitVcsDriver: { | 7190 | gitVcsDriver: { |
| 7191 | remoteExists, | ||
| 7184 | fetchRemote, | 7192 | fetchRemote, |
| 7185 | resolveRemoteTrackingCommit, | 7193 | resolveRemoteTrackingCommit, |
| 7186 | createWorktree, | 7194 | createWorktree, |
| ... | @@ -7271,6 +7279,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -7271,6 +7279,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 7271 | fallbackRemoteName: "origin", | 7279 | fallbackRemoteName: "origin", |
| 7272 | }); | 7280 | }); |
| 7273 | assert.deepEqual(bootstrapGitOperations, [ | 7281 | assert.deepEqual(bootstrapGitOperations, [ |
| 7282 | "remote-exists", | ||
| 7274 | "fetch", | 7283 | "fetch", |
| 7275 | "resolve-remote-commit", | 7284 | "resolve-remote-commit", |
| 7276 | "create-worktree", | 7285 | "create-worktree", |
| ... | @@ -7299,6 +7308,110 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -7299,6 +7308,110 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 7299 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 7308 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 7300 | ); | 7309 | ); |
| 7301 | 7310 | ||
| 7311 | it.effect( | ||
| 7312 | "falls back to the local base branch when startFromOrigin is set but no origin remote exists", | ||
| 7313 | () => | ||
| 7314 | Effect.gen(function* () { | ||
| 7315 | const dispatchedCommands: Array<OrchestrationCommand> = []; | ||
| 7316 | const remoteExists = vi.fn( | ||
| 7317 | (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["remoteExists"]>[0]) => | ||
| 7318 | Effect.succeed(false), | ||
| 7319 | ); | ||
| 7320 | const fetchRemote = vi.fn( | ||
| 7321 | (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"]>[0]) => Effect.void, | ||
| 7322 | ); | ||
| 7323 | const resolveRemoteTrackingCommit = vi.fn( | ||
| 7324 | (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["resolveRemoteTrackingCommit"]>[0]) => | ||
| 7325 | Effect.succeed({ | ||
| 7326 | commitSha: "0123456789abcdef0123456789abcdef01234567", | ||
| 7327 | remoteRefName: "origin/main", | ||
| 7328 | }), | ||
| 7329 | ); | ||
| 7330 | const createWorktree = vi.fn( | ||
| 7331 | (_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["createWorktree"]>[0]) => | ||
| 7332 | Effect.succeed({ | ||
| 7333 | worktree: { | ||
| 7334 | refName: "t3code/bootstrap-refName", | ||
| 7335 | path: "/tmp/bootstrap-worktree", | ||
| 7336 | }, | ||
| 7337 | }), | ||
| 7338 | ); | ||
| 7339 | |||
| 7340 | yield* buildAppUnderTest({ | ||
| 7341 | layers: { | ||
| 7342 | gitVcsDriver: { | ||
| 7343 | remoteExists, | ||
| 7344 | fetchRemote, | ||
| 7345 | resolveRemoteTrackingCommit, | ||
| 7346 | createWorktree, | ||
| 7347 | }, | ||
| 7348 | orchestrationEngine: { | ||
| 7349 | dispatch: (command) => | ||
| 7350 | Effect.sync(() => { | ||
| 7351 | dispatchedCommands.push(command); | ||
| 7352 | return { sequence: dispatchedCommands.length }; | ||
| 7353 | }), | ||
| 7354 | readEvents: () => Stream.empty, | ||
| 7355 | }, | ||
| 7356 | }, | ||
| 7357 | }); | ||
| 7358 | |||
| 7359 | const createdAt = "2026-01-01T00:00:00.000Z"; | ||
| 7360 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 7361 | yield* Effect.scoped( | ||
| 7362 | withWsRpcClient(wsUrl, (client) => | ||
| 7363 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ | ||
| 7364 | type: "thread.turn.start", | ||
| 7365 | commandId: CommandId.make("cmd-bootstrap-turn-start-no-origin"), | ||
| 7366 | threadId: ThreadId.make("thread-bootstrap-no-origin"), | ||
| 7367 | message: { | ||
| 7368 | messageId: MessageId.make("msg-bootstrap-no-origin"), | ||
| 7369 | role: "user", | ||
| 7370 | text: "hello", | ||
| 7371 | attachments: [], | ||
| 7372 | }, | ||
| 7373 | modelSelection: defaultModelSelection, | ||
| 7374 | runtimeMode: "full-access", | ||
| 7375 | interactionMode: "default", | ||
| 7376 | bootstrap: { | ||
| 7377 | createThread: { | ||
| 7378 | projectId: defaultProjectId, | ||
| 7379 | title: "Bootstrap Thread", | ||
| 7380 | modelSelection: defaultModelSelection, | ||
| 7381 | runtimeMode: "full-access", | ||
| 7382 | interactionMode: "default", | ||
| 7383 | branch: "main", | ||
| 7384 | worktreePath: null, | ||
| 7385 | createdAt, | ||
| 7386 | }, | ||
| 7387 | prepareWorktree: { | ||
| 7388 | projectCwd: "/tmp/project", | ||
| 7389 | baseBranch: "main", | ||
| 7390 | branch: "t3code/bootstrap-refName", | ||
| 7391 | startFromOrigin: true, | ||
| 7392 | }, | ||
| 7393 | }, | ||
| 7394 | createdAt, | ||
| 7395 | }), | ||
| 7396 | ), | ||
| 7397 | ); | ||
| 7398 | |||
| 7399 | assert.deepEqual(remoteExists.mock.calls[0]?.[0], { | ||
| 7400 | cwd: "/tmp/project", | ||
| 7401 | remoteName: "origin", | ||
| 7402 | }); | ||
| 7403 | assert.equal(fetchRemote.mock.calls.length, 0); | ||
| 7404 | assert.equal(resolveRemoteTrackingCommit.mock.calls.length, 0); | ||
| 7405 | assert.deepEqual(createWorktree.mock.calls[0]?.[0], { | ||
| 7406 | cwd: "/tmp/project", | ||
| 7407 | refName: "main", | ||
| 7408 | newRefName: "t3code/bootstrap-refName", | ||
| 7409 | baseRefName: "main", | ||
| 7410 | path: null, | ||
| 7411 | }); | ||
| 7412 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 7413 | ); | ||
| 7414 | |||
| 7302 | it.effect("records setup-script failures without aborting bootstrap turn start", () => | 7415 | it.effect("records setup-script failures without aborting bootstrap turn start", () => |
| 7303 | Effect.gen(function* () { | 7416 | Effect.gen(function* () { |
| 7304 | const dispatchedCommands: Array<OrchestrationCommand> = []; | 7417 | const dispatchedCommands: Array<OrchestrationCommand> = []; |
apps/server/src/vcs/GitVcsDriver.ts+6| ... | @@ -168,6 +168,11 @@ export interface GitFetchRemoteInput { | ... | @@ -168,6 +168,11 @@ export interface GitFetchRemoteInput { |
| 168 | remoteName: string; | 168 | remoteName: string; |
| 169 | } | 169 | } |
| 170 | 170 | ||
| 171 | export interface GitRemoteExistsInput { | ||
| 172 | cwd: string; | ||
| 173 | remoteName: string; | ||
| 174 | } | ||
| 175 | |||
| 171 | export interface GitResolveRemoteTrackingCommitInput { | 176 | export interface GitResolveRemoteTrackingCommitInput { |
| 172 | cwd: string; | 177 | cwd: string; |
| 173 | refName: string; | 178 | refName: string; |
| ... | @@ -243,6 +248,7 @@ export class GitVcsDriver extends Context.Service< | ... | @@ -243,6 +248,7 @@ export class GitVcsDriver extends Context.Service< |
| 243 | readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>; | 248 | readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>; |
| 244 | readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>; | 249 | readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>; |
| 245 | readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect<void, GitCommandError>; | 250 | readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect<void, GitCommandError>; |
| 251 | readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect<boolean, GitCommandError>; | ||
| 246 | readonly resolveRemoteTrackingCommit: ( | 252 | readonly resolveRemoteTrackingCommit: ( |
| 247 | input: GitResolveRemoteTrackingCommitInput, | 253 | input: GitResolveRemoteTrackingCommitInput, |
| 248 | ) => Effect.Effect<GitResolveRemoteTrackingCommitResult, GitCommandError>; | 254 | ) => Effect.Effect<GitResolveRemoteTrackingCommitResult, GitCommandError>; |
apps/server/src/vcs/GitVcsDriverCore.ts+6-2| ... | @@ -1284,11 +1284,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -1284,11 +1284,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 1284 | }, | 1284 | }, |
| 1285 | ).pipe(Effect.map((result) => result.exitCode === 0)); | 1285 | ).pipe(Effect.map((result) => result.exitCode === 0)); |
| 1286 | 1286 | ||
| 1287 | const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> => | 1287 | const remoteExists: GitVcsDriver.GitVcsDriver["Service"]["remoteExists"] = (input) => |
| 1288 | executeGit("GitVcsDriver.originRemoteExists", cwd, ["remote", "get-url", "origin"], { | 1288 | executeGit("GitVcsDriver.remoteExists", input.cwd, ["remote", "get-url", input.remoteName], { |
| 1289 | allowNonZeroExit: true, | 1289 | allowNonZeroExit: true, |
| 1290 | }).pipe(Effect.map((result) => result.exitCode === 0)); | 1290 | }).pipe(Effect.map((result) => result.exitCode === 0)); |
| 1291 | 1291 | ||
| 1292 | const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> => | ||
| 1293 | remoteExists({ cwd, remoteName: "origin" }); | ||
| 1294 | |||
| 1292 | const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> => | 1295 | const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> => |
| 1293 | runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( | 1296 | runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( |
| 1294 | Effect.map(parseRemoteNamesInGitOrder), | 1297 | Effect.map(parseRemoteNamesInGitOrder), |
| ... | @@ -3071,6 +3074,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -3071,6 +3074,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 3071 | ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), | 3074 | ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), |
| 3072 | resolvePrimaryRemoteName, | 3075 | resolvePrimaryRemoteName, |
| 3073 | fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), | 3076 | fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), |
| 3077 | remoteExists, | ||
| 3074 | resolveRemoteTrackingCommit, | 3078 | resolveRemoteTrackingCommit, |
| 3075 | fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), | 3079 | fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), |
| 3076 | fetchRemoteTrackingBranch: (input) => | 3080 | fetchRemoteTrackingBranch: (input) => |
apps/server/src/ws.ts+10-1| ... | @@ -908,7 +908,16 @@ const makeWsRpcLayer = ( | ... | @@ -908,7 +908,16 @@ const makeWsRpcLayer = ( |
| 908 | 908 | ||
| 909 | if (bootstrap?.prepareWorktree) { | 909 | if (bootstrap?.prepareWorktree) { |
| 910 | let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; | 910 | let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; |
| 911 | if (bootstrap.prepareWorktree.startFromOrigin) { | 911 | // "Start from origin" is a stored default; repos without an |
| 912 | // origin remote fall back to the local base branch instead of | ||
| 913 | // failing the whole bootstrap on `git fetch origin`. | ||
| 914 | const startFromOrigin = | ||
| 915 | bootstrap.prepareWorktree.startFromOrigin === true && | ||
| 916 | (yield* gitWorkflow.remoteExists({ | ||
| 917 | cwd: bootstrap.prepareWorktree.projectCwd, | ||
| 918 | remoteName: "origin", | ||
| 919 | })); | ||
| 920 | if (startFromOrigin) { | ||
| 912 | yield* gitWorkflow.fetchRemote({ | 921 | yield* gitWorkflow.fetchRemote({ |
| 913 | cwd: bootstrap.prepareWorktree.projectCwd, | 922 | cwd: bootstrap.prepareWorktree.projectCwd, |
| 914 | remoteName: "origin", | 923 | remoteName: "origin", |