Skip to content
Open
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
120 changes: 70 additions & 50 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ describe("ProviderCommandReactor", () => {
readonly requiresNewThreadForModelChange?: boolean;
readonly titleRegenerationCompletionDispatchFailures?: number;
readonly titleRegenerationBeforeStart?: "one" | "two";
readonly worktreeBranchPrefix?: string;
readonly startSessionEffect?: (
session: ProviderSession,
) => Effect.Effect<ProviderSession, ProviderAdapterRequestError>;
Expand Down Expand Up @@ -412,7 +413,13 @@ describe("ProviderCommandReactor", () => {
generateThreadTitle,
}),
),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(
ServerSettingsService.layerTest(
input?.worktreeBranchPrefix === undefined
? {}
: { worktreeBranchPrefix: input.worktreeBranchPrefix },
),
),
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
Layer.provideMerge(NodeServices.layer),
);
Expand Down Expand Up @@ -1456,59 +1463,72 @@ describe("ProviderCommandReactor", () => {
expect(thread?.title).toBe("Reconnect spinner resume bug");
});

it("generates a worktree branch name for the first turn", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";
it.each([
{
prefix: undefined,
generated: "feature/gpt-5-6-luna",
expected: "t3code/feature/gpt-5-6-luna",
},
{
prefix: "my-team",
generated: "My-Team/feature/gpt-5-6-luna",
expected: "my-team/feature/gpt-5-6-luna",
},
{
prefix: "t3code/my-team",
generated: "t3code/my-team/feature/gpt-5-6-luna",
expected: "t3code/my-team/feature/gpt-5-6-luna",
},
{ prefix: "", generated: "feature/gpt-5-6-luna", expected: "feature/gpt-5-6-luna" },
])(
"generates a worktree branch name with prefix $prefix",
async ({ prefix, generated, expected }) => {
const harness = await createHarness(
prefix === undefined ? undefined : { worktreeBranchPrefix: prefix },
);
const now = "2026-01-01T00:00:00.000Z";

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("cmd-thread-branch"),
threadId: ThreadId.make("thread-1"),
branch: "t3code/1234abcd",
worktreePath: "/tmp/provider-project-worktree",
}),
);
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("cmd-thread-branch"),
threadId: ThreadId.make("thread-1"),
branch: "t3code/1234abcd",
worktreePath: "/tmp/provider-project-worktree",
}),
);

harness.generateBranchName.mockImplementation((input: unknown) =>
Effect.succeed({
branch:
typeof input === "object" &&
input !== null &&
"modelSelection" in input &&
typeof input.modelSelection === "object" &&
input.modelSelection !== null &&
"model" in input.modelSelection &&
typeof input.modelSelection.model === "string"
? `feature/${input.modelSelection.model}`
: "feature/generated",
}),
);
harness.generateBranchName.mockReturnValue(Effect.succeed({ branch: generated }));

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-branch-model"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-branch-model"),
role: "user",
text: "Add a safer reconnect backoff.",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-branch-model"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-branch-model"),
role: "user",
text: "Add a safer reconnect backoff.",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
await waitFor(() => harness.refreshStatus.mock.calls.length === 1);
expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({
message: "Add a safer reconnect backoff.",
});
expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree");
});
await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
await waitFor(() => harness.refreshStatus.mock.calls.length === 1);
expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({
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]).toMatchObject({
oldBranch: "t3code/1234abcd",
newBranch: expected,
});
},
);

it("forwards codex model options through session start and turn send", async () => {
const harness = await createHarness();
Expand Down
34 changes: 18 additions & 16 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
type RuntimeMode,
type TurnId,
} from "@t3tools/contracts";
import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git";
import {
isTemporaryWorktreeBranch,
sanitizeBranchFragment,
WORKTREE_BRANCH_PREFIX,
} from "@t3tools/shared/git";
import * as Cache from "effect/Cache";
import * as Cause from "effect/Cause";
import * as Crypto from "effect/Crypto";
Expand Down Expand Up @@ -275,27 +279,22 @@ 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, prefix: 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 branchFragment = withoutPrefix
.replace(/[^a-z0-9/_-]+/g, "-")
.replace(/\/+/g, "/")
.replace(/-+/g, "-")
.replace(/^[./_-]+|[./_-]+$/g, "")
.slice(0, 64)
.replace(/[./_-]+$/g, "");
const withoutPrefix =
prefix.length > 0 && normalized.startsWith(`${prefix}/`)
? normalized.slice(`${prefix}/`.length)
: normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`)
? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length)
: normalized;

const safeFragment = branchFragment.length > 0 ? branchFragment : "update";
return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`;
const safeFragment = sanitizeBranchFragment(withoutPrefix);
return prefix.length > 0 ? `${prefix}/${safeFragment}` : safeFragment;
}

const make = Effect.gen(function* () {
Expand Down Expand Up @@ -818,7 +817,10 @@ const make = Effect.gen(function* () {
});
if (!generated) return;

const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
const targetBranch = buildGeneratedWorktreeBranchName(
generated.branch,
settings.worktreeBranchPrefix,
);
if (targetBranch === oldBranch) return;

const renamed = yield* gitWorkflow.renameBranch({ cwd, oldBranch, newBranch: targetBranch });
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from "@t3tools/contracts/settings";
import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
import { createModelSelection } from "@t3tools/shared/model";
import { sanitizeBranchFragment } from "@t3tools/shared/git";
import * as Duration from "effect/Duration";
import * as Equal from "effect/Equal";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -518,6 +519,9 @@ export function useSettingsRestore(onRestored?: () => void) {
DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin
? ["New worktrees start from origin"]
: []),
...(settings.worktreeBranchPrefix !== DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix
? ["Worktree branch prefix"]
: []),
...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory
? ["Add project base directory"]
: []),
Expand Down Expand Up @@ -550,6 +554,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.addProjectBaseDirectory,
settings.defaultThreadEnvMode,
settings.newWorktreesStartFromOrigin,
settings.worktreeBranchPrefix,
settings.diffIgnoreWhitespace,
settings.environmentIdentificationMode,
settings.fontFamilyCode,
Expand Down Expand Up @@ -655,6 +660,7 @@ export function useSettingsRestore(onRestored?: () => void) {
providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval,
defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
worktreeBranchPrefix: DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix,
addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
Expand Down Expand Up @@ -2199,6 +2205,41 @@ export function GeneralSettingsPanel() {
/>
) : null}

<SettingsRow
{...searchableSetting("worktree-branch-prefix")}
description="Prefix for automatically generated worktree branch names. Leave empty for no prefix."
resetAction={
settings.worktreeBranchPrefix !== DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix ? (
<SettingResetButton
label="worktree branch prefix"
onClick={() =>
updateSettings({
worktreeBranchPrefix: DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix,
})
}
/>
) : null
}
control={
<DraftInput
className="w-full sm:w-44"
value={settings.worktreeBranchPrefix}
placeholder="t3code"
spellCheck={false}
aria-label="Worktree branch prefix"
onCommit={(next) => {
const rawPrefix = next.trim();
const worktreeBranchPrefix = /[a-z0-9]/i.test(rawPrefix)
? sanitizeBranchFragment(rawPrefix)
: "";
if (worktreeBranchPrefix !== settings.worktreeBranchPrefix) {
updateSettings({ worktreeBranchPrefix });
}
}}
/>
}
/>

<SettingsRow
{...searchableSetting("add-project-starts-in")}
description='Leave empty to use "~/" when the Add Project browser opens.'
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
targetId: "new-threads",
},
{
id: "worktree-branch-prefix",
title: "Worktree branch prefix",
to: "/settings/general",
},
{
id: "add-project-starts-in",
title: "Add project starts in",
Expand Down
3 changes: 3 additions & 0 deletions docs/user/source-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

T3 Code connects to your Git hosting provider so you can create pull requests, review code, and manage repositories without leaving the app.

T3 Code prefixes automatically generated worktree branches with `t3code` by default. Change or
remove the prefix under **Settings → General → Worktree branch prefix**.

## Supported Providers

T3 Code works with the platforms your team already uses:
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,12 @@ describe("ServerSettings worktree defaults", () => {
decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin,
).toBe(false);
});

it("defaults the worktree branch prefix and accepts an empty prefix", () => {
expect(decodeServerSettings({}).worktreeBranchPrefix).toBe("t3code");
expect(decodeServerSettingsPatch({ worktreeBranchPrefix: "" }).worktreeBranchPrefix).toBe("");
expect(() => decodeServerSettingsPatch({ worktreeBranchPrefix: "/.." })).toThrow();
});
});

describe("ServerSettings.sourceControlWritingStyle", () => {
Expand Down
15 changes: 15 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,17 @@ const makeBinaryPathSetting = (fallback: string) =>
Schema.withDecodingDefault(Effect.succeed(fallback)),
);

const WorktreeBranchPrefix = TrimmedString.check(
Schema.makeFilter(
(value) =>
value.length === 0 ||
(value.length <= 64 &&
/^[a-z0-9](?:[a-z0-9/_-]*[a-z0-9])?$/.test(value) &&
!value.includes("//")) ||
"must be an empty string or a normalized Git branch prefix",
),
);

export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch";

export interface ProviderSettingsFormAnnotation {
Expand Down Expand Up @@ -627,6 +638,9 @@ export const ServerSettings = Schema.Struct({
newWorktreesStartFromOrigin: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(true)),
),
worktreeBranchPrefix: WorktreeBranchPrefix.pipe(
Schema.withDecodingDefault(Effect.succeed("t3code")),
),
addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
textGenerationModelSelection: ModelSelection.pipe(
Schema.withDecodingDefault(
Expand Down Expand Up @@ -826,6 +840,7 @@ export const ServerSettingsPatch = Schema.Struct({
backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile),
defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
worktreeBranchPrefix: Schema.optionalKey(WorktreeBranchPrefix),
addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
sourceControlWritingStyle: Schema.optionalKey(
Expand Down
Loading