Skip to content
Closed
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
32 changes: 16 additions & 16 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ describe("ProviderCommandReactor", () => {

async function createHarness(input?: {
readonly baseDir?: string;
readonly worktreeBranchPrefix?: string;
readonly threadModelSelection?: ModelSelection;
readonly sessionModelSwitch?: "unsupported" | "in-session";
readonly requiresNewThreadForModelChange?: boolean;
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,8 +1463,8 @@ 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();
it("generates a worktree branch name for the first turn using the configured prefix", async () => {
const harness = await createHarness({ worktreeBranchPrefix: "feat/" });
const now = "2026-01-01T00:00:00.000Z";

await Effect.runPromise(
Expand All @@ -1470,19 +1477,8 @@ describe("ProviderCommandReactor", () => {
}),
);

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.mockImplementation(() =>
Effect.succeed({ branch: "Safer reconnect backoff" }),
);

await Effect.runPromise(
Expand All @@ -1507,6 +1503,10 @@ describe("ProviderCommandReactor", () => {
expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({
message: "Add a safer reconnect backoff.",
});
expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({
oldBranch: "t3code/1234abcd",
newBranch: "feat/safer-reconnect-backoff",
});
expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree");
});

Expand Down
30 changes: 5 additions & 25 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
type RuntimeMode,
type TurnId,
} from "@t3tools/contracts";
import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git";
import { buildGeneratedWorktreeBranchName, isTemporaryWorktreeBranch } 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,29 +275,6 @@ 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 {
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 safeFragment = branchFragment.length > 0 ? branchFragment : "update";
return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`;
}

const make = Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
const orchestrationEngine = yield* OrchestrationEngineService;
Expand Down Expand Up @@ -818,7 +795,10 @@ const make = Effect.gen(function* () {
});
if (!generated) return;

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

const renamed = yield* gitWorkflow.renameBranch({ cwd, oldBranch, newBranch: targetBranch });
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
MIN_TERMINAL_FONT_SIZE,
} from "@t3tools/contracts/settings";
import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
import { sanitizeBranchPrefix } from "@t3tools/shared/git";
import { createModelSelection } from "@t3tools/shared/model";
import * as Duration from "effect/Duration";
import * as Equal from "effect/Equal";
Expand Down Expand Up @@ -517,6 +518,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 All @@ -540,6 +544,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.addProjectBaseDirectory,
settings.defaultThreadEnvMode,
settings.newWorktreesStartFromOrigin,
settings.worktreeBranchPrefix,
settings.diffIgnoreWhitespace,
settings.environmentIdentificationMode,
settings.fontFamilyCode,
Expand Down Expand Up @@ -645,6 +650,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 @@ -2159,6 +2165,35 @@ export function GeneralSettingsPanel() {
/>
) : null}

<SettingsRow
{...searchableSetting("worktree-branch-prefix")}
description="Prefixes generated worktree branch names. Empty for none."
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-72"
value={settings.worktreeBranchPrefix}
onCommit={(next) =>
updateSettings({ worktreeBranchPrefix: sanitizeBranchPrefix(next) })
}
placeholder="t3code/"
spellCheck={false}
aria-label="Worktree branch prefix"
/>
}
/>

<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 @@ -139,6 +139,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
11 changes: 11 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,17 @@ describe("ServerSettings worktree defaults", () => {
decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin,
).toBe(false);
});

it("defaults the branch prefix to the built-in namespace for legacy configs", () => {
expect(decodeServerSettings({}).worktreeBranchPrefix).toBe("t3code/");
});

it("accepts a custom branch prefix, including an empty one", () => {
expect(decodeServerSettingsPatch({ worktreeBranchPrefix: "feat/" }).worktreeBranchPrefix).toBe(
"feat/",
);
expect(decodeServerSettingsPatch({ worktreeBranchPrefix: "" }).worktreeBranchPrefix).toBe("");
});
});

describe("ServerSettings.sourceControlWritingStyle", () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,7 @@ export const ServerSettings = Schema.Struct({
newWorktreesStartFromOrigin: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(true)),
),
worktreeBranchPrefix: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("t3code/"))),
addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
textGenerationModelSelection: ModelSelection.pipe(
Schema.withDecodingDefault(
Expand Down Expand Up @@ -725,6 +726,7 @@ export const ServerSettingsPatch = Schema.Struct({
backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile),
defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
worktreeBranchPrefix: Schema.optionalKey(TrimmedString),
addProjectBaseDirectory: Schema.optionalKey(TrimmedString),
textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),
sourceControlWritingStyle: Schema.optionalKey(
Expand Down
57 changes: 57 additions & 0 deletions packages/shared/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";

import {
applyGitStatusStreamEvent,
buildGeneratedWorktreeBranchName,
buildTemporaryWorktreeBranchName,
isTemporaryWorktreeBranch,
normalizeGitRemoteUrl,
Expand Down Expand Up @@ -110,6 +111,62 @@ describe("isTemporaryWorktreeBranch", () => {
});
});

describe("buildGeneratedWorktreeBranchName", () => {
it("applies the default prefix", () => {
expect(buildGeneratedWorktreeBranchName("t3code/", "Multiselect Toggle")).toBe(
"t3code/multiselect-toggle",
);
});

it("applies a custom prefix verbatim", () => {
expect(buildGeneratedWorktreeBranchName("feat/", "Multiselect Toggle")).toBe(
"feat/multiselect-toggle",
);
expect(buildGeneratedWorktreeBranchName("wip-", "Multiselect Toggle")).toBe(
"wip-multiselect-toggle",
);
});

it("produces a bare name for an empty prefix", () => {
expect(buildGeneratedWorktreeBranchName("", "Multiselect Toggle")).toBe("multiselect-toggle");
});

it("strips a temporary-branch namespace the model echoed back", () => {
expect(buildGeneratedWorktreeBranchName("feat/", "t3code/multiselect-toggle")).toBe(
"feat/multiselect-toggle",
);
});

it("strips the configured prefix the model echoed back", () => {
expect(buildGeneratedWorktreeBranchName("feat/", "feat/multiselect-toggle")).toBe(
"feat/multiselect-toggle",
);
expect(buildGeneratedWorktreeBranchName("feat/", "refs/heads/feat/multiselect-toggle")).toBe(
"feat/multiselect-toggle",
);
});

it("keeps a name that merely starts with a separator-less prefix", () => {
expect(buildGeneratedWorktreeBranchName("feat", "feature-x")).toBe("featfeature-x");
});

it("filters characters git rejects in a prefix", () => {
expect(buildGeneratedWorktreeBranchName("feat branch/", "multiselect toggle")).toBe(
"feat-branch/multiselect-toggle",
);
expect(buildGeneratedWorktreeBranchName("//feat//", "multiselect toggle")).toBe(
"feat/multiselect-toggle",
);
expect(buildGeneratedWorktreeBranchName("-feat/", "multiselect toggle")).toBe(
"feat/multiselect-toggle",
);
});

it("falls back to update when the generated name sanitizes to nothing", () => {
expect(buildGeneratedWorktreeBranchName("feat/", "!!!")).toBe("feat/update");
});
});

describe("applyGitStatusStreamEvent", () => {
it("treats a remote-only update as a repository when local state is missing", () => {
const remote: VcsStatusRemoteResult = {
Expand Down
31 changes: 31 additions & 0 deletions packages/shared/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,37 @@ export function isTemporaryWorktreeBranch(refName: string): boolean {
return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase());
}

export function sanitizeBranchPrefix(raw: string): string {
return raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9/_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/\/+/g, "/")
.replace(/^[-/]+/, "")
.slice(0, 64);
}

function stripLeadingNamespace(value: string, namespace: string): string {
return /[/_-]$/.test(namespace) && value.startsWith(namespace)
? value.slice(namespace.length)
: value;
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

export function buildGeneratedWorktreeBranchName(prefix: string, raw: string): string {
const normalized = raw
.trim()
.toLowerCase()
.replace(/^refs\/heads\//, "")
.replace(/['"`]/g, "");
const safePrefix = sanitizeBranchPrefix(prefix);
const withoutEcho = stripLeadingNamespace(
stripLeadingNamespace(normalized, `${WORKTREE_BRANCH_PREFIX}/`),
safePrefix,
);
return `${safePrefix}${sanitizeBranchFragment(withoutEcho)}`;
}

/**
* Normalize a git remote URL into a stable comparison key.
*/
Expand Down
Loading