From 564de4116140b85c663b40ffcb74a611e808fa33 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Mon, 17 Aug 2026 14:51:09 -0500 Subject: [PATCH] fix: scope worktree branches to their project - Generate temporary and readable worktree branches under a project-specific namespace - Preserve regeneration support for legacy t3code worktree branches - Update web, mobile, server tests, and source-control documentation --- .../features/threads/use-project-actions.ts | 5 +++- .../src/state/use-thread-outbox-drain.ts | 2 +- .../Layers/ProviderCommandReactor.test.ts | 7 ++++- .../Layers/ProviderCommandReactor.ts | 22 +++++++++----- apps/web/src/components/ChatView.tsx | 5 +++- docs/user/source-control.md | 6 ++++ packages/shared/src/git.test.ts | 30 ++++++++++++++----- packages/shared/src/git.ts | 27 ++++++++++++----- 8 files changed, 78 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 9d03dde59a93..aef77f4d539d 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -74,7 +74,10 @@ export function useCreateProjectThread() { branch: input.branch, worktreePath: input.worktreePath, startFromOrigin: input.startFromOrigin ?? false, - worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), + worktreeBranchName: buildTemporaryWorktreeBranchName( + input.project.workspaceRoot, + randomHex, + ), }), }); if (AsyncResult.isFailure(result)) { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 68c973ff97e3..ab8d6db056d6 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -274,7 +274,7 @@ export function useThreadOutboxDrain(): void { branch: creation.branch, worktreePath: creation.worktreePath, startFromOrigin: creation.startFromOrigin ?? false, - worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), + worktreeBranchName: buildTemporaryWorktreeBranchName(projectCwd, randomHex), }), }); return completeDelivery(deliveryResult); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 305da9274ca7..7d9eb00759a7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1894,7 +1894,7 @@ describe("ProviderCommandReactor", () => { type: "thread.meta.update", commandId: CommandId.make("cmd-thread-branch"), threadId: ThreadId.make("thread-1"), - branch: "t3code/1234abcd", + branch: "provider-project/worktree-1234abcd", worktreePath: "/tmp/provider-project-worktree", }), ); @@ -1937,6 +1937,11 @@ describe("ProviderCommandReactor", () => { message: "Add a safer reconnect backoff.", }); expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); + expect(harness.renameBranch.mock.calls[0]?.[0]).toEqual({ + cwd: "/tmp/provider-project-worktree", + oldBranch: "provider-project/worktree-1234abcd", + newBranch: "provider-project/feature/gpt-5-6-luna", + }); }); it("forwards codex model options through session start and turn send", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index e52bc4b83c3a..97935c9e0f44 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -17,7 +17,11 @@ import { type VcsCreateWorktreeInput, type VcsRef, } from "@t3tools/contracts"; -import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; +import { + deriveProjectBranchPrefix, + isTemporaryWorktreeBranch, + WORKTREE_BRANCH_PREFIX, +} from "@t3tools/shared/git"; import * as FileSystem from "effect/FileSystem"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; @@ -334,16 +338,18 @@ function stalePendingRequestDetail( return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`; } -function buildGeneratedWorktreeBranchName(raw: string): string { +function buildGeneratedWorktreeBranchName(raw: string, projectCwd: string): string { const normalized = raw .trim() .toLowerCase() .replace(/^refs\/heads\//, "") .replace(/['"`]/g, ""); - const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`) - ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length) - : normalized; + const projectPrefix = deriveProjectBranchPrefix(projectCwd); + const existingPrefix = [projectPrefix, WORKTREE_BRANCH_PREFIX].find((prefix) => + normalized.startsWith(`${prefix}/`), + ); + const withoutPrefix = existingPrefix ? normalized.slice(`${existingPrefix}/`.length) : normalized; const branchFragment = withoutPrefix .replace(/[^a-z0-9/_-]+/g, "-") @@ -354,7 +360,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string { .replace(/[./_-]+$/g, ""); const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; - return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; + return `${projectPrefix}/${safeFragment}`; } const make = Effect.gen(function* () { @@ -911,6 +917,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly branch: string | null; readonly worktreePath: string | null; + readonly projectCwd: string; readonly messageText: string; readonly attachments?: ReadonlyArray; }) { @@ -942,7 +949,7 @@ const make = Effect.gen(function* () { }); if (!generated) return; - const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + const targetBranch = buildGeneratedWorktreeBranchName(generated.branch, input.projectCwd); if (targetBranch === oldBranch) return; const renamed = yield* gitWorkflow.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); @@ -1239,6 +1246,7 @@ const make = Effect.gen(function* () { branch: thread.branch, worktreePath: thread.worktreePath, ...generationInput, + projectCwd: project?.workspaceRoot ?? generationCwd, }).pipe(Effect.forkScoped); if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7f1c7b733ffc..83672d659da4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5468,7 +5468,10 @@ function ChatViewContent(props: ChatViewProps) { prepareWorktree: { projectCwd: activeProject.workspaceRoot, baseBranch: baseBranchForWorktree, - branch: buildTemporaryWorktreeBranchName(randomHex), + branch: buildTemporaryWorktreeBranchName( + activeProject.workspaceRoot, + randomHex, + ), ...(startFromOrigin ? { startFromOrigin: true } : {}), }, runSetupScript: true, diff --git a/docs/user/source-control.md b/docs/user/source-control.md index c64a63f7bc49..225104484141 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -27,6 +27,12 @@ T3 Code works with the platforms your team already uses: - Use the **Publish Repository** action to create a new hosted repository (GitHub, GitLab, Bitbucket, or Azure DevOps), add it as your origin remote, and push, in one flow - If the local repository has no commits yet, publishing creates the remote and wires it up but does not push. Make a commit, then push normally. +### Keep Worktree Branches Project-Scoped + +New worktree threads use the selected project's directory name as their branch namespace. For +example, a project in `Code/dashboard` starts with a temporary `dashboard/worktree-…` branch and +then replaces it with a readable `dashboard/…` name generated from the first message. + ### Manage Code Reviews Without Context Switching **Create pull requests while you work** diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 8dea20f0b423..a218a0394f82 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { applyGitStatusStreamEvent, buildTemporaryWorktreeBranchName, + deriveProjectBranchPrefix, isTemporaryWorktreeBranch, normalizeGitRemoteUrl, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, @@ -66,7 +67,7 @@ describe("isTemporaryWorktreeBranch", () => { it("matches the generated temporary worktree refName format", () => { expect( isTemporaryWorktreeBranch( - buildTemporaryWorktreeBranchName((byteLength) => { + buildTemporaryWorktreeBranchName("/Users/example/Code/dashboard", (byteLength) => { expect(byteLength).toBe(4); return "DEADBEEF"; }), @@ -75,15 +76,18 @@ describe("isTemporaryWorktreeBranch", () => { }); it("matches generated temporary worktree refs", () => { - expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef`)).toBe(true); - expect(isTemporaryWorktreeBranch(` ${WORKTREE_BRANCH_PREFIX}/deadbeef `)).toBe(true); - expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/DEADBEEF`)).toBe(true); + expect(isTemporaryWorktreeBranch("dashboard/worktree-deadbeef")).toBe(true); + expect(isTemporaryWorktreeBranch(" dashboard/worktree-deadbeef ")).toBe(true); + expect(isTemporaryWorktreeBranch("dashboard/worktree-DEADBEEF")).toBe(true); }); - it("normalizes a UUID-shaped random callback to the canonical 8-hex form", () => { - expect(buildTemporaryWorktreeBranchName(() => "f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12")).toBe( - `${WORKTREE_BRANCH_PREFIX}/f4ae4e0e`, - ); + it("scopes temporary branches to the project directory", () => { + expect( + buildTemporaryWorktreeBranchName( + "C:\\Users\\example\\Code\\My Dashboard.git\\", + () => "f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12", + ), + ).toBe("my-dashboard/worktree-f4ae4e0e"); }); it("matches legacy UUID-shaped temporary worktree refs from older mobile builds", () => { @@ -107,6 +111,16 @@ describe("isTemporaryWorktreeBranch", () => { expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false); expect(isTemporaryWorktreeBranch("main")).toBe(false); expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef-extra`)).toBe(false); + expect(isTemporaryWorktreeBranch("feature/deadbeef")).toBe(false); + }); +}); + +describe("deriveProjectBranchPrefix", () => { + it("uses the project directory rather than the T3 Code product name", () => { + expect(deriveProjectBranchPrefix("/home/example/Code/scratch")).toBe("scratch"); + expect(deriveProjectBranchPrefix("/home/example/Code/Nightsong Dashboard/")).toBe( + "nightsong-dashboard", + ); }); }); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 7c088970d583..d4e907a3e36b 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -10,14 +10,12 @@ import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; +// Kept for recognizing branches created before worktree names became project-scoped. export const WORKTREE_BRANCH_PREFIX = "t3code"; -// Canonical form is `t3code/<8 hex>`. Older mobile builds generated `t3code/` -// via Crypto.randomUUID() (always RFC 4122 v4), so the matcher also accepts exactly -// that shape — version nibble `4`, variant nibble `[89ab]` — to keep those threads -// eligible for branch regeneration without loosening beyond what was ever generated. -const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp( +const LEGACY_TEMP_WORKTREE_BRANCH_PATTERN = new RegExp( `^${WORKTREE_BRANCH_PREFIX}\\/(?:[0-9a-f]{8}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`, ); +const PROJECT_SCOPED_TEMP_WORKTREE_BRANCH_PATTERN = /^[a-z0-9][a-z0-9_-]*\/worktree-[0-9a-f]{8}$/; /** * Sanitize an arbitrary string into a valid, lowercase git refName fragment. @@ -92,7 +90,18 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { return branchName.slice(firstSeparatorIndex + 1); } +export function deriveProjectBranchPrefix(projectCwd: string): string { + const normalized = projectCwd.trim().replace(/\\/g, "/").replace(/\/+$/g, ""); + const basename = + normalized + .split("/") + .at(-1) + ?.replace(/\.git$/i, "") ?? ""; + return sanitizeBranchFragment(basename || "project").replace(/\//g, "-"); +} + export function buildTemporaryWorktreeBranchName( + projectCwd: string, randomHex: (byteLength: number) => string, ): string { // Normalize to exactly 8 lowercase hex chars so a UUID-shaped callback @@ -101,11 +110,15 @@ export function buildTemporaryWorktreeBranchName( .toLowerCase() .replace(/[^0-9a-f]/g, "") .slice(0, 8); - return `${WORKTREE_BRANCH_PREFIX}/${token}`; + return `${deriveProjectBranchPrefix(projectCwd)}/worktree-${token}`; } export function isTemporaryWorktreeBranch(refName: string): boolean { - return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); + const normalized = refName.trim().toLowerCase(); + return ( + PROJECT_SCOPED_TEMP_WORKTREE_BRANCH_PATTERN.test(normalized) || + LEGACY_TEMP_WORKTREE_BRANCH_PATTERN.test(normalized) + ); } /**