From a13c58904fe9e9dfb139aaf66dfbf077a8eeb951 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 00:45:41 -0500 Subject: [PATCH 1/2] fix(mobile): mark the thread worktree branch as current Query branch refs for the selected thread checkout and refresh the target when its cwd changes. Add a real-Git regression for switching between the root checkout and a linked worktree. Verification pending: dependency installation did not start before the guarded admission deadline. Co-Authored-By: GPT-6.1 Sol --- .../use-selected-thread-git-state.test.tsx | 95 +++++++++++++++++++ .../state/use-selected-thread-git-state.ts | 6 +- apps/mobile/vite.config.ts | 1 + 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/state/use-selected-thread-git-state.test.tsx diff --git a/apps/mobile/src/state/use-selected-thread-git-state.test.tsx b/apps/mobile/src/state/use-selected-thread-git-state.test.tsx new file mode 100644 index 0000000000000..d5dc597d256ab --- /dev/null +++ b/apps/mobile/src/state/use-selected-thread-git-state.test.tsx @@ -0,0 +1,95 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => ({ + root: "", + cwd: "", +})); + +vi.mock("./use-thread-selection", () => ({ + useThreadSelection: () => ({ + selectedThread: { environmentId: "environment-1" }, + selectedThreadProject: { workspaceRoot: harness.root }, + }), +})); +vi.mock("./use-selected-thread-worktree", () => ({ + useSelectedThreadWorktree: () => ({ selectedThreadCwd: harness.cwd }), +})); +vi.mock("./use-vcs-action-state", () => ({ + useVcsActionState: () => ({ currentLabel: null }), +})); +vi.mock("./sourceControl", () => ({ + sourceControlEnvironment: { discovery: () => null }, +})); +vi.mock("./query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); +vi.mock("./queries", () => ({ + useBranches: ({ cwd }: { cwd: string }) => ({ + data: { + refs: execFileSync("git", ["for-each-ref", "--format=%(refname:short) %(HEAD)", "refs/heads"], { + cwd, + encoding: "utf8", + }) + .trim() + .split("\n") + .map((line) => ({ name: line.split(" ")[0], current: line.endsWith("*"), isRemote: false })), + }, + isPending: false, + }), +})); + +import { useSelectedThreadGitState } from "./use-selected-thread-git-state"; + +it("marks the selected checkout's branch current when switching threads in one project", () => { + const directory = mkdtempSync(join(tmpdir(), "mobile-branches-")); + const git = (...args: string[]) => + execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { + cwd: directory, + encoding: "utf8", + stdio: "pipe", + }); + const root = createRoot(document.createElement("div")); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + let currentBranches: string[] = []; + function Consumer() { + currentBranches = useSelectedThreadGitState() + .selectedThreadBranches.filter((branch) => branch.current) + .map((branch) => branch.name); + return null; + } + + try { + git("init", "--initial-branch=main"); + git( + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-m", + "initial", + ); + const worktree = join(directory, "linked"); + git("worktree", "add", "-b", "feature/mobile", worktree); + harness.root = directory; + harness.cwd = directory; + act(() => root.render()); + expect(currentBranches).toEqual(["main"]); + + harness.cwd = worktree; + act(() => root.render()); + expect(currentBranches).toEqual(["feature/mobile"]); + + harness.cwd = directory; + act(() => root.render()); + expect(currentBranches).toEqual(["main"]); + } finally { + act(() => root.unmount()); + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/mobile/src/state/use-selected-thread-git-state.ts b/apps/mobile/src/state/use-selected-thread-git-state.ts index a8c037db6f774..767f0e7cf927e 100644 --- a/apps/mobile/src/state/use-selected-thread-git-state.ts +++ b/apps/mobile/src/state/use-selected-thread-git-state.ts @@ -10,7 +10,7 @@ import { useThreadSelection } from "./use-thread-selection"; import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; export function useSelectedThreadGitState() { - const { selectedThread, selectedThreadProject } = useThreadSelection(); + const { selectedThread } = useThreadSelection(); const { selectedThreadCwd } = useSelectedThreadWorktree(); const selectedThreadGitTarget = useMemo( @@ -33,10 +33,10 @@ export function useSelectedThreadGitState() { const selectedThreadBranchTarget = useMemo( () => ({ environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadProject?.workspaceRoot ?? null, + cwd: selectedThreadCwd, query: null, }), - [selectedThread?.environmentId, selectedThreadProject?.workspaceRoot], + [selectedThread?.environmentId, selectedThreadCwd], ); const selectedThreadBranchState = useBranches(selectedThreadBranchTarget); const selectedThreadBranches = useMemo( diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 3f66714c68815..c0449b6a245cd 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -7,6 +7,7 @@ const reactTests = [ "src/features/settings/components/BranchNamingSettings.test.tsx", "src/native/NativeHeaderToolbar.test.tsx", "src/state/use-selected-thread-git-actions.test.tsx", + "src/state/use-selected-thread-git-state.test.tsx", ]; export default defineConfig({ From 9b11511bd8a6b32eea2b6aaba3adef222acc9d9d Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 22 Oct 2026 01:01:30 -0500 Subject: [PATCH 2/2] test(mobile): follow node imports and observe committed hook state Validate the linked-worktree regression against base and candidate. Apply the repository namespace-import rule and read hook output after the React commit. Co-Authored-By: GPT-6.1 Sol --- .../use-selected-thread-git-state.test.tsx | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/state/use-selected-thread-git-state.test.tsx b/apps/mobile/src/state/use-selected-thread-git-state.test.tsx index d5dc597d256ab..73c3b22d02abe 100644 --- a/apps/mobile/src/state/use-selected-thread-git-state.test.tsx +++ b/apps/mobile/src/state/use-selected-thread-git-state.test.tsx @@ -1,8 +1,8 @@ -import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { act } from "react"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; @@ -30,13 +30,21 @@ vi.mock("./query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); vi.mock("./queries", () => ({ useBranches: ({ cwd }: { cwd: string }) => ({ data: { - refs: execFileSync("git", ["for-each-ref", "--format=%(refname:short) %(HEAD)", "refs/heads"], { - cwd, - encoding: "utf8", - }) + refs: NodeChildProcess.execFileSync( + "git", + ["for-each-ref", "--format=%(refname:short) %(HEAD)", "refs/heads"], + { + cwd, + encoding: "utf8", + }, + ) .trim() .split("\n") - .map((line) => ({ name: line.split(" ")[0], current: line.endsWith("*"), isRemote: false })), + .map((line) => ({ + name: line.split(" ")[0], + current: line.endsWith("*"), + isRemote: false, + })), }, isPending: false, }), @@ -45,9 +53,9 @@ vi.mock("./queries", () => ({ import { useSelectedThreadGitState } from "./use-selected-thread-git-state"; it("marks the selected checkout's branch current when switching threads in one project", () => { - const directory = mkdtempSync(join(tmpdir(), "mobile-branches-")); + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "mobile-branches-")); const git = (...args: string[]) => - execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { + NodeChildProcess.execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { cwd: directory, encoding: "utf8", stdio: "pipe", @@ -56,9 +64,12 @@ it("marks the selected checkout's branch current when switching threads in one p (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; let currentBranches: string[] = []; function Consumer() { - currentBranches = useSelectedThreadGitState() - .selectedThreadBranches.filter((branch) => branch.current) - .map((branch) => branch.name); + const { selectedThreadBranches } = useSelectedThreadGitState(); + useLayoutEffect(() => { + currentBranches = selectedThreadBranches + .filter((branch) => branch.current) + .map((branch) => branch.name); + }, [selectedThreadBranches]); return null; } @@ -74,7 +85,7 @@ it("marks the selected checkout's branch current when switching threads in one p "-m", "initial", ); - const worktree = join(directory, "linked"); + const worktree = NodePath.join(directory, "linked"); git("worktree", "add", "-b", "feature/mobile", worktree); harness.root = directory; harness.cwd = directory; @@ -90,6 +101,6 @@ it("marks the selected checkout's branch current when switching threads in one p expect(currentBranches).toEqual(["main"]); } finally { act(() => root.unmount()); - rmSync(directory, { recursive: true, force: true }); + NodeFS.rmSync(directory, { recursive: true, force: true }); } });