diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index c44c3a70f19b9..a5c6be3f4cb77 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -212,7 +212,7 @@ function makeHarness(options: HarnessOptions = {}) { fetchWorktreeBaseCommit: options.fetchWorktreeBaseCommit ?? (() => Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" })), - hasCommit: options.hasCommit ?? (() => Effect.succeed(false)), + hasCommit: options.hasCommit ?? (({ refName }) => Effect.succeed(refName === "HEAD")), currentBranch: () => Effect.succeed(null), resolveWorktreeBaseRemote: () => Effect.succeed({ remoteName: "origin", branchName: "main" }), remoteExists: options.remoteExists ?? (() => Effect.succeed(true)), @@ -990,7 +990,8 @@ it.effect.each([ [projectId]: { branchNamingMode: "static", branchNamePrefix: scenario.prefix }, }, }, - hasCommit: ({ refName }) => Effect.succeed(refName === `refs/heads/${scenario.collision}`), + hasCommit: ({ refName }) => + Effect.succeed(refName === "HEAD" || refName === `refs/heads/${scenario.collision}`), generateBranchName: () => Deferred.succeed(namingAttempted, undefined).pipe( Effect.andThen( @@ -1288,6 +1289,102 @@ it.effect.each(["stable", "upstream/release"])( ), ); +it.effect.each([ + { name: "a repository with no commits", setup: [], baseRef: "main", worktree: false }, + { + name: "an orphan branch beside remote-tracking branches", + setup: [ + ["commit", "--allow-empty", "-m", "initial"], + ["update-ref", "refs/remotes/origin/main", "HEAD"], + ["update-ref", "refs/remotes/origin/other", "HEAD"], + ["checkout", "--orphan", "empty"], + ["branch", "-D", "main"], + ], + baseRef: "origin/main...origin/other", + worktree: true, + }, +])("launches a new worktree thread from $name", (scenario) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver.GitVcsDriver; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "launch-unborn-" }); + const cwd = `${directory}/repo`; + yield* fs.makeDirectory(cwd); + for (const args of [ + ["init", "-b", "main"], + ["config", "user.email", "test@example.com"], + ["config", "user.name", "Test"], + ...scenario.setup, + ]) { + yield* git.execute({ operation: "launch-test", cwd, args }); + } + + const harness = makeHarness({ + hasCommit: ({ refName }) => + git + .execute({ + operation: "launch-test", + cwd, + args: ["rev-parse", "--verify", `${refName}^{commit}`], + }) + .pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ), + git: { + currentBranch: () => + git + .execute({ operation: "launch-test", cwd, args: ["symbolic-ref", "--short", "HEAD"] }) + .pipe(Effect.map((result) => result.stdout.trim())), + }, + createWorktree: (input, options) => + git.createWorktree({ ...input, cwd, path: `${directory}/worktree` }, options), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:unborn", + thread: "thread:launch:unborn", + message: "Say hello", + workspace: { type: "worktree", baseRef: scenario.baseRef, branch: "feature" }, + }), + ); + const projection = yield* preparationSettled(launched.threadId); + assert.equal( + projection.thread.worktreePath, + scenario.worktree ? `${directory}/worktree` : null, + ); + assert.equal(projection.runs[0]?.status, "starting"); + }).pipe(Effect.provide(harness.layer)); + }).pipe( + Effect.provide( + GitVcsDriver.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "launch-unborn-git-" })), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), +); + +it.effect("checks out a commit-search base when the current branch has no commits", () => { + const harness = makeHarness({ hasCommit: () => Effect.succeed(false) }); + return Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:commit-search", + thread: "thread:launch:commit-search", + message: "Say hello", + workspace: { type: "worktree", baseRef: ":/initial", branch: "feature" }, + }), + ); + const projection = yield* preparationSettled(launched.threadId); + assert.equal(projection.thread.worktreePath, "/repo-worktrees/feature"); + assert.equal(harness.createWorktree.mock.calls[0]?.[0]?.refName, ":/initial"); + }).pipe(Effect.provide(harness.layer)); +}); + it.effect("fetches the origin base from the remote it already resolved", () => { let fetchInput: Deferred.Deferred | null = null; const harness = makeHarness({ diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index b354ba6737e30..160c028088070 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -554,99 +554,116 @@ const make = Effect.gen(function* () { } } if (shouldFetch) yield* setupTracker.stageStatus(threadId, "fetch", "done"); - if (branch !== null && (generatedTemporaryBranch || isTemporaryWorktreeBranch(branch))) { - const parts = branch.split("/"); - for (let depth = 1; depth < parts.length; depth++) { - if ( - yield* git - .hasCommit({ - cwd: project.workspaceRoot, - refName: `refs/heads/${parts.slice(0, depth).join("/")}`, - }) - .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))) - ) { - branch = generatedTemporaryBranch - ? branch.replaceAll("/", "-") - : flattenTemporaryWorktreeBranchName(branch); - break; + const unborn = yield* Effect.gen(function* () { + const cwd = project.workspaceRoot; + if (yield* git.hasCommit({ cwd, refName: "HEAD" })) return null; + // Only the unborn branch itself has nothing to check out; Git resolves any other base. + const current = yield* git.currentBranch(cwd); + if (startRef !== "HEAD" && startRef !== current) return null; + if (yield* git.hasCommit({ cwd, refName: startRef })) return null; + return { branch: current }; + }).pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))); + if (unborn !== null) { + // A branch with no commits has nothing to check out; run in the project folder. + branch = unborn.branch; + yield* setupTracker.update(threadId, (snapshot) => ({ ...snapshot, branch })); + yield* setupTracker.stageStatus(threadId, "checkout", "skipped"); + yield* setupTracker.stageStatus(threadId, "submodules", "skipped"); + } else { + if (branch !== null && (generatedTemporaryBranch || isTemporaryWorktreeBranch(branch))) { + const parts = branch.split("/"); + for (let depth = 1; depth < parts.length; depth++) { + if ( + yield* git + .hasCommit({ + cwd: project.workspaceRoot, + refName: `refs/heads/${parts.slice(0, depth).join("/")}`, + }) + .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))) + ) { + branch = generatedTemporaryBranch + ? branch.replaceAll("/", "-") + : flattenTemporaryWorktreeBranchName(branch); + break; + } } } - } - yield* setupTracker.stageStatus(threadId, "checkout", "running"); - let submoduleProgressReportedAt = Number.NEGATIVE_INFINITY; - const worktree = yield* git - .createWorktree( - { - cwd: project.workspaceRoot, - refName: startRef, - newRefName: branch!, - baseRefName, - path: null, - }, - { - progress: { - onWorktreeClaimed: (path) => - Effect.sync(() => { - createdWorktreePath = path; - createdBranch = branch; - }), - onCheckoutProgress: (progress) => - setupTracker.stage(threadId, "checkout", { percent: progress.percent }), - onSubmodulesStarted: () => - setupTracker - .stageStatus(threadId, "checkout", "done") - .pipe( - Effect.andThen(setupTracker.stageStatus(threadId, "submodules", "running")), + yield* setupTracker.stageStatus(threadId, "checkout", "running"); + let submoduleProgressReportedAt = Number.NEGATIVE_INFINITY; + const worktree = yield* git + .createWorktree( + { + cwd: project.workspaceRoot, + refName: startRef, + newRefName: branch!, + baseRefName, + path: null, + }, + { + progress: { + onWorktreeClaimed: (path) => + Effect.sync(() => { + createdWorktreePath = path; + createdBranch = branch; + }), + onCheckoutProgress: (progress) => + setupTracker.stage(threadId, "checkout", { percent: progress.percent }), + onSubmodulesStarted: () => + setupTracker + .stageStatus(threadId, "checkout", "done") + .pipe( + Effect.andThen(setupTracker.stageStatus(threadId, "submodules", "running")), + ), + onSubmodulesDisabled: ({ source }) => + setupTracker.stageStatus( + threadId, + "submodules", + "skipped", + `disabled in ${source}`, ), - onSubmodulesDisabled: ({ source }) => - setupTracker.stageStatus( - threadId, - "submodules", - "skipped", - `disabled in ${source}`, - ), - onSubmoduleLine: (line) => { - // Paths may contain quotes; git closes the path with "': ". - const submodulePath = /^\s*Submodule path '(.+)': /.exec(line)?.[1]; - if (submodulePath !== undefined) { - return setupTracker.stage(threadId, "submodules", { detail: submodulePath }); - } - if (parseGitCloneProgressLine(line) === null) return Effect.void; - // Git redraws on every percent; hold the websocket to one update a second. - return Effect.flatMap(Clock.currentTimeMillis, (now) => { - if (now - submoduleProgressReportedAt < 1000) return Effect.void; - submoduleProgressReportedAt = now; - return setupTracker.stage(threadId, "submodules", { - detail: line.trim().replace(/\s+/g, " "), + onSubmoduleLine: (line) => { + // Paths may contain quotes; git closes the path with "': ". + const submodulePath = /^\s*Submodule path '(.+)': /.exec(line)?.[1]; + if (submodulePath !== undefined) { + return setupTracker.stage(threadId, "submodules", { detail: submodulePath }); + } + if (parseGitCloneProgressLine(line) === null) return Effect.void; + // Git redraws on every percent; hold the websocket to one update a second. + return Effect.flatMap(Clock.currentTimeMillis, (now) => { + if (now - submoduleProgressReportedAt < 1000) return Effect.void; + submoduleProgressReportedAt = now; + return setupTracker.stage(threadId, "submodules", { + detail: line.trim().replace(/\s+/g, " "), + }); }); - }); + }, + onSubmodulesFinished: ({ ok, detail }) => + setupTracker.stageStatus( + threadId, + "submodules", + ok ? "done" : "warning", + ok ? undefined : (detail ?? "submodule checkout failed"), + ), }, - onSubmodulesFinished: ({ ok, detail }) => - setupTracker.stageStatus( - threadId, - "submodules", - ok ? "done" : "warning", - ok ? undefined : (detail ?? "submodule checkout failed"), - ), }, - }, - ) - .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))); - worktreePath = worktree.worktree.path; - branch = worktree.worktree.refName; - createdWorktreePath = worktreePath; - createdBranch = branch; - yield* setupTracker.update(threadId, (snapshot) => ({ - ...snapshot, - worktreePath, - branch, - stages: snapshot.stages.map((stage) => - stage.id === "submodules" && stage.status === "pending" - ? { ...stage, status: "skipped", detail: "none" } - : stage, - ), - })); - yield* setupTracker.stageStatus(threadId, "checkout", "done"); + ) + .pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))); + worktreePath = worktree.worktree.path; + branch = worktree.worktree.refName; + createdWorktreePath = worktreePath; + createdBranch = branch; + yield* setupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + worktreePath, + branch, + stages: snapshot.stages.map((stage) => + stage.id === "submodules" && stage.status === "pending" + ? { ...stage, status: "skipped", detail: "none" } + : stage, + ), + })); + yield* setupTracker.stageStatus(threadId, "checkout", "done"); + } } // Do not rewrite reused worktrees; that could undo the original branch rename.