Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export class GitWorkflowService extends Context.Service<
readonly cwd: string;
readonly refName: string;
}) => Effect.Effect<boolean, GitCommandError>;
/** The branch `origin/HEAD` points at, or null when origin has none. */
readonly resolveDefaultBranch: (cwd: string) => Effect.Effect<string | null, GitCommandError>;
readonly status: (
input: VcsStatusInput,
) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
Expand Down Expand Up @@ -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) =>
Expand Down
168 changes: 165 additions & 3 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -11718,16 +11720,23 @@ 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,
refName: call[0]?.refName,
})),
[
{ cwd: anchorRepoRoot, refName: fetchedOriginCommit },
{ cwd: cousinRepoRoot, refName: "dev" },
{ cwd: cousinRepoRoot, refName: fetchedOriginCommit },
],
);

Expand All @@ -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<OrchestrationCommand> = [];
const remoteExists = vi.fn(
(_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["remoteExists"]>[0]) =>
Effect.succeed(true),
);
const fetchRemote = vi.fn(
(_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"]>[0]) => Effect.void,
);
const remoteBranchExists = vi.fn(
(_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["remoteBranchExists"]>[0]) =>
Effect.succeed(true),
);
const fetchedOriginCommit = "0123456789abcdef0123456789abcdef01234567";
const resolveRemoteTrackingCommit = vi.fn(
(_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["resolveRemoteTrackingCommit"]>[0]) =>
Effect.succeed({
commitSha: fetchedOriginCommit,
remoteRefName: "origin/main",
}),
);
const execute = vi.fn((_: Parameters<GitVcsDriver.GitVcsDriver["Service"]["execute"]>[0]) =>
Effect.succeed(SUCCESSFUL_GIT_EXECUTION),
);
const createWorktree = vi.fn(
(input: Parameters<GitVcsDriver.GitVcsDriver["Service"]["createWorktree"]>[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<OrchestrationCommand> = [];
Expand Down
56 changes: 50 additions & 6 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1628,12 +1674,10 @@ const makeWsRpcLayer = (
: yield* Effect.forEach(
cousinRepoRoots,
(repoRoot): Effect.Effect<WorktreeFanoutTarget> =>
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,
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useProject, useThreadShell, useThreadShellsForProjectRefs } from "../st
import {
type EnvMode,
type EnvironmentOption,
resolveAnchorRepoRoot,
resolveContextStripLabelsCompact,
resolveCurrentWorkspaceLabel,
resolveEnvModeLabel,
Expand All @@ -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,
Expand Down Expand Up @@ -599,6 +601,20 @@ export const BranchToolbar = memo(function BranchToolbar({
const [stripElement, setStripElement] = useState<HTMLDivElement | null>(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 (
Expand Down Expand Up @@ -687,6 +703,14 @@ export const BranchToolbar = memo(function BranchToolbar({
/>
) : null}

{showGitControls && otherRepoRoots.length > 0 ? (
<RepoBaseBranchesMenu
environmentId={environmentId}
threadId={threadId}
repoRoots={otherRepoRoots}
startFromOrigin={startFromOrigin}
/>
) : null}
{showGitControls ? (
<BranchToolbarBranchSelector
forceNewWorktree={forceNewWorktree}
Expand Down
Loading
Loading