From 0cba575eb5b406bceeed1d77ab9cfff0ebd6109e Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 17 Oct 2026 07:35:02 +0000 Subject: [PATCH 01/15] fix(mobile): a new thread on a branch held by a worktree uses that worktree Before checking out a branch in the project checkout, the new-thread flow now asks the server where that branch lives right now. If another worktree holds it, the thread binds to that worktree instead of running git checkout in the main checkout, which git refuses. This covers a remote ref whose local branch is in a worktree, and "New thread on " from a thread that started on the main checkout before its branch moved into a worktree. Closes #20810 Co-Authored-By: Claude Opus 5.5 --- .../threads/NewTaskContextPickerScreens.tsx | 3 + .../threads/NewTaskDraftRouteScreen.tsx | 22 +++- .../threads/checkout-new-task-branch.test.ts | 102 +++++++++++++++++- .../threads/checkout-new-task-branch.ts | 33 +++++- packages/client-runtime/src/state/vcs.ts | 6 ++ packages/shared/src/git.ts | 2 +- 6 files changed, 156 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index c0c3d24113b5d..c5e729868313e 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -266,6 +266,7 @@ export function NewTaskBranchPickerRouteScreen() { AuthSourceControlWriteScope, ); const navigation = useNavigation(); + const readRefs = useAtomCommand(vcsEnvironment.readRefs, { reportFailure: false }); const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const [switchingBranchName, setSwitchingBranchName] = useState(null); const selectingBranchNameRef = useRef(null); @@ -310,6 +311,7 @@ export function NewTaskBranchPickerRouteScreen() { branch, project: flow.selectedProject, workspaceMode: flow.workspaceMode, + readRefs, switchRef, }); if (result._tag === "Failure") { @@ -348,6 +350,7 @@ export function NewTaskBranchPickerRouteScreen() { flow.setBranchQuery, flow.workspaceMode, navigation, + readRefs, switchRef, ], ); diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index 52b975289f356..6e226e693319e 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -12,6 +12,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { useWorkspaceState } from "../../state/workspace"; import { vcsEnvironment } from "../../state/vcs"; import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; +import { resolveNewTaskBranchWorktreePath } from "./new-task-context-presentation"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; @@ -37,6 +38,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps count - 1); @@ -110,6 +113,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps - result?._tag === "Success" - ? { ...initialProjectRef, branch: result.value.name } + result?._tag === "Success" && workspaceRoot + ? { + ...initialProjectRef, + branch: result.value.name, + worktreePath: resolveNewTaskBranchWorktreePath({ + workspaceMode: "local", + projectCwd: workspaceRoot, + branchWorktreePath: result.value.worktreePath, + }), + } : initialProjectRef, - [initialProjectRef, result], + [initialProjectRef, result, workspaceRoot], ); const preparingBranch = checkoutPending || (needsPreparation && result?._tag !== "Success"); diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts index 6440e63912e7d..c2c54e58d93ef 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts @@ -35,16 +35,72 @@ afterEach(async () => { await NodeFSP.rm(directory, { recursive: true, force: true }); }); -function selectBranch(switchRef: Parameters[0]["switchRef"]) { +type CheckoutInput = Parameters[0]; + +// Mirrors the server's local ref listing: a branch's worktree comes from +// `git worktree list`, and `current` means the queried checkout holds it. +const readRefs: CheckoutInput["readRefs"] = (request) => + settlePromise(async () => { + expect(request.environmentId).toBe(environmentId); + const { stdout: worktrees } = await exec("git", [ + "-C", + request.input.cwd, + "worktree", + "list", + "--porcelain", + ]); + const worktreeByBranch = new Map(); + let path: string | null = null; + for (const line of worktrees.split("\n")) { + if (line.startsWith("worktree ")) path = line.slice("worktree ".length); + if (line.startsWith("branch refs/heads/") && path) { + worktreeByBranch.set(line.slice("branch refs/heads/".length), path); + } + } + const { stdout: names } = await exec("git", [ + "-C", + request.input.cwd, + "for-each-ref", + "--format=%(refname:short)", + "refs/heads", + ]); + const refs = names + .split("\n") + .filter((name) => name.length > 0 && name.includes(request.input.query ?? "")) + .map((name) => { + const worktreePath = worktreeByBranch.get(name) ?? null; + return { + name, + current: worktreePath === request.input.cwd, + isDefault: false, + isRemote: false, + worktreePath, + }; + }); + return { + refs, + isRepo: true, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: refs.length, + }; + }); + +function selectBranch(switchRef: CheckoutInput["switchRef"]) { return checkoutNewTaskBranch({ branch, project: { environmentId, workspaceRoot: cwd }, workspaceMode: "local", + readRefs, switchRef, }); } -const switchRef: Parameters[0]["switchRef"] = (request) => +const refuseSwitch: CheckoutInput["switchRef"] = () => { + throw new Error("A branch held by a worktree must not switch the project checkout"); +}; + +const switchRef: CheckoutInput["switchRef"] = (request) => settlePromise(async () => { expect(request.environmentId).toBe(environmentId); await exec("git", ["-C", request.input.cwd, "checkout", request.input.refName]); @@ -100,6 +156,7 @@ describe("new-task branch checkout", () => { branch, project: null, workspaceMode: "local", + readRefs, switchRef: () => { throw new Error("An unavailable project must not run checkout"); }, @@ -117,9 +174,8 @@ describe("new-task branch checkout", () => { branch: { ...branch, worktreePath }, project: { environmentId, workspaceRoot: cwd }, workspaceMode: "local", - switchRef: () => { - throw new Error("Existing worktrees must not switch the project checkout"); - }, + readRefs, + switchRef: refuseSwitch, }); expect(result._tag).toBe("Success"); if (result._tag !== "Success") throw new Error("Worktree selection failed"); @@ -129,4 +185,40 @@ describe("new-task branch checkout", () => { "feature\n", ); }); + + it("reuses the worktree a branch moved into after the thread remembered it", async () => { + const worktreePath = NodePath.join(directory, "worktree"); + await git("worktree", "add", worktreePath, "feature/a"); + const result = await selectBranch(refuseSwitch); + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") throw new Error("Worktree selection failed"); + expect(result.value.name).toBe("feature/a"); + expect(result.value.worktreePath).toBe(await NodeFSP.realpath(worktreePath)); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + }); + + it("resolves a remote ref to the worktree holding its local branch", async () => { + const worktreePath = NodePath.join(directory, "worktree"); + await git("worktree", "add", worktreePath, "feature/a"); + const result = await checkoutNewTaskBranch({ + branch: { ...branch, name: "upstream/feature/a", isRemote: true, remoteName: "upstream" }, + project: { environmentId, workspaceRoot: cwd }, + workspaceMode: "local", + readRefs, + switchRef: refuseSwitch, + }); + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") throw new Error("Remote worktree selection failed"); + expect(result.value).toMatchObject({ name: "feature/a", isRemote: false }); + expect(result.value.worktreePath).toBe(await NodeFSP.realpath(worktreePath)); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + }); + + it("skips checkout when the project checkout already holds the branch", async () => { + await git("checkout", "feature/a"); + const result = await selectBranch(refuseSwitch); + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") throw new Error("Current branch selection failed"); + expect(result.value.current).toBe(true); + }); }); diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.ts index 3ae5dde1670f7..5e46000e9acb1 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.ts @@ -4,7 +4,13 @@ import { type AtomCommandResult, mapAtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import type { VcsSwitchRefInput, VcsSwitchRefResult } from "@t3tools/contracts"; +import type { + VcsListRefsInput, + VcsListRefsResult, + VcsSwitchRefInput, + VcsSwitchRefResult, +} from "@t3tools/contracts"; +import { deriveLocalBranchNameCandidatesFromRemoteRef } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; @@ -14,6 +20,10 @@ export async function checkoutNewTaskBranch(input: { readonly branch: VcsRef; readonly project: Pick | null; readonly workspaceMode: "local" | "worktree"; + readonly readRefs: (request: { + readonly environmentId: EnvironmentProject["environmentId"]; + readonly input: VcsListRefsInput; + }) => Promise>; readonly switchRef: (request: { readonly environmentId: EnvironmentProject["environmentId"]; readonly input: VcsSwitchRefInput; @@ -34,6 +44,27 @@ export async function checkoutNewTaskBranch(input: { return AsyncResult.success(input.branch); } + // A thread's remembered location and the cached picker list can both be out + // of date, and a remote ref never carries its local branch's worktree. Git + // refuses to check out a branch another worktree holds, so reuse that one. + const localNames = input.branch.isRemote + ? deriveLocalBranchNameCandidatesFromRemoteRef(input.branch.name, input.branch.remoteName) + : [input.branch.name]; + for (const localName of localNames) { + const refs = await input.readRefs({ + environmentId: input.project.environmentId, + input: { + cwd: input.project.workspaceRoot, + query: localName, + refKind: "local", + refresh: true, + }, + }); + if (refs._tag === "Failure") return AsyncResult.failure(refs.cause); + const local = refs.value.refs.find((ref) => ref.name === localName); + if (local?.current || local?.worktreePath) return AsyncResult.success(local); + } + const result = await input.switchRef({ environmentId: input.project.environmentId, input: { cwd: input.project.workspaceRoot, refName: input.branch.name }, diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 3a638e2b512d1..96b00334d0fa3 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -277,6 +277,12 @@ export function createVcsEnvironmentAtoms( return { listRefs, + // An uncached read for decisions that must match the repository now, not + // the persisted list a picker renders from. + readRefs: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:read-refs", + tag: WS_METHODS.vcsListRefs, + }), status: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:status", idleTtlMs: VCS_STATUS_IDLE_TTL_MS, diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 14af818f4c216..114ea8a5a3672 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -244,7 +244,7 @@ export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | nu return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; } -function deriveLocalBranchNameCandidatesFromRemoteRef( +export function deriveLocalBranchNameCandidatesFromRemoteRef( branchName: string, remoteName?: string, ): ReadonlyArray { From b06fac87e96823742b2952bf54384c2ed12936f1 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 17 Oct 2026 07:43:44 +0000 Subject: [PATCH 02/15] fix(mobile): keep looking past the first page for the branch's worktree The ref query is a substring match, so in a repository with many similarly named branches the exact one can land past the first page. Co-Authored-By: Claude Opus 5.5 --- .../threads/checkout-new-task-branch.test.ts | 18 ++++++++-- .../threads/checkout-new-task-branch.ts | 34 ++++++++++++------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts index c2c54e58d93ef..d0a7463db2afa 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts @@ -37,6 +37,8 @@ afterEach(async () => { type CheckoutInput = Parameters[0]; +const REF_PAGE_SIZE = 2; + // Mirrors the server's local ref listing: a branch's worktree comes from // `git worktree list`, and `current` means the queried checkout holds it. const readRefs: CheckoutInput["readRefs"] = (request) => @@ -77,11 +79,13 @@ const readRefs: CheckoutInput["readRefs"] = (request) => worktreePath, }; }); + const cursor = request.input.cursor ?? 0; + const page = refs.slice(cursor, cursor + REF_PAGE_SIZE); return { - refs, + refs: page, isRepo: true, hasPrimaryRemote: false, - nextCursor: null, + nextCursor: cursor + page.length < refs.length ? cursor + page.length : null, totalCount: refs.length, }; }); @@ -221,4 +225,14 @@ describe("new-task branch checkout", () => { if (result._tag !== "Success") throw new Error("Current branch selection failed"); expect(result.value.current).toBe(true); }); + + it("finds the branch past the first page of similarly named branches", async () => { + for (const name of ["a-feature/a", "b-feature/a", "c-feature/a"]) await git("branch", name); + const worktreePath = NodePath.join(directory, "worktree"); + await git("worktree", "add", worktreePath, "feature/a"); + const result = await selectBranch(refuseSwitch); + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") throw new Error("Paged worktree selection failed"); + expect(result.value.worktreePath).toBe(await NodeFSP.realpath(worktreePath)); + }); }); diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.ts index 5e46000e9acb1..9aacfb42fe929 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.ts @@ -51,18 +51,28 @@ export async function checkoutNewTaskBranch(input: { ? deriveLocalBranchNameCandidatesFromRemoteRef(input.branch.name, input.branch.remoteName) : [input.branch.name]; for (const localName of localNames) { - const refs = await input.readRefs({ - environmentId: input.project.environmentId, - input: { - cwd: input.project.workspaceRoot, - query: localName, - refKind: "local", - refresh: true, - }, - }); - if (refs._tag === "Failure") return AsyncResult.failure(refs.cause); - const local = refs.value.refs.find((ref) => ref.name === localName); - if (local?.current || local?.worktreePath) return AsyncResult.success(local); + // The query is a substring match, so the exact branch can sit past the + // first page in a repository with many similarly named branches. + let cursor: number | undefined; + for (;;) { + const refs = await input.readRefs({ + environmentId: input.project.environmentId, + input: { + cwd: input.project.workspaceRoot, + query: localName, + refKind: "local", + ...(cursor === undefined ? { refresh: true } : { cursor }), + }, + }); + if (refs._tag === "Failure") return AsyncResult.failure(refs.cause); + const local = refs.value.refs.find((ref) => ref.name === localName); + if (local) { + if (local.current || local.worktreePath) return AsyncResult.success(local); + break; + } + if (refs.value.nextCursor === null) break; + cursor = refs.value.nextCursor; + } } const result = await input.switchRef({ From 6eaee3615fed2d654be11854b2c936ac073cffc9 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 17 Oct 2026 07:46:03 +0000 Subject: [PATCH 03/15] fix(mobile): look up branches longer than the ref query limit Co-Authored-By: Claude Opus 5.5 --- .../threads/checkout-new-task-branch.test.ts | 20 +++++++++++++++++++ .../threads/checkout-new-task-branch.ts | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts index d0a7463db2afa..dbaa49d1ac9fc 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts @@ -235,4 +235,24 @@ describe("new-task branch checkout", () => { if (result._tag !== "Success") throw new Error("Paged worktree selection failed"); expect(result.value.worktreePath).toBe(await NodeFSP.realpath(worktreePath)); }); + + it("looks up a branch whose name is longer than the ref query limit", async () => { + const longName = `feature/${"a".repeat(120)}/${"b".repeat(150)}`; + await git("branch", longName); + const worktreePath = NodePath.join(directory, "worktree"); + await git("worktree", "add", worktreePath, longName); + const result = await checkoutNewTaskBranch({ + branch: { ...branch, name: longName }, + project: { environmentId, workspaceRoot: cwd }, + workspaceMode: "local", + readRefs: (request) => { + expect(request.input.query?.length).toBeLessThanOrEqual(256); + return readRefs(request); + }, + switchRef: refuseSwitch, + }); + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") throw new Error("Long branch selection failed"); + expect(result.value.worktreePath).toBe(await NodeFSP.realpath(worktreePath)); + }); }); diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.ts index 9aacfb42fe929..66a9b802022f9 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.ts @@ -16,6 +16,8 @@ import { AsyncResult } from "effect/reactivity"; import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; +const VCS_REF_QUERY_MAX_LENGTH = 256; + export async function checkoutNewTaskBranch(input: { readonly branch: VcsRef; readonly project: Pick | null; @@ -59,7 +61,9 @@ export async function checkoutNewTaskBranch(input: { environmentId: input.project.environmentId, input: { cwd: input.project.workspaceRoot, - query: localName, + // The contract caps queries; a prefix still matches, and the exact + // name is compared below. + query: localName.slice(0, VCS_REF_QUERY_MAX_LENGTH), refKind: "local", ...(cursor === undefined ? { refresh: true } : { cursor }), }, From 523a4feec583c3f11cdd28b177c5b155881f91f7 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 17 Oct 2026 07:50:52 +0000 Subject: [PATCH 04/15] fix(mobile): map a remote ref to its local branch by the ref's own remote name Remote names can contain slashes, so splitting at the first slash could pick an unrelated local branch. Co-Authored-By: Claude Opus 5.5 --- .../threads/checkout-new-task-branch.test.ts | 24 +++++++ .../threads/checkout-new-task-branch.ts | 62 ++++++++++--------- packages/shared/src/git.ts | 2 +- 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts index dbaa49d1ac9fc..e2894fe47fdda 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts @@ -255,4 +255,28 @@ describe("new-task branch checkout", () => { if (result._tag !== "Success") throw new Error("Long branch selection failed"); expect(result.value.worktreePath).toBe(await NodeFSP.realpath(worktreePath)); }); + + it("strips a remote name that contains a slash before looking up the branch", async () => { + await git("branch", "upstream/feature/a"); + const decoyPath = NodePath.join(directory, "decoy"); + await git("worktree", "add", decoyPath, "upstream/feature/a"); + const worktreePath = NodePath.join(directory, "worktree"); + await git("worktree", "add", worktreePath, "feature/a"); + const result = await checkoutNewTaskBranch({ + branch: { + ...branch, + name: "team/upstream/feature/a", + isRemote: true, + remoteName: "team/upstream", + }, + project: { environmentId, workspaceRoot: cwd }, + workspaceMode: "local", + readRefs, + switchRef: refuseSwitch, + }); + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") throw new Error("Remote worktree selection failed"); + expect(result.value.name).toBe("feature/a"); + expect(result.value.worktreePath).toBe(await NodeFSP.realpath(worktreePath)); + }); }); diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.ts index 66a9b802022f9..d0b42160f4d23 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.ts @@ -10,7 +10,7 @@ import type { VcsSwitchRefInput, VcsSwitchRefResult, } from "@t3tools/contracts"; -import { deriveLocalBranchNameCandidatesFromRemoteRef } from "@t3tools/shared/git"; +import { deriveLocalBranchNameFromRemoteRef } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; @@ -49,34 +49,30 @@ export async function checkoutNewTaskBranch(input: { // A thread's remembered location and the cached picker list can both be out // of date, and a remote ref never carries its local branch's worktree. Git // refuses to check out a branch another worktree holds, so reuse that one. - const localNames = input.branch.isRemote - ? deriveLocalBranchNameCandidatesFromRemoteRef(input.branch.name, input.branch.remoteName) - : [input.branch.name]; - for (const localName of localNames) { - // The query is a substring match, so the exact branch can sit past the - // first page in a repository with many similarly named branches. - let cursor: number | undefined; - for (;;) { - const refs = await input.readRefs({ - environmentId: input.project.environmentId, - input: { - cwd: input.project.workspaceRoot, - // The contract caps queries; a prefix still matches, and the exact - // name is compared below. - query: localName.slice(0, VCS_REF_QUERY_MAX_LENGTH), - refKind: "local", - ...(cursor === undefined ? { refresh: true } : { cursor }), - }, - }); - if (refs._tag === "Failure") return AsyncResult.failure(refs.cause); - const local = refs.value.refs.find((ref) => ref.name === localName); - if (local) { - if (local.current || local.worktreePath) return AsyncResult.success(local); - break; - } - if (refs.value.nextCursor === null) break; - cursor = refs.value.nextCursor; + const localName = localBranchName(input.branch); + // The query is a substring match, so the exact branch can sit past the + // first page in a repository with many similarly named branches. + let cursor: number | undefined; + for (;;) { + const refs = await input.readRefs({ + environmentId: input.project.environmentId, + input: { + cwd: input.project.workspaceRoot, + // The contract caps queries; a prefix still matches, and the exact + // name is compared below. + query: localName.slice(0, VCS_REF_QUERY_MAX_LENGTH), + refKind: "local", + ...(cursor === undefined ? { refresh: true } : { cursor }), + }, + }); + if (refs._tag === "Failure") return AsyncResult.failure(refs.cause); + const local = refs.value.refs.find((ref) => ref.name === localName); + if (local) { + if (local.current || local.worktreePath) return AsyncResult.success(local); + break; } + if (refs.value.nextCursor === null) break; + cursor = refs.value.nextCursor; } const result = await input.switchRef({ @@ -90,3 +86,13 @@ export async function checkoutNewTaskBranch(input: { name: value.refName ?? input.branch.name, })); } + +// Remote names may contain slashes, so the ref's own remote name is the only +// reliable prefix; the first-slash split is for refs that do not carry one. +function localBranchName(branch: VcsRef): string { + if (!branch.isRemote) return branch.name; + const prefix = branch.remoteName ? `${branch.remoteName}/` : null; + return prefix && branch.name.startsWith(prefix) + ? branch.name.slice(prefix.length) + : deriveLocalBranchNameFromRemoteRef(branch.name); +} diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 114ea8a5a3672..14af818f4c216 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -244,7 +244,7 @@ export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | nu return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; } -export function deriveLocalBranchNameCandidatesFromRemoteRef( +function deriveLocalBranchNameCandidatesFromRemoteRef( branchName: string, remoteName?: string, ): ReadonlyArray { From d8ef409b16a6c1de52aa8df9ec23120ad886b694 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 05:11:07 +0000 Subject: [PATCH 05/15] test(server): cover local ref upstream metadata --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index ace13f40198ae..0649e0cf3cbf0 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -25,6 +25,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { GitCommandError, ReviewDiffPreviewInput, + VcsRef, type ReviewDiffFileContentsInput, type ReviewDiffPreviewResult, type WorktreeSubmodules, @@ -41,6 +42,7 @@ import * as GitVcsDriver from "./GitVcsDriver.ts"; import { gitConfigEnv } from "./VcsProcess.ts"; const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError)); +const decodeVcsRef = Schema.decodeUnknownSync(VcsRef); const layerServerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-vcs-driver-test-", @@ -3208,7 +3210,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("refName operations", () => { - it.effect("optionally includes remote refs that match local branches", () => + it.effect("lists upstream metadata and optionally includes matching remote refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const remote = yield* makeTmpDir("git-vcs-driver-remote-"); @@ -3216,6 +3218,10 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* git(cwd, ["remote", "add", "origin", remote]); yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["remote", "add", "team/upstream", remote]); + yield* git(cwd, ["push", "team/upstream", "HEAD:refs/heads/feature/a"]); + yield* git(cwd, ["branch", "--track", "feature/a", "team/upstream/feature/a"]); + yield* git(cwd, ["branch", "--no-track", "local-only"]); const driver = yield* GitVcsDriver.GitVcsDriver; const deduplicated = yield* driver.listRefs({ cwd }); @@ -3225,6 +3231,10 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ); const complete = yield* driver.listRefs({ cwd, includeMatchingRemoteRefs: true }); + const untracked = complete.refs.find((ref) => ref.name === "local-only"); + assert.ok(untracked); + assert.equal("upstreamRef" in untracked, false); + assert.deepEqual(decodeVcsRef(untracked), untracked); assert.equal( complete.refs.some((ref) => ref.name === initialBranch), true, @@ -3243,6 +3253,13 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(remoteOnly.refs.length, 1); assert.equal(remoteOnly.refs[0]?.name, `origin/${initialBranch}`); assert.equal(remoteOnly.refs[0]?.isRemote, true); + + const tracked = complete.refs.find((ref) => ref.name === "feature/a"); + assert.ok(tracked); + assert.deepEqual(decodeVcsRef(tracked), { + ...tracked, + upstreamRef: "refs/remotes/team/upstream/feature/a", + }); }), ); From ea344de44f1d8532172149d6c769e787e860a5e7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 05:16:56 +0000 Subject: [PATCH 06/15] feat(server): include upstream refs in Git branch snapshots --- apps/server/src/vcs/GitVcsDriverCore.ts | 5 +++-- packages/contracts/src/git.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index c3fb932c311ce..2ca5e856aff59 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3197,7 +3197,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* [ ...gitDirArgs, "for-each-ref", - "--format=%(refname)%09%(committerdate:unix)%09%(symref)", + "--format=%(refname)%09%(committerdate:unix)%09%(symref)%09%(upstream)", "refs/heads", "refs/remotes", ], @@ -3262,7 +3262,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* for (const line of refsResult.stdout.split("\n")) { if (line.length === 0) continue; - const [fullRefName, lastCommitRaw, symbolicTarget] = line.split("\t"); + const [fullRefName, lastCommitRaw, symbolicTarget, upstreamRef] = line.split("\t"); if (!fullRefName || symbolicTarget) continue; const parsedLastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); const lastCommit = Number.isFinite(parsedLastCommit) ? parsedLastCommit : 0; @@ -3276,6 +3276,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* isRemote: false, isDefault: name === defaultBranch, worktreePath: worktreeMap.get(name) ?? null, + ...(upstreamRef ? { upstreamRef } : {}), }, lastCommit, }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index e719c0005d5d9..08b5e67d83fbe 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -84,6 +84,7 @@ export const VcsRef = Schema.Struct({ name: TrimmedNonEmptyStringSchema, isRemote: Schema.optional(Schema.Boolean), remoteName: Schema.optional(TrimmedNonEmptyStringSchema), + upstreamRef: Schema.optional(TrimmedNonEmptyStringSchema), current: Schema.Boolean, isDefault: Schema.Boolean, worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), From 81ab27549f041ae8e28e94b7aa817555f34e03ab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 05:09:24 +0000 Subject: [PATCH 07/15] fix(mobile): reuse worktrees when preparing new threads Adapted from shivamhwp in PR #20825; original diagnosis and implementation retained with focused regression tests. --- .../threads/NewTaskContextPickerScreens.tsx | 2 + .../threads/NewTaskDraftRouteScreen.tsx | 20 ++- .../threads/checkout-new-task-branch.test.ts | 121 +++++++++++++++++- .../threads/checkout-new-task-branch.ts | 44 ++++++- packages/client-runtime/src/state/vcs.ts | 6 + 5 files changed, 184 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index ee055b6bec233..ea60225d254c1 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -268,6 +268,7 @@ export function NewTaskBranchPickerRouteScreen() { AuthSourceControlWriteScope, ); const navigation = useNavigation(); + const readRefs = useAtomCommand(vcsEnvironment.readRefs, { reportFailure: false }); const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const [switchingBranchName, setSwitchingBranchName] = useState(null); const selectingBranchNameRef = useRef(null); @@ -314,6 +315,7 @@ export function NewTaskBranchPickerRouteScreen() { branch, project, workspaceMode: flow.workspaceMode, + readRefs, switchRef, }); if (mountedRef.current) setSwitchingBranchName(null); diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index 52b975289f356..0dc59e1875d23 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -12,6 +12,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { useWorkspaceState } from "../../state/workspace"; import { vcsEnvironment } from "../../state/vcs"; import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; +import { resolveNewTaskBranchWorktreePath } from "./new-task-context-presentation"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { NewTaskDraftScreen } from "./NewTaskDraftScreen"; @@ -37,6 +38,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps count - 1); @@ -110,6 +111,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps - result?._tag === "Success" - ? { ...initialProjectRef, branch: result.value.name } + result?._tag === "Success" && workspaceRoot + ? { + ...initialProjectRef, + branch: result.value.name, + worktreePath: resolveNewTaskBranchWorktreePath({ + workspaceMode: "local", + projectCwd: workspaceRoot, + branchWorktreePath: result.value.worktreePath, + }), + } : initialProjectRef, - [initialProjectRef, result], + [initialProjectRef, result, workspaceRoot], ); const preparingBranch = checkoutPending || (needsPreparation && result?._tag !== "Success"); diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts index 6440e63912e7d..787d70683ab17 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts @@ -3,8 +3,9 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeUtil from "node:util"; -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, VcsListRefsInput } from "@t3tools/contracts"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import * as Schema from "effect/Schema"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { checkoutNewTaskBranch } from "./checkout-new-task-branch"; @@ -35,16 +36,61 @@ afterEach(async () => { await NodeFSP.rm(directory, { recursive: true, force: true }); }); -function selectBranch(switchRef: Parameters[0]["switchRef"]) { +type CheckoutInput = Parameters[0]; +const decodeListRefsInput = Schema.decodeUnknownSync(VcsListRefsInput); +const REF_PAGE_SIZE = 2; + +const readRefs: CheckoutInput["readRefs"] = (request) => + settlePromise(async () => { + expect(request.environmentId).toBe(environmentId); + const input = decodeListRefsInput(request.input); + const { stdout } = await exec("git", [ + "-C", + input.cwd, + "for-each-ref", + "--sort=refname", + "--format=%(refname:short)%09%(worktreepath)", + "refs/heads", + ]); + const refs = stdout + .trimEnd() + .split("\n") + .map((line) => { + const [name = "", path] = line.split("\t"); + return { + name, + current: path === input.cwd, + isDefault: name === "main", + isRemote: false, + worktreePath: path || null, + }; + }) + .filter((ref) => ref.name.includes(input.query ?? "")); + const cursor = input.cursor ?? 0; + return { + refs: refs.slice(cursor, cursor + REF_PAGE_SIZE), + isRepo: true, + hasPrimaryRemote: false, + nextCursor: cursor + REF_PAGE_SIZE < refs.length ? cursor + REF_PAGE_SIZE : null, + totalCount: refs.length, + }; + }); + +const refuseSwitch: CheckoutInput["switchRef"] = () => { + throw new Error("An occupied branch must not trigger checkout"); +}; + +function selectBranch(switchRef: CheckoutInput["switchRef"]) { return checkoutNewTaskBranch({ branch, project: { environmentId, workspaceRoot: cwd }, workspaceMode: "local", + readRefs, switchRef, }); } -const switchRef: Parameters[0]["switchRef"] = (request) => +const switchRef: CheckoutInput["switchRef"] = (request) => settlePromise(async () => { expect(request.environmentId).toBe(environmentId); await exec("git", ["-C", request.input.cwd, "checkout", request.input.refName]); @@ -100,6 +146,7 @@ describe("new-task branch checkout", () => { branch, project: null, workspaceMode: "local", + readRefs, switchRef: () => { throw new Error("An unavailable project must not run checkout"); }, @@ -117,6 +164,7 @@ describe("new-task branch checkout", () => { branch: { ...branch, worktreePath }, project: { environmentId, workspaceRoot: cwd }, workspaceMode: "local", + readRefs, switchRef: () => { throw new Error("Existing worktrees must not switch the project checkout"); }, @@ -129,4 +177,71 @@ describe("new-task branch checkout", () => { "feature\n", ); }); + + it("resolves historical branch metadata to its worktree beyond a long query's first page", async () => { + const name = `feature/${"a".repeat(120)}/${"b".repeat(150)}`; + await git("branch", "-m", branch.name, name); + await git("branch", `a-${name}`); + await git("branch", `b-${name}`); + await git("worktree", "add", NodePath.join(directory, "decoy"), `a-${name}`); + const worktreePath = NodePath.join(directory, "worktree"); + await git("worktree", "add", worktreePath, name); + + const result = await checkoutNewTaskBranch({ + branch: { ...branch, name }, + project: { environmentId, workspaceRoot: cwd }, + workspaceMode: "local", + readRefs, + switchRef: refuseSwitch, + }); + + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") throw new Error("Historical branch selection failed"); + expect(result.value).toMatchObject({ + name, + worktreePath: await NodeFSP.realpath(worktreePath), + }); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe("main\n"); + }); + + it("resolves a non-origin remote to the right worktree when the remote name contains slashes", async () => { + await git("branch", "upstream/feature/a"); + await git("worktree", "add", NodePath.join(directory, "decoy"), "upstream/feature/a"); + const worktreePath = NodePath.join(directory, "worktree"); + await git("worktree", "add", worktreePath, branch.name); + + const result = await checkoutNewTaskBranch({ + branch: { + ...branch, + name: "team/upstream/feature/a", + isRemote: true, + remoteName: "team/upstream", + }, + project: { environmentId, workspaceRoot: cwd }, + workspaceMode: "local", + readRefs, + switchRef: refuseSwitch, + }); + + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") throw new Error("Remote branch selection failed"); + expect(result.value).toMatchObject({ + name: branch.name, + isRemote: false, + worktreePath: await NodeFSP.realpath(worktreePath), + }); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + }); + + it("skips checkout when historical metadata names the already-current branch", async () => { + await git("checkout", branch.name); + + const result = await selectBranch(refuseSwitch); + + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") throw new Error("Current branch selection failed"); + expect(result.value).toMatchObject({ name: branch.name, current: true }); + expect((await git("branch", "--show-current")).stdout.trim()).toBe(branch.name); + }); }); diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.ts index 3ae5dde1670f7..8f946aa796085 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.ts @@ -4,16 +4,28 @@ import { type AtomCommandResult, mapAtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import type { VcsSwitchRefInput, VcsSwitchRefResult } from "@t3tools/contracts"; +import type { + VcsListRefsInput, + VcsListRefsResult, + VcsSwitchRefInput, + VcsSwitchRefResult, +} from "@t3tools/contracts"; +import { deriveLocalBranchNameFromRemoteRef } from "@t3tools/shared/git"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; +const VCS_REF_QUERY_MAX_LENGTH = 256; + export async function checkoutNewTaskBranch(input: { readonly branch: VcsRef; readonly project: Pick | null; readonly workspaceMode: "local" | "worktree"; + readonly readRefs: (request: { + readonly environmentId: EnvironmentProject["environmentId"]; + readonly input: VcsListRefsInput; + }) => Promise>; readonly switchRef: (request: { readonly environmentId: EnvironmentProject["environmentId"]; readonly input: VcsSwitchRefInput; @@ -34,6 +46,27 @@ export async function checkoutNewTaskBranch(input: { return AsyncResult.success(input.branch); } + // Remote refs omit local worktrees, and historical thread metadata can be stale. + const localName = localBranchName(input.branch); + let cursor: number | undefined; + for (;;) { + const refs = await input.readRefs({ + environmentId: input.project.environmentId, + input: { + cwd: input.project.workspaceRoot, + // Bound the substring query; compare the full name across every page. + query: localName.slice(0, VCS_REF_QUERY_MAX_LENGTH), + refKind: "local", + ...(cursor === undefined ? { refresh: true } : { cursor }), + }, + }); + if (refs._tag === "Failure") return AsyncResult.failure(refs.cause); + const local = refs.value.refs.find((ref) => ref.name === localName); + if (local && (local.current || local.worktreePath)) return AsyncResult.success(local); + if (local || refs.value.nextCursor === null) break; + cursor = refs.value.nextCursor; + } + const result = await input.switchRef({ environmentId: input.project.environmentId, input: { cwd: input.project.workspaceRoot, refName: input.branch.name }, @@ -45,3 +78,12 @@ export async function checkoutNewTaskBranch(input: { name: value.refName ?? input.branch.name, })); } + +// A remote name can itself contain slashes; prefer the authoritative prefix. +function localBranchName(branch: VcsRef): string { + if (!branch.isRemote) return branch.name; + const prefix = branch.remoteName ? `${branch.remoteName}/` : null; + return prefix && branch.name.startsWith(prefix) + ? branch.name.slice(prefix.length) + : deriveLocalBranchNameFromRemoteRef(branch.name); +} diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 3a638e2b512d1..96b00334d0fa3 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -277,6 +277,12 @@ export function createVcsEnvironmentAtoms( return { listRefs, + // An uncached read for decisions that must match the repository now, not + // the persisted list a picker renders from. + readRefs: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:read-refs", + tag: WS_METHODS.vcsListRefs, + }), status: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:status", idleTtlMs: VCS_STATUS_IDLE_TTL_MS, From fc6220af46255302fa0f53b4d10518a6030d02dc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 05:12:38 +0000 Subject: [PATCH 08/15] test(mobile): cover remote worktree identity and nested projects --- .../threads/checkout-new-task-branch.test.ts | 96 ++++++++++++++++--- 1 file changed, 81 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts index 787d70683ab17..8d860a7c7b15c 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts @@ -18,7 +18,9 @@ const branch = { name: "feature/a", current: false, isDefault: false, worktreePa const environmentId = EnvironmentId.make("branch-test-environment"); beforeEach(async () => { - directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-branch-selection-")); + directory = await NodeFSP.realpath( + await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-branch-selection-")), + ); cwd = NodePath.join(directory, "project"); await exec("git", ["init", "-b", "main", cwd]); await git("config", "user.name", "Branch test"); @@ -44,25 +46,33 @@ const readRefs: CheckoutInput["readRefs"] = (request) => settlePromise(async () => { expect(request.environmentId).toBe(environmentId); const input = decodeListRefsInput(request.input); + const { stdout: head } = await exec("git", [ + "-C", + input.cwd, + "rev-parse", + "--symbolic-full-name", + "HEAD", + ]); const { stdout } = await exec("git", [ "-C", input.cwd, "for-each-ref", "--sort=refname", - "--format=%(refname:short)%09%(worktreepath)", + "--format=%(refname:short)%09%(worktreepath)%09%(upstream)", "refs/heads", ]); const refs = stdout .trimEnd() .split("\n") .map((line) => { - const [name = "", path] = line.split("\t"); + const [name = "", path, upstreamRef] = line.split("\t"); return { name, - current: path === input.cwd, + current: head.trim() === `refs/heads/${name}`, isDefault: name === "main", isRemote: false, worktreePath: path || null, + ...(upstreamRef ? { upstreamRef } : {}), }; }) .filter((ref) => ref.name.includes(input.query ?? "")); @@ -95,9 +105,28 @@ const switchRef: CheckoutInput["switchRef"] = (request) => expect(request.environmentId).toBe(environmentId); await exec("git", ["-C", request.input.cwd, "checkout", request.input.refName]); const { stdout } = await exec("git", ["-C", request.input.cwd, "branch", "--show-current"]); - return { refName: stdout.trim() }; + return { refName: stdout.trim() || null }; }); +async function addRemote(remoteName: string, sourceRef = branch.name) { + const remotePath = NodePath.join(directory, `${remoteName.replaceAll("/", "-")}.git`); + await exec("git", ["init", "--bare", remotePath]); + await git("remote", "add", remoteName, remotePath); + await git("push", remoteName, `${sourceRef}:refs/heads/${branch.name}`); + const { stdout } = await git( + "rev-parse", + "--verify", + "--symbolic-full-name", + `refs/remotes/${remoteName}/${branch.name}`, + ); + return { + ...branch, + name: stdout.trim().replace("refs/remotes/", ""), + isRemote: true, + remoteName, + }; +} + describe("new-task branch checkout", () => { it("switches main to the older thread's feature branch before returning a selection", async () => { const result = await selectBranch(switchRef); @@ -199,25 +228,22 @@ describe("new-task branch checkout", () => { if (result._tag !== "Success") throw new Error("Historical branch selection failed"); expect(result.value).toMatchObject({ name, - worktreePath: await NodeFSP.realpath(worktreePath), + worktreePath, }); expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe("main\n"); }); it("resolves a non-origin remote to the right worktree when the remote name contains slashes", async () => { + const remoteBranch = await addRemote("team/upstream"); + await git("branch", "--set-upstream-to", remoteBranch.name, branch.name); await git("branch", "upstream/feature/a"); await git("worktree", "add", NodePath.join(directory, "decoy"), "upstream/feature/a"); const worktreePath = NodePath.join(directory, "worktree"); await git("worktree", "add", worktreePath, branch.name); const result = await checkoutNewTaskBranch({ - branch: { - ...branch, - name: "team/upstream/feature/a", - isRemote: true, - remoteName: "team/upstream", - }, + branch: remoteBranch, project: { environmentId, workspaceRoot: cwd }, workspaceMode: "local", readRefs, @@ -229,19 +255,59 @@ describe("new-task branch checkout", () => { expect(result.value).toMatchObject({ name: branch.name, isRemote: false, - worktreePath: await NodeFSP.realpath(worktreePath), + worktreePath, }); expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); }); - it("skips checkout when historical metadata names the already-current branch", async () => { + it("switches to the selected remote instead of reusing a worktree tracking another remote", async () => { + const originBranch = await addRemote("origin"); + await git("branch", "--set-upstream-to", originBranch.name, branch.name); + const worktreePath = NodePath.join(directory, "worktree"); + await git("worktree", "add", worktreePath, branch.name); + await git("checkout", "-b", "upstream-source"); + await NodeFSP.writeFile(NodePath.join(cwd, "file.txt"), "upstream feature\n"); + await git("commit", "-am", "divergent upstream feature"); + const remoteBranch = await addRemote("team/upstream", "HEAD"); + const selectedCommit = (await git("rev-parse", remoteBranch.name)).stdout.trim(); + expect((await git("rev-parse", originBranch.name)).stdout.trim()).not.toBe(selectedCommit); + await git("checkout", "main"); + + const result = await checkoutNewTaskBranch({ + branch: remoteBranch, + project: { environmentId, workspaceRoot: cwd }, + workspaceMode: "local", + readRefs, + switchRef, + }); + + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") throw new Error("Remote checkout failed"); + expect(result.value.worktreePath).toBeNull(); + expect(result.value.name).toBe(remoteBranch.name); + expect((await git("rev-parse", "HEAD")).stdout.trim()).toBe(selectedCommit); + expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe( + "upstream feature\n", + ); + expect(await NodeFSP.readFile(NodePath.join(worktreePath, "file.txt"), "utf8")).toBe( + "feature\n", + ); + }); + + it("keeps a nested project's cwd when historical metadata names the already-current branch", async () => { await git("checkout", branch.name); + cwd = NodePath.join(cwd, "packages", "app"); + await NodeFSP.mkdir(cwd, { recursive: true }); const result = await selectBranch(refuseSwitch); expect(result._tag).toBe("Success"); if (result._tag !== "Success") throw new Error("Current branch selection failed"); - expect(result.value).toMatchObject({ name: branch.name, current: true }); + expect(result.value).toMatchObject({ + name: branch.name, + current: true, + worktreePath: null, + }); expect((await git("branch", "--show-current")).stdout.trim()).toBe(branch.name); }); }); From 94e3f1a93cc9147a16e5f462487b22eddabcc42e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 05:17:57 +0000 Subject: [PATCH 09/15] fix(mobile): verify upstream before reusing branch worktrees --- .../src/features/threads/checkout-new-task-branch.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.ts index 8f946aa796085..33412f8062ac5 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.ts @@ -43,7 +43,9 @@ export async function checkoutNewTaskBranch(input: { workspaceMode: input.workspaceMode, }) ) { - return AsyncResult.success(input.branch); + return AsyncResult.success( + input.branch.current ? { ...input.branch, worktreePath: null } : input.branch, + ); } // Remote refs omit local worktrees, and historical thread metadata can be stale. @@ -62,7 +64,13 @@ export async function checkoutNewTaskBranch(input: { }); if (refs._tag === "Failure") return AsyncResult.failure(refs.cause); const local = refs.value.refs.find((ref) => ref.name === localName); - if (local && (local.current || local.worktreePath)) return AsyncResult.success(local); + if ( + local && + (!input.branch.isRemote || local.upstreamRef === `refs/remotes/${input.branch.name}`) && + (local.current || local.worktreePath) + ) { + return AsyncResult.success(local.current ? { ...local, worktreePath: null } : local); + } if (local || refs.value.nextCursor === null) break; cursor = refs.value.nextCursor; } From 18c8ee44ea078415fdd21f14a6539de83270bcac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 22:44:46 +0000 Subject: [PATCH 10/15] test(mobile): reject unsafe worktree reuse for nested projects --- .../threads/checkout-new-task-branch.test.ts | 62 +++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.test.ts | 19 +++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts index 8d860a7c7b15c..f913caebc3b9a 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts @@ -46,6 +46,12 @@ const readRefs: CheckoutInput["readRefs"] = (request) => settlePromise(async () => { expect(request.environmentId).toBe(environmentId); const input = decodeListRefsInput(request.input); + const { stdout: rootPath } = await exec("git", [ + "-C", + input.cwd, + "rev-parse", + "--show-toplevel", + ]); const { stdout: head } = await exec("git", [ "-C", input.cwd, @@ -78,6 +84,7 @@ const readRefs: CheckoutInput["readRefs"] = (request) => .filter((ref) => ref.name.includes(input.query ?? "")); const cursor = input.cursor ?? 0; return { + rootPath: rootPath.trim(), refs: refs.slice(cursor, cursor + REF_PAGE_SIZE), isRepo: true, hasPrimaryRemote: false, @@ -294,6 +301,61 @@ describe("new-task branch checkout", () => { ); }); + it.each([ + { source: "historical", nested: true }, + { source: "picker", nested: true }, + { source: "historical", nested: false }, + { source: "picker", nested: false }, + ])( + "rejects $source worktree reuse when project root is unsafe (nested: $nested)", + async ({ source, nested }) => { + const projectRoot = cwd; + const worktreePath = NodePath.join(directory, "worktree"); + await git("worktree", "add", worktreePath, branch.name); + if (nested) { + cwd = NodePath.join(projectRoot, "packages", "app"); + await NodeFSP.mkdir(cwd, { recursive: true }); + } + let checkoutAttempted = false; + + const result = await checkoutNewTaskBranch({ + branch: { ...branch, worktreePath: source === "picker" ? worktreePath : null }, + project: { environmentId, workspaceRoot: cwd }, + workspaceMode: "local", + readRefs: async (request) => { + const result = await readRefs(request); + if (!nested && result._tag === "Success") { + // Older servers cannot prove whether this project is at the repository root. + const { rootPath: _rootPath, ...value } = result.value as typeof result.value & { + rootPath?: string; + }; + return { ...result, value }; + } + return result; + }, + switchRef: (request) => { + checkoutAttempted = true; + return refuseSwitch(request); + }, + }); + + expect(checkoutAttempted).toBe(false); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + expect(await NodeFSP.readFile(NodePath.join(projectRoot, "file.txt"), "utf8")).toBe("main\n"); + expect( + (await exec("git", ["-C", worktreePath, "branch", "--show-current"])).stdout.trim(), + ).toBe(branch.name); + expect(await NodeFSP.readFile(NodePath.join(worktreePath, "file.txt"), "utf8")).toBe( + "feature\n", + ); + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") throw new Error("Expected unsafe worktree reuse to fail"); + if (nested) { + expect(String(squashAtomCommandFailure(result))).toMatch(/subdirectory/i); + } + }, + ); + it("keeps a nested project's cwd when historical metadata names the already-current branch", async () => { await git("checkout", branch.name); cwd = NodePath.join(cwd, "packages", "app"); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 0649e0cf3cbf0..acc5ebf0985c1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -26,6 +26,7 @@ import { GitCommandError, ReviewDiffPreviewInput, VcsRef, + VcsListRefsResult, type ReviewDiffFileContentsInput, type ReviewDiffPreviewResult, type WorktreeSubmodules, @@ -43,6 +44,7 @@ import { gitConfigEnv } from "./VcsProcess.ts"; const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError)); const decodeVcsRef = Schema.decodeUnknownSync(VcsRef); +const decodeVcsListRefsResult = Schema.decodeUnknownSync(VcsListRefsResult); const layerServerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-vcs-driver-test-", @@ -3210,7 +3212,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("refName operations", () => { - it.effect("lists upstream metadata and optionally includes matching remote refs", () => + it.effect("lists root and upstream metadata and optionally includes matching remote refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const remote = yield* makeTmpDir("git-vcs-driver-remote-"); @@ -3230,7 +3232,15 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { false, ); - const complete = yield* driver.listRefs({ cwd, includeMatchingRemoteRefs: true }); + const worktreePath = yield* makeTmpDir("git-vcs-driver-worktree-"); + yield* git(cwd, ["worktree", "add", worktreePath, "feature/a"]); + yield* writeTextFile(worktreePath, "packages/app/file.txt", "nested project\n"); + const pathService = yield* Path.Path; + const nestedCwd = pathService.join(worktreePath, "packages", "app"); + const complete = yield* driver.listRefs({ + cwd: nestedCwd, + includeMatchingRemoteRefs: true, + }); const untracked = complete.refs.find((ref) => ref.name === "local-only"); assert.ok(untracked); assert.equal("upstreamRef" in untracked, false); @@ -3260,6 +3270,11 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ...tracked, upstreamRef: "refs/remotes/team/upstream/feature/a", }); + const expected = { + ...complete, + rootPath: yield* git(nestedCwd, ["rev-parse", "--show-toplevel"]), + }; + assert.deepEqual(decodeVcsListRefsResult(complete), expected); }), ); From 888420512c1501c49483a09816ce402b85cf3b5f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 22:47:45 +0000 Subject: [PATCH 11/15] fix(mobile): reject unsafe worktree reuse for subdirectory projects --- .../threads/checkout-new-task-branch.test.ts | 5 +-- .../threads/checkout-new-task-branch.ts | 35 +++++++++++++------ apps/server/src/vcs/GitVcsDriverCore.ts | 1 + packages/contracts/src/git.ts | 2 ++ 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts index f913caebc3b9a..c30299f2b0c8a 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts @@ -291,7 +291,6 @@ describe("new-task branch checkout", () => { expect(result._tag).toBe("Success"); if (result._tag !== "Success") throw new Error("Remote checkout failed"); expect(result.value.worktreePath).toBeNull(); - expect(result.value.name).toBe(remoteBranch.name); expect((await git("rev-parse", "HEAD")).stdout.trim()).toBe(selectedCommit); expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe( "upstream feature\n", @@ -326,9 +325,7 @@ describe("new-task branch checkout", () => { const result = await readRefs(request); if (!nested && result._tag === "Success") { // Older servers cannot prove whether this project is at the repository root. - const { rootPath: _rootPath, ...value } = result.value as typeof result.value & { - rootPath?: string; - }; + const { rootPath: _rootPath, ...value } = result.value; return { ...result, value }; } return result; diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.ts index 33412f8062ac5..86f2a21016c23 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.ts @@ -11,11 +11,10 @@ import type { VcsSwitchRefResult, } from "@t3tools/contracts"; import { deriveLocalBranchNameFromRemoteRef } from "@t3tools/shared/git"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { shouldCheckoutNewTaskBranch } from "./new-task-context-presentation"; - const VCS_REF_QUERY_MAX_LENGTH = 256; export async function checkoutNewTaskBranch(input: { @@ -36,13 +35,7 @@ export async function checkoutNewTaskBranch(input: { Cause.fail(new Error("The selected project is unavailable. Reconnect and try again.")), ); } - if ( - !shouldCheckoutNewTaskBranch({ - branchIsCurrent: input.branch.current, - branchWorktreePath: input.branch.worktreePath, - workspaceMode: input.workspaceMode, - }) - ) { + if (input.workspaceMode === "worktree" || input.branch.current) { return AsyncResult.success( input.branch.current ? { ...input.branch, worktreePath: null } : input.branch, ); @@ -69,7 +62,29 @@ export async function checkoutNewTaskBranch(input: { (!input.branch.isRemote || local.upstreamRef === `refs/remotes/${input.branch.name}`) && (local.current || local.worktreePath) ) { - return AsyncResult.success(local.current ? { ...local, worktreePath: null } : local); + if (local.current) return AsyncResult.success({ ...local, worktreePath: null }); + if (!refs.value.rootPath) { + return AsyncResult.failure( + Cause.fail( + new Error( + "The server did not report the repository root, so this worktree cannot be reused safely. Update the server or choose another branch.", + ), + ), + ); + } + if ( + normalizeProjectPathForComparison(refs.value.rootPath) !== + normalizeProjectPathForComparison(input.project.workspaceRoot) + ) { + return AsyncResult.failure( + Cause.fail( + new Error( + "This project is a repository subdirectory. Choose another branch or add the repository root as a project to reuse this worktree.", + ), + ), + ); + } + return AsyncResult.success(local); } if (local || refs.value.nextCursor === null) break; cursor = refs.value.nextCursor; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 2ca5e856aff59..a4cc3df462715 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3470,6 +3470,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); return { + ...(repositoryPaths.worktreeRoot ? { rootPath: repositoryPaths.worktreeRoot } : {}), refs: [...refs.refs], isRepo: true, hasPrimaryRemote: snapshot.hasPrimaryRemote, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 08b5e67d83fbe..15ddc1de01f94 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -84,6 +84,7 @@ export const VcsRef = Schema.Struct({ name: TrimmedNonEmptyStringSchema, isRemote: Schema.optional(Schema.Boolean), remoteName: Schema.optional(TrimmedNonEmptyStringSchema), + // Full Git ref, for example refs/remotes/origin/feature. upstreamRef: Schema.optional(TrimmedNonEmptyStringSchema), current: Schema.Boolean, isDefault: Schema.Boolean, @@ -277,6 +278,7 @@ export const VcsStatusStreamEvent = Schema.Union([ export type VcsStatusStreamEvent = typeof VcsStatusStreamEvent.Type; export const VcsListRefsResult = Schema.Struct({ + rootPath: Schema.optional(GitFilePath), refs: Schema.Array(VcsRef), isRepo: Schema.Boolean, hasPrimaryRemote: Schema.Boolean, From a9a54ceeb2f993fb717c9089d09386cc280b9cc8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 22:53:24 +0000 Subject: [PATCH 12/15] test(mobile): cover worktree reuse from symlinked project roots --- .../threads/checkout-new-task-branch.test.ts | 36 +++++++++++++++++-- apps/server/src/vcs/GitVcsDriverCore.test.ts | 18 +++++++--- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts index c30299f2b0c8a..9a19408ad1b10 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts @@ -42,7 +42,7 @@ type CheckoutInput = Parameters[0]; const decodeListRefsInput = Schema.decodeUnknownSync(VcsListRefsInput); const REF_PAGE_SIZE = 2; -const readRefs: CheckoutInput["readRefs"] = (request) => +const readRefs = (request: Parameters[0]) => settlePromise(async () => { expect(request.environmentId).toBe(environmentId); const input = decodeListRefsInput(request.input); @@ -84,7 +84,7 @@ const readRefs: CheckoutInput["readRefs"] = (request) => .filter((ref) => ref.name.includes(input.query ?? "")); const cursor = input.cursor ?? 0; return { - rootPath: rootPath.trim(), + isAtRepositoryRoot: (await NodeFSP.realpath(input.cwd)) === rootPath.trim(), refs: refs.slice(cursor, cursor + REF_PAGE_SIZE), isRepo: true, hasPrimaryRemote: false, @@ -214,6 +214,36 @@ describe("new-task branch checkout", () => { ); }); + it("reuses an occupied picker worktree when the project root is a symlink", async () => { + const worktreePath = NodePath.join(directory, "worktree"); + await git("worktree", "add", worktreePath, branch.name); + const workspaceRoot = NodePath.join(directory, "project-alias"); + await NodeFSP.symlink(cwd, workspaceRoot, "junction"); + + const result = await checkoutNewTaskBranch({ + branch: { ...branch, worktreePath }, + project: { environmentId, workspaceRoot }, + workspaceMode: "local", + readRefs, + switchRef: refuseSwitch, + }); + + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + expect(await NodeFSP.readFile(NodePath.join(workspaceRoot, "file.txt"), "utf8")).toBe("main\n"); + expect( + (await exec("git", ["-C", worktreePath, "branch", "--show-current"])).stdout.trim(), + ).toBe(branch.name); + expect(await NodeFSP.readFile(NodePath.join(worktreePath, "file.txt"), "utf8")).toBe( + "feature\n", + ); + expect( + result._tag, + result._tag === "Failure" ? String(squashAtomCommandFailure(result)) : undefined, + ).toBe("Success"); + if (result._tag !== "Success") throw new Error("Symlink-root worktree selection failed"); + expect(result.value.worktreePath).toBe(worktreePath); + }); + it("resolves historical branch metadata to its worktree beyond a long query's first page", async () => { const name = `feature/${"a".repeat(120)}/${"b".repeat(150)}`; await git("branch", "-m", branch.name, name); @@ -325,7 +355,7 @@ describe("new-task branch checkout", () => { const result = await readRefs(request); if (!nested && result._tag === "Success") { // Older servers cannot prove whether this project is at the repository root. - const { rootPath: _rootPath, ...value } = result.value; + const { isAtRepositoryRoot: _isAtRepositoryRoot, ...value } = result.value; return { ...result, value }; } return result; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index acc5ebf0985c1..0520a0214a0fe 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3270,11 +3270,19 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ...tracked, upstreamRef: "refs/remotes/team/upstream/feature/a", }); - const expected = { - ...complete, - rootPath: yield* git(nestedCwd, ["rev-parse", "--show-toplevel"]), - }; - assert.deepEqual(decodeVcsListRefsResult(complete), expected); + const fileSystem = yield* FileSystem.FileSystem; + const aliasCwd = pathService.join(cwd, "worktree-alias"); + yield* fileSystem.symlink(worktreePath, aliasCwd); + const aliased = yield* driver.listRefs({ cwd: aliasCwd }); + const expected = [ + { ...complete, isAtRepositoryRoot: false }, + { ...aliased, isAtRepositoryRoot: true }, + ]; + assert.deepEqual([complete, aliased], expected); + assert.deepEqual( + [decodeVcsListRefsResult(complete), decodeVcsListRefsResult(aliased)], + expected, + ); }), ); From ab91420e74c6dbcdebf13fe16104a47e9e859b94 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 22:55:34 +0000 Subject: [PATCH 13/15] fix(mobile): allow worktree reuse from symlinked repository roots --- .../threads/checkout-new-task-branch.test.ts | 2 +- .../features/threads/checkout-new-task-branch.ts | 10 +++------- apps/server/src/vcs/GitVcsDriverCore.ts | 14 +++++++++++++- packages/contracts/src/git.ts | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts index 9a19408ad1b10..37deb4bee8ba5 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts @@ -42,7 +42,7 @@ type CheckoutInput = Parameters[0]; const decodeListRefsInput = Schema.decodeUnknownSync(VcsListRefsInput); const REF_PAGE_SIZE = 2; -const readRefs = (request: Parameters[0]) => +const readRefs: CheckoutInput["readRefs"] = (request) => settlePromise(async () => { expect(request.environmentId).toBe(environmentId); const input = decodeListRefsInput(request.input); diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.ts index 86f2a21016c23..0ade3dc6d3ed9 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.ts @@ -11,7 +11,6 @@ import type { VcsSwitchRefResult, } from "@t3tools/contracts"; import { deriveLocalBranchNameFromRemoteRef } from "@t3tools/shared/git"; -import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; @@ -63,19 +62,16 @@ export async function checkoutNewTaskBranch(input: { (local.current || local.worktreePath) ) { if (local.current) return AsyncResult.success({ ...local, worktreePath: null }); - if (!refs.value.rootPath) { + if (refs.value.isAtRepositoryRoot === undefined) { return AsyncResult.failure( Cause.fail( new Error( - "The server did not report the repository root, so this worktree cannot be reused safely. Update the server or choose another branch.", + "The server did not report whether this project is at the repository root, so this worktree cannot be reused safely. Update the server or choose another branch.", ), ), ); } - if ( - normalizeProjectPathForComparison(refs.value.rootPath) !== - normalizeProjectPathForComparison(input.project.workspaceRoot) - ) { + if (!refs.value.isAtRepositoryRoot) { return AsyncResult.failure( Cause.fail( new Error( diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a4cc3df462715..0fa450ce2004b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -156,6 +156,7 @@ class GitRefsRefreshCacheKey extends Data.Class<{ interface GitRepositoryPaths { readonly gitCommonDir: string; readonly worktreeRoot: string | null; + readonly isAtRepositoryRoot: boolean | undefined; readonly currentBranch: string | null; } @@ -1345,6 +1346,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : path.resolve(cwd, worktreeRootOutput), ) : null; + // Git reports physical roots, so resolve project aliases before comparing. + const isAtRepositoryRoot = + worktreeRoot === null + ? undefined + : yield* fileSystem.realPath(cwd).pipe( + Effect.map((physicalCwd) => path.relative(worktreeRoot, physicalCwd) === ""), + Effect.orElseSucceed(() => undefined), + ); const currentBranchOutput = currentBranchResult.stdout.trim(); const currentBranch = currentBranchResult.exitCode === 0 && currentBranchOutput.length > 0 @@ -1354,6 +1363,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { gitCommonDir, worktreeRoot, + isAtRepositoryRoot, currentBranch, } satisfies GitRepositoryPaths; }); @@ -3470,7 +3480,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); return { - ...(repositoryPaths.worktreeRoot ? { rootPath: repositoryPaths.worktreeRoot } : {}), + ...(repositoryPaths.isAtRepositoryRoot === undefined + ? {} + : { isAtRepositoryRoot: repositoryPaths.isAtRepositoryRoot }), refs: [...refs.refs], isRepo: true, hasPrimaryRemote: snapshot.hasPrimaryRemote, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 15ddc1de01f94..0976dcb07d5af 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -278,7 +278,7 @@ export const VcsStatusStreamEvent = Schema.Union([ export type VcsStatusStreamEvent = typeof VcsStatusStreamEvent.Type; export const VcsListRefsResult = Schema.Struct({ - rootPath: Schema.optional(GitFilePath), + isAtRepositoryRoot: Schema.optional(Schema.Boolean), refs: Schema.Array(VcsRef), isRepo: Schema.Boolean, hasPrimaryRemote: Schema.Boolean, From db4d4060677a6706962cbb05e4b81d62e10c494d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 22:56:28 +0000 Subject: [PATCH 14/15] test(server): use a junction for the repository alias fixture --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 0520a0214a0fe..eb57413ae7c6a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3270,9 +3270,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ...tracked, upstreamRef: "refs/remotes/team/upstream/feature/a", }); - const fileSystem = yield* FileSystem.FileSystem; const aliasCwd = pathService.join(cwd, "worktree-alias"); - yield* fileSystem.symlink(worktreePath, aliasCwd); + NodeFS.symlinkSync(worktreePath, aliasCwd, "junction"); const aliased = yield* driver.listRefs({ cwd: aliasCwd }); const expected = [ { ...complete, isAtRepositoryRoot: false }, From 1b3b634297c4f12ead8d0325e6ba1e14c5e9b852 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 23:10:05 +0000 Subject: [PATCH 15/15] fix(mobile): clear stale worktree paths after branch checkout --- .../threads/checkout-new-task-branch.test.ts | 14 ++++++++++++-- .../features/threads/checkout-new-task-branch.ts | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts index 37deb4bee8ba5..b744fb1be7dd1 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.test.ts @@ -135,12 +135,22 @@ async function addRemote(remoteName: string, sourceRef = branch.name) { } describe("new-task branch checkout", () => { - it("switches main to the older thread's feature branch before returning a selection", async () => { - const result = await selectBranch(switchRef); + it("switches to a free branch and clears its historical worktree path", async () => { + const previousWorktree = NodePath.join(directory, "previous-worktree"); + await git("worktree", "add", previousWorktree, branch.name); + await exec("git", ["-C", previousWorktree, "checkout", "-b", "other-branch"]); + const result = await checkoutNewTaskBranch({ + branch: { ...branch, worktreePath: previousWorktree }, + project: { environmentId, workspaceRoot: cwd }, + workspaceMode: "local", + readRefs, + switchRef, + }); expect(result._tag).toBe("Success"); if (result._tag !== "Success") throw new Error("Checkout failed"); expect(result.value.name).toBe("feature/a"); expect(result.value.current).toBe(true); + expect(result.value.worktreePath).toBeNull(); expect((await git("branch", "--show-current")).stdout.trim()).toBe("feature/a"); expect(await NodeFSP.readFile(NodePath.join(cwd, "file.txt"), "utf8")).toBe("feature\n"); }); diff --git a/apps/mobile/src/features/threads/checkout-new-task-branch.ts b/apps/mobile/src/features/threads/checkout-new-task-branch.ts index 0ade3dc6d3ed9..fb7920dcdd5f6 100644 --- a/apps/mobile/src/features/threads/checkout-new-task-branch.ts +++ b/apps/mobile/src/features/threads/checkout-new-task-branch.ts @@ -95,6 +95,7 @@ export async function checkoutNewTaskBranch(input: { current: true, isRemote: false, name: value.refName ?? input.branch.name, + worktreePath: null, })); }