From eecb4d7a7cfa5cddea9875d898bf9588e5521fa8 Mon Sep 17 00:00:00 2001 From: Logan Rupe Date: Wed, 23 Sep 2026 12:57:52 +1000 Subject: [PATCH] feat: pick a base branch per repo for multi-repo isolated runs A multi-repo isolated run based only the anchor repo on the chosen branch; every other repo branched off whatever it had checked out, and mobile's base-branch list was empty because it queried the directory holding the .code-workspace. prepareWorktree gains an optional repoBaseBranches list of per-repo picks. The server bases each non-anchor repo on its pick, else its own default branch (origin/HEAD), else its checkout, and "start from origin" now fetches per repo. The web composer shows a "+N repos" menu next to the anchor's base selector with a branch submenu per repo. Mobile lists the anchor repo's branches and leaves the rest to the server default. Fixes #113 --- .../threads/new-task-flow-provider.tsx | 19 +- apps/server/src/git/GitWorkflowService.ts | 6 + apps/server/src/server.test.ts | 168 +++++++++++++++++- apps/server/src/ws.ts | 56 +++++- apps/web/src/components/BranchToolbar.tsx | 24 +++ apps/web/src/components/ChatView.tsx | 3 + .../src/components/RepoBaseBranchesMenu.tsx | 136 ++++++++++++++ apps/web/src/repoBaseBranchStore.ts | 44 +++++ packages/contracts/src/orchestration.ts | 12 ++ 9 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/RepoBaseBranchesMenu.tsx create mode 100644 apps/web/src/repoBaseBranchStore.ts diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index f2801255b3bc..7087239d5297 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -19,6 +19,7 @@ import { ThreadId, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { resolveAnchorRepoRoot } from "@t3tools/shared/git"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; @@ -630,11 +631,23 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const branchTarget = useMemo( () => ({ environmentId: selectedProject?.environmentId ?? null, - // `|| null` also skips the stand-in project's empty workspaceRoot. - cwd: selectedProject?.workspaceRoot || null, + // Skips the stand-in project's empty workspaceRoot. A workspace-file + // project's workspaceRoot is the directory holding the file, usually + // not a repo, so its refs come from the anchor repo root instead. + cwd: selectedProject?.workspaceRoot + ? resolveAnchorRepoRoot({ + workspaceRoot: selectedProject.workspaceRoot, + repoRoots: selectedProject.repoRoots, + }) + : null, query: debouncedBranchQuery, }), - [debouncedBranchQuery, selectedProject?.environmentId, selectedProject?.workspaceRoot], + [ + debouncedBranchQuery, + selectedProject?.environmentId, + selectedProject?.workspaceRoot, + selectedProject?.repoRoots, + ], ); const branchState = usePaginatedBranches(branchTarget); const branchSearchIsDebouncing = branchQuery.trim() !== debouncedBranchQuery.trim(); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index f5f5a6d39336..c0ffda682662 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -40,6 +40,8 @@ export class GitWorkflowService extends Context.Service< readonly cwd: string; readonly refName: string; }) => Effect.Effect; + /** The branch `origin/HEAD` points at, or null when origin has none. */ + readonly resolveDefaultBranch: (cwd: string) => Effect.Effect; readonly status: ( input: VcsStatusInput, ) => Effect.Effect; @@ -295,6 +297,10 @@ export const make = Effect.gen(function* () { ), Effect.map((result) => result.exitCode === 0), ), + resolveDefaultBranch: (cwd) => + ensureGitCommand("GitWorkflowService.resolveDefaultBranch", cwd).pipe( + Effect.andThen(git.resolveDefaultBranchName(cwd, "origin")), + ), 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 41ca635ba9bf..aee741f46fb6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -11617,6 +11617,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { layers: { gitVcsDriver: { execute, + // No origin/HEAD, so the cousin falls back to its checked-out ref. + resolveDefaultBranchName: () => Effect.succeed(null), remoteExists, fetchRemote, remoteBranchExists, @@ -11718,8 +11720,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - // The base ref the user picked lands on the anchor repo; the cousin - // branches off its own HEAD instead of silently reusing it. + // The base ref the user picked lands on the anchor repo. The cousin + // resolves its own base ("dev") and starts it from origin too. + assert.deepEqual( + fetchRemote.mock.calls.map((call) => [call[0]?.cwd, call[0]?.refName]), + [ + [anchorRepoRoot, "main"], + [cousinRepoRoot, "dev"], + ], + ); assert.deepEqual( createWorktree.mock.calls.map((call) => ({ cwd: call[0]?.cwd, @@ -11727,7 +11736,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { })), [ { cwd: anchorRepoRoot, refName: fetchedOriginCommit }, - { cwd: cousinRepoRoot, refName: "dev" }, + { cwd: cousinRepoRoot, refName: fetchedOriginCommit }, ], ); @@ -11745,6 +11754,159 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("bases each multi-repo cousin on its pick, else its own default branch", () => + Effect.gen(function* () { + // A `.code-workspace` project's `projectCwd` is the directory holding + // the file, which is not a git repo — the chosen base ref belongs to the + // first repo root, and git commands must run there. + const anchorRepoRoot = "/tmp/workspace/api"; + const cousinRepoRoot = "/tmp/workspace/web"; + const defaultedRepoRoot = "/tmp/workspace/docs"; + const dispatchedCommands: Array = []; + const remoteExists = vi.fn( + (_: Parameters[0]) => + Effect.succeed(true), + ); + const fetchRemote = vi.fn( + (_: Parameters[0]) => Effect.void, + ); + const remoteBranchExists = vi.fn( + (_: Parameters[0]) => + Effect.succeed(true), + ); + const fetchedOriginCommit = "0123456789abcdef0123456789abcdef01234567"; + const resolveRemoteTrackingCommit = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + commitSha: fetchedOriginCommit, + remoteRefName: "origin/main", + }), + ); + const execute = vi.fn((_: Parameters[0]) => + Effect.succeed(SUCCESSFUL_GIT_EXECUTION), + ); + const createWorktree = vi.fn( + (input: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: `${input.cwd}-worktree`, + }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + execute, + resolveDefaultBranchName: (cwd) => + Effect.succeed(cwd === defaultedRepoRoot ? "master" : "main"), + remoteExists, + fetchRemote, + remoteBranchExists, + resolveRemoteTrackingCommit, + createWorktree, + }, + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, + gitManager: { + // The cousin repo branches off its own checked-out ref. + localStatus: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "dev", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + }, + projectionSnapshotQuery: { + getProjectShellById: () => + Effect.succeed( + Option.some({ + id: defaultProjectId, + title: "Workspace", + workspaceRoot: "/tmp/workspace", + workspaceFile: "/tmp/workspace/project.code-workspace", + repoRoots: [anchorRepoRoot, cousinRepoRoot, defaultedRepoRoot], + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ), + }, + 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-multi-repo-bases"), + threadId: ThreadId.make("thread-bootstrap-multi-repo-bases"), + message: { + messageId: MessageId.make("msg-bootstrap-multi-repo-bases"), + 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/workspace", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + repoBaseBranches: [{ repoRoot: cousinRepoRoot, baseBranch: "release" }], + }, + }, + createdAt, + }), + ), + ); + + // No "start from origin": nothing is fetched, and each cousin uses the + // client's pick or, without one, its own origin/HEAD branch rather + // than whatever it has checked out ("dev"). + assert.equal(fetchRemote.mock.calls.length, 0); + assert.deepEqual( + createWorktree.mock.calls.map((call) => ({ + cwd: call[0]?.cwd, + refName: call[0]?.refName, + })), + [ + { cwd: anchorRepoRoot, refName: "main" }, + { cwd: cousinRepoRoot, refName: "release" }, + { cwd: defaultedRepoRoot, refName: "master" }, + ], + ); + }).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/ws.ts b/apps/server/src/ws.ts index 9db420769c93..04ee1b4f5f49 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1536,10 +1536,56 @@ const makeWsRpcLayer = ( // Multi-repo projects fan the isolated run out to one worktree per // repo root. The anchor repo root goes first so the thread's // `worktreePath` stays `worktrees[0]` and carries the setup - // progress; the other roots branch off their current HEAD. + // progress. const cousinRepoRoots = (projectShell?.repoRoots ?? []).filter( (repoRoot) => repoRoot !== anchorRepoRoot, ); + // A cousin bases on the client's pick for it, else its own + // default branch (origin/HEAD), else whatever it has checked out. + // "Start from origin" applies per repo, falling back to the local + // branch; a default branch that only exists on origin still works. + const resolveCousinBase = (repoRoot: string) => + Effect.gen(function* () { + const baseBranch = + prepareWorktree.repoBaseBranches?.find((entry) => entry.repoRoot === repoRoot) + ?.baseBranch ?? + (yield* gitWorkflow + .resolveDefaultBranch(repoRoot) + .pipe(Effect.orElseSucceed(() => null))) ?? + (yield* gitWorkflow.localStatus({ cwd: repoRoot }).pipe( + Effect.map((status) => status.refName), + Effect.orElseSucceed(() => null), + )); + if (!baseBranch) return { baseRef: "HEAD" }; + if ( + prepareWorktree.startFromOrigin === true && + (yield* gitWorkflow + .remoteExists({ cwd: repoRoot, remoteName: "origin" }) + .pipe(Effect.orElseSucceed(() => false))) + ) { + const remoteBase = yield* gitWorkflow + .fetchRemote({ cwd: repoRoot, remoteName: "origin", refName: baseBranch }) + .pipe( + Effect.andThen( + gitWorkflow.resolveRemoteTrackingCommit({ + cwd: repoRoot, + refName: baseBranch, + fallbackRemoteName: "origin", + }), + ), + Effect.map((resolved) => resolved.commitSha), + Effect.orElseSucceed(() => null), + ); + if (remoteBase) return { baseRef: remoteBase, baseRefName: baseBranch }; + } + for (const candidate of [baseBranch, `origin/${baseBranch}`]) { + const exists = yield* gitWorkflow + .hasCommit({ cwd: repoRoot, refName: candidate }) + .pipe(Effect.orElseSucceed(() => false)); + if (exists) return { baseRef: candidate, baseRefName: baseBranch }; + } + return { baseRef: "HEAD" }; + }); yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running"); let checkoutTotal: number | null = null; @@ -1628,12 +1674,10 @@ const makeWsRpcLayer = ( : yield* Effect.forEach( cousinRepoRoots, (repoRoot): Effect.Effect => - gitWorkflow.localStatus({ cwd: repoRoot }).pipe( - Effect.map((status) => status.refName ?? "HEAD"), - Effect.orElseSucceed(() => "HEAD"), - Effect.map((baseRef) => ({ + resolveCousinBase(repoRoot).pipe( + Effect.map((base) => ({ repoRoot, - baseRef, + ...base, newBranch: prepareWorktree.branch ?? null, options: { submodules, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 607236f2d63b..c0d9deb1368d 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -26,6 +26,7 @@ import { useProject, useThreadShell, useThreadShellsForProjectRefs } from "../st import { type EnvMode, type EnvironmentOption, + resolveAnchorRepoRoot, resolveContextStripLabelsCompact, resolveCurrentWorkspaceLabel, resolveEnvModeLabel, @@ -41,6 +42,7 @@ import { } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; +import { RepoBaseBranchesMenu } from "./RepoBaseBranchesMenu"; import { Button } from "./ui/button"; import { Menu, @@ -599,6 +601,20 @@ export const BranchToolbar = memo(function BranchToolbar({ const [stripElement, setStripElement] = useState(null); const labelsOverflow = useLabelsOverflow(stripElement); + // A new isolated run of a multi-repo project fans out to every repo root; + // the roots besides the anchor get their own base-branch picker. + const otherRepoRoots = + activeProject && effectiveEnvMode === "worktree" && !activeWorktreePath + ? (activeProject.repoRoots ?? []).filter( + (repoRoot) => + repoRoot !== + resolveAnchorRepoRoot({ + workspaceRoot: activeProject.workspaceRoot, + repoRoots: activeProject.repoRoots, + }), + ) + : []; + if (!hasActiveThread || !activeProject) return null; return ( @@ -687,6 +703,14 @@ export const BranchToolbar = memo(function BranchToolbar({ /> ) : null} + {showGitControls && otherRepoRoots.length > 0 ? ( + + ) : null} {showGitControls ? ( " selector; each + * other root shows its own default branch (origin/HEAD) until the user picks + * one, matching what the server falls back to. + */ +export const RepoBaseBranchesMenu = memo(function RepoBaseBranchesMenu({ + environmentId, + threadId, + repoRoots, + startFromOrigin, +}: { + environmentId: EnvironmentId; + threadId: ThreadId; + repoRoots: ReadonlyArray; + startFromOrigin: boolean; +}) { + const composerFloatingLayerProps = useComposerMenuProps(); + const threadKey = scopedThreadKey(scopeThreadRef(environmentId, threadId)); + return ( + + } + aria-label="Base branches for the other repos" + data-composer-context-control + > + +{repoRoots.length}{" "} + {repoRoots.length === 1 ? "repo" : "repos"} + + + + + Base for the other repos + {repoRoots.map((repoRoot) => ( + + ))} + + + + ); +}); + +function RepoBaseBranchSub({ + environmentId, + threadKey, + repoRoot, + startFromOrigin, +}: { + environmentId: EnvironmentId; + threadKey: string; + repoRoot: string; + startFromOrigin: boolean; +}) { + const pick = useRepoBaseBranchStore((store) => store.byThread[threadKey]?.[repoRoot] ?? null); + const setBase = useRepoBaseBranchStore((store) => store.setBase); + const refTarget = useMemo( + () => ({ environmentId, cwd: repoRoot, query: null }), + [environmentId, repoRoot], + ); + const { refs } = usePaginatedBranches(refTarget); + // Local branches, plus the default branch's origin copy when it has no local + // one, so the default the server would use is always pickable. + const branchNames = useMemo(() => { + const local = refs.filter((ref) => !ref.isRemote).map((ref) => ref.name); + const remoteDefault = refs.find((ref) => ref.isRemote && ref.isDefault); + return remoteDefault && !refs.some((ref) => !ref.isRemote && ref.isDefault) + ? [remoteDefault.name, ...local] + : local; + }, [refs]); + const defaultName = + refs.find((ref) => ref.isDefault && !ref.isRemote)?.name ?? + refs.find((ref) => ref.isDefault)?.name ?? + refs.find((ref) => ref.current)?.name ?? + null; + const selected = pick ?? defaultName; + const selectedLabel = + selected && startFromOrigin && !selected.startsWith("origin/") + ? `origin/${selected}` + : selected; + + return ( + + + {repoName(repoRoot)} + + {selectedLabel ?? "Loading…"} + + + + setBase(threadKey, repoRoot, String(value))} + > + {branchNames.map((name) => ( + + {name} + + ))} + + + + ); +} diff --git a/apps/web/src/repoBaseBranchStore.ts b/apps/web/src/repoBaseBranchStore.ts new file mode 100644 index 000000000000..958474d07754 --- /dev/null +++ b/apps/web/src/repoBaseBranchStore.ts @@ -0,0 +1,44 @@ +/** + * Per-repo base branches a user picked for a multi-repo thread's next isolated + * run, keyed by scoped thread key then repo root. Only explicit picks live + * here; roots without one base on their own default branch, which the server + * resolves. In-memory on purpose: a pick only matters until the run starts. + */ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { create } from "zustand"; + +interface RepoBaseBranchStore { + readonly byThread: Readonly>>>; + readonly setBase: (threadKey: string, repoRoot: string, baseBranch: string) => void; +} + +export const useRepoBaseBranchStore = create((set) => ({ + byThread: {}, + setBase: (threadKey, repoRoot, baseBranch) => + set((state) => ({ + byThread: { + ...state.byThread, + [threadKey]: { ...state.byThread[threadKey], [repoRoot]: baseBranch }, + }, + })), +})); + +/** + * The picks a thread holds for `repoRoots`, spread into `prepareWorktree` when + * a send creates an isolated run. Empty when there are none. + */ +export function repoBaseBranchesForSend( + thread: { readonly environmentId: EnvironmentId; readonly id: ThreadId }, + repoRoots: ReadonlyArray | undefined, +): { repoBaseBranches?: ReadonlyArray<{ repoRoot: string; baseBranch: string }> } { + const picks = + useRepoBaseBranchStore.getState().byThread[ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) + ] ?? {}; + const repoBaseBranches = (repoRoots ?? []).flatMap((repoRoot) => { + const baseBranch = picks[repoRoot]; + return baseBranch ? [{ repoRoot, baseBranch }] : []; + }); + return repoBaseBranches.length > 0 ? { repoBaseBranches } : {}; +} diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 1acf0d44d413..cd9fec48d6b1 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1319,9 +1319,21 @@ const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ createdAt: IsoDateTime, }); +/** A multi-repo isolated run's base branch for one non-anchor repo root. */ +export const ThreadTurnStartBootstrapRepoBaseBranch = Schema.Struct({ + repoRoot: TrimmedNonEmptyString, + baseBranch: TrimmedNonEmptyString, +}); + const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ projectCwd: TrimmedNonEmptyString, + /** Base for the anchor repo, the only repo in a single-repo project. */ baseBranch: TrimmedNonEmptyString, + /** + * Per-repo overrides for the other roots of a multi-repo project. Roots left + * out base on their own default branch (origin/HEAD). + */ + repoBaseBranches: Schema.optional(Schema.Array(ThreadTurnStartBootstrapRepoBaseBranch)), branch: Schema.optional(TrimmedNonEmptyString), startFromOrigin: Schema.optional(Schema.Boolean), requireWorktree: Schema.optional(Schema.Boolean),