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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/mobile/src/features/threads/use-project-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/state/use-thread-outbox-drain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
);
Expand Down Expand Up @@ -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 () => {
Expand Down
22 changes: 15 additions & 7 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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, "-")
Expand All @@ -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* () {
Expand Down Expand Up @@ -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<ChatAttachment>;
}) {
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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)) {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions docs/user/source-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
30 changes: 22 additions & 8 deletions packages/shared/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
applyGitStatusStreamEvent,
buildTemporaryWorktreeBranchName,
deriveProjectBranchPrefix,
isTemporaryWorktreeBranch,
normalizeGitRemoteUrl,
parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
Expand Down Expand Up @@ -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";
}),
Expand All @@ -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", () => {
Expand All @@ -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",
);
});
});

Expand Down
27 changes: 20 additions & 7 deletions packages/shared/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uuid>`
// 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.
Expand Down Expand Up @@ -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
Expand All @@ -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)
);
}

/**
Expand Down
Loading