diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index bc4f1ebdafd89..c17434c0ca78a 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..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 @@ -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"; @@ -17,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"); @@ -35,30 +38,119 @@ 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: rootPath } = await exec("git", [ + "-C", + input.cwd, + "rev-parse", + "--show-toplevel", + ]); + 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)%09%(upstream)", + "refs/heads", + ]); + const refs = stdout + .trimEnd() + .split("\n") + .map((line) => { + const [name = "", path, upstreamRef] = line.split("\t"); + return { + name, + current: head.trim() === `refs/heads/${name}`, + isDefault: name === "main", + isRemote: false, + worktreePath: path || null, + ...(upstreamRef ? { upstreamRef } : {}), + }; + }) + .filter((ref) => ref.name.includes(input.query ?? "")); + const cursor = input.cursor ?? 0; + return { + isAtRepositoryRoot: (await NodeFSP.realpath(input.cwd)) === rootPath.trim(), + 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]); 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); + 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"); }); @@ -100,6 +192,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 +210,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 +223,190 @@ describe("new-task branch checkout", () => { "feature\n", ); }); + + 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); + 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, + }); + 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: remoteBranch, + 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, + }); + expect((await git("branch", "--show-current")).stdout.trim()).toBe("main"); + }); + + 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((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.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 { isAtRepositoryRoot: _isAtRepositoryRoot, ...value } = result.value; + 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"); + 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, + worktreePath: null, + }); + 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..fb7920dcdd5f6 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,26 @@ 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; @@ -24,14 +34,56 @@ 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, - }) - ) { - return AsyncResult.success(input.branch); + if (input.workspaceMode === "worktree" || input.branch.current) { + return AsyncResult.success( + input.branch.current ? { ...input.branch, worktreePath: null } : 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 && + (!input.branch.isRemote || local.upstreamRef === `refs/remotes/${input.branch.name}`) && + (local.current || local.worktreePath) + ) { + if (local.current) return AsyncResult.success({ ...local, worktreePath: null }); + if (refs.value.isAtRepositoryRoot === undefined) { + return AsyncResult.failure( + Cause.fail( + new Error( + "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 (!refs.value.isAtRepositoryRoot) { + 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; } const result = await input.switchRef({ @@ -43,5 +95,15 @@ export async function checkoutNewTaskBranch(input: { current: true, isRemote: false, name: value.refName ?? input.branch.name, + worktreePath: null, })); } + +// 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/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index ddc5767d6aab7..eef44e9c02c80 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -26,6 +26,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { GitCommandError, ReviewDiffPreviewInput, + VcsRef, + VcsListRefsResult, type ReviewDiffFileContentsInput, type ReviewDiffPreviewResult, type WorktreeSubmodules, @@ -44,6 +46,8 @@ import { gitConfigEnv } from "./VcsProcess.ts"; import { makeProcessProbe, processProbeSource } from "../testUtils/processProbe.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-", @@ -3827,7 +3831,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("refName operations", () => { - it.effect("optionally includes remote refs that match local branches", () => + 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-"); @@ -3835,6 +3839,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 }); @@ -3843,7 +3851,19 @@ 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); + assert.deepEqual(decodeVcsRef(untracked), untracked); assert.equal( complete.refs.some((ref) => ref.name === initialBranch), true, @@ -3862,6 +3882,25 @@ 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", + }); + const aliasCwd = pathService.join(cwd, "worktree-alias"); + NodeFS.symlinkSync(worktreePath, aliasCwd, "junction"); + 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, + ); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 0727e2ca9c50c..64ce4024edd69 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -161,6 +161,7 @@ class GitRefsRefreshCacheKey extends Data.Class<{ interface GitRepositoryPaths { readonly gitCommonDir: string; readonly worktreeRoot: string | null; + readonly isAtRepositoryRoot: boolean | undefined; readonly currentBranch: string | null; } @@ -1408,6 +1409,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 @@ -1417,6 +1426,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { gitCommonDir, worktreeRoot, + isAtRepositoryRoot, currentBranch, } satisfies GitRepositoryPaths; }); @@ -3381,7 +3391,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", ], @@ -3450,7 +3460,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; @@ -3464,6 +3474,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* isRemote: false, isDefault: name === defaultBranch, worktreePath: worktreeMap.get(name) ?? null, + ...(upstreamRef ? { upstreamRef } : {}), }, lastCommit, }); @@ -3660,6 +3671,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); return { + ...(repositoryPaths.isAtRepositoryRoot === undefined + ? {} + : { isAtRepositoryRoot: repositoryPaths.isAtRepositoryRoot }), refs: [...refs.refs], isRepo: true, hasPrimaryRemote: snapshot.hasPrimaryRemote, 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/contracts/src/git.ts b/packages/contracts/src/git.ts index e719c0005d5d9..0976dcb07d5af 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -84,6 +84,8 @@ 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, worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), @@ -276,6 +278,7 @@ export const VcsStatusStreamEvent = Schema.Union([ export type VcsStatusStreamEvent = typeof VcsStatusStreamEvent.Type; export const VcsListRefsResult = Schema.Struct({ + isAtRepositoryRoot: Schema.optional(Schema.Boolean), refs: Schema.Array(VcsRef), isRepo: Schema.Boolean, hasPrimaryRemote: Schema.Boolean,