diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index b1aab9541212..7b9e2a058d20 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -21,6 +21,7 @@ import { AndroidSettingsEnvironmentFilter, SettingsEnvironmentFilterHeader, } from "./components/SettingsEnvironmentFilterHeader"; +import { BranchNamingSettings } from "./components/BranchNamingSettings"; import { SettingsChoiceRow } from "./components/SettingsChoiceRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; @@ -45,7 +46,13 @@ const PAGE_TITLES: Record = { const PAGE_PROJECT_KEYS: Record = { "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"], - "source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"], + "source-control": [ + "defaultAutoPull", + "newWorktreesStartFromOrigin", + "branchNamingMode", + "branchNamePrefix", + "branchNameInstructions", + ], "agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"], maintenance: ["continueThreadsAfterServerUpdate"], }; @@ -314,6 +321,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { {props.page === "source-control" ? ( <> + `${target.environment.environmentId}:${target.projectId}`) + .join(",")} + mode={uniform("branchNamingMode")} + prefix={uniform("branchNamePrefix")} + instructions={uniform("branchNameInstructions")} + disabled={disabledFor("branchNamingMode")} + onChange={write} + /> ; + +export function BranchNamingSettings(props: { + mode: BranchNamingMode | null; + prefix: string | null; + instructions: string | null; + disabled: boolean; + onChange: (patch: ServerSettingsPatch) => void; +}) { + const prefixEdited = useRef(false); + const instructionsEdited = useRef(false); + return ( + Mixed : null + } + > + {BranchNamingMode.literals.map((mode, index) => ( + 0} + disabled={props.disabled} + onPress={() => props.onChange({ branchNamingMode: mode })} + /> + ))} + {props.mode === "static" ? ( + + + Use t3code or t3code/ for t3code/add-search. Leave empty for no prefix. + + { + prefixEdited.current = true; + }} + defaultValue={props.prefix ?? ""} + placeholder={props.prefix === null ? "Mixed" : "No prefix"} + editable={!props.disabled} + autoCapitalize="none" + autoCorrect={false} + className="min-h-10 rounded-xl px-3 py-2 text-base text-foreground" + onEndEditing={(event) => { + const value = event.nativeEvent.text.trim(); + if ( + !props.disabled && + prefixEdited.current && + (props.prefix === null || value !== props.prefix) + ) + props.onChange({ branchNamePrefix: value }); + prefixEdited.current = false; + }} + /> + + ) : null} + {props.mode === "custom" ? ( + + + Append instructions to the naming prompt. + + { + instructionsEdited.current = true; + }} + defaultValue={props.instructions ?? ""} + placeholder={ + props.instructions === null + ? "Mixed. Enter instructions for all selected targets." + : "Use julius/ followed by the issue ID and a short description." + } + editable={!props.disabled} + multiline + autoCapitalize="sentences" + className="min-h-24 rounded-xl px-3 py-2 text-base text-foreground" + onEndEditing={(event) => { + const value = event.nativeEvent.text.trim(); + if ( + !props.disabled && + instructionsEdited.current && + (props.instructions === null || value !== props.instructions) + ) + props.onChange({ branchNameInstructions: value }); + instructionsEdited.current = false; + }} + /> + + ) : null} + + ); +} diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 1d043cd89e42..912e0c1f4620 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -110,6 +110,7 @@ export class GitWorkflowService extends Context.Service< input: VcsSwitchRefInput, ) => Effect.Effect; readonly renameBranch: (input: { + readonly exactName?: boolean; readonly cwd: string; readonly oldBranch: string; readonly newBranch: string; diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index beb23a3f7da5..8abcc9c68a55 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -243,13 +243,23 @@ const make = Effect.gen(function* () { ); return yield* textGeneration .generateBranchName({ + naming: { + mode: settings.branchNamingMode, + prefix: settings.branchNamePrefix, + instructions: settings.branchNameInstructions, + }, cwd, message: message.text, attachments: message.attachments, ...(message.context ? { context: message.context } : {}), modelSelection, }) - .pipe(Effect.map((result) => result.branch)); + .pipe( + Effect.map((result) => ({ + branch: result.branch, + exactName: settings.branchNamingMode === "custom", + })), + ); }); // The server owns worktree naming: without an explicit branch, provision @@ -369,8 +379,13 @@ const make = Effect.gen(function* () { const oldBranch = branch; const worktreeCwd = worktreePath; yield* generateBranchNameFor(worktreeCwd, initialMessage).pipe( - Effect.flatMap((newBranch) => - git.renameBranch({ cwd: worktreeCwd, oldBranch, newBranch }), + Effect.flatMap(({ branch: newBranch, exactName }) => + git.renameBranch({ + cwd: worktreeCwd, + oldBranch, + newBranch, + ...(exactName ? { exactName: true } : {}), + }), ), Effect.flatMap((renamed) => threads.dispatch({ diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 769c4ff83d0c..5cf319218ece 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -3,7 +3,7 @@ import { type ProviderSetupError, TextGenerationError, } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -381,10 +381,14 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener Effect.fn("AntigravityTextGeneration.generateBranchName")(function* (input) { const generated = yield* runAntigravityJson({ operation: "generateBranchName", - ...buildBranchNamePrompt({ message: input.message, attachments: input.attachments }), + ...buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + naming: input.naming, + }), modelSelection: input.modelSelection, }); - return { branch: sanitizeBranchFragment(generated.branch) }; + return { branch: formatGeneratedBranchName(generated.branch, input.naming) }; }); const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 357ecd686e46..b9f2aa0871d1 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -15,7 +15,7 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { TextGenerationError } from "@t3tools/contracts"; @@ -372,6 +372,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + naming: input.naming, }); const generated = yield* runClaudeJson({ @@ -383,7 +384,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu }); return { - branch: sanitizeBranchFragment(generated.branch), + branch: formatGeneratedBranchName(generated.branch, input.naming), }; }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 852e477e6398..4af16bf82f01 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -378,6 +378,50 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { ), ); + for (const example of [ + { + mode: "static", + output: "Add Search", + expected: "team/add-search", + instruction: "without a prefix or namespace", + }, + { + mode: "semantic", + output: "feat/add-search", + expected: "feat/add-search", + instruction: "semantic prefix", + }, + { + mode: "custom", + output: "Julius/ABC-123.v2", + expected: "Julius/ABC-123.v2", + instruction: "Preserve the issue ID and capitalization.", + }, + ] as const) { + it.effect(`generates a branch using ${example.mode} naming`, () => + withFakeCodexEnv( + { + output: JSON.stringify({ branch: example.output }), + stdinMustContain: example.instruction, + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Add search", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + naming: { + mode: example.mode, + prefix: "team/", + instructions: "Preserve the issue ID and capitalization.", + }, + }); + expect(generated.branch).toBe(example.expected); + }), + ), + ); + } + it.effect("generates branch names even when the ambient scope is already closed", () => withFakeCodexEnv( { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index b14e4fa3dd21..335163a8be8e 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -13,7 +13,7 @@ import { type ServerProviderModel, TextGenerationError, } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { resolveAttachmentPath } from "../attachmentStore.ts"; @@ -391,6 +391,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + naming: input.naming, }); const generated = yield* runCodexJson({ @@ -403,7 +404,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func }); return { - branch: sanitizeBranchFragment(generated.branch), + branch: formatGeneratedBranchName(generated.branch, input.naming), }; }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 6bb48ab6dbb8..747dff1a17b0 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -12,7 +12,7 @@ import { type ModelSelection, type ProviderSetupError, } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { TextGenerationError } from "@t3tools/contracts"; @@ -260,6 +260,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + naming: input.naming, }); const generated = yield* runCursorJson({ @@ -270,7 +271,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu }); return { - branch: sanitizeBranchFragment(generated.branch), + branch: formatGeneratedBranchName(generated.branch, input.naming), }; }); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index ea4f6bb19201..ee7bc28d64fa 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -7,7 +7,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import type * as EffectAcpErrors from "effect-acp/errors"; import { type GrokSettings, type ModelSelection } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; @@ -224,6 +224,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + naming: input.naming, }); const generated = yield* runGrokJson({ @@ -235,7 +236,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi }); return { - branch: sanitizeBranchFragment(generated.branch), + branch: formatGeneratedBranchName(generated.branch, input.naming), }; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 3ab0b4966dad..3aa347eca917 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -8,7 +8,7 @@ import { type ModelSelection, type OpenCodeSettings, } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; @@ -415,6 +415,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + naming: input.naming, }); const generated = yield* runOpenCodeJson({ operation: "generateBranchName", @@ -426,7 +427,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }); return { - branch: sanitizeBranchFragment(generated.branch), + branch: formatGeneratedBranchName(generated.branch, input.naming), }; }); diff --git a/apps/server/src/textGeneration/PiTextGeneration.ts b/apps/server/src/textGeneration/PiTextGeneration.ts index d735eb5d77df..1a4dfb93bfc3 100644 --- a/apps/server/src/textGeneration/PiTextGeneration.ts +++ b/apps/server/src/textGeneration/PiTextGeneration.ts @@ -11,7 +11,7 @@ import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/unstable/process"; import { TextGenerationError, type ModelSelection, type PiSettings } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { formatGeneratedBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { makePiRpcConnection, parsePiModelSlug } from "../orchestration-v2/Adapters/PiRpc.ts"; @@ -214,6 +214,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + naming: input.naming, }); const generated = yield* runPiJson({ operation: "generateBranchName", @@ -223,7 +224,7 @@ export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* modelSelection: input.modelSelection, }); return { - branch: sanitizeBranchFragment(generated.branch), + branch: formatGeneratedBranchName(generated.branch, input.naming), }; }); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 37634cea7e4e..a4f4db1d9044 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,7 +1,12 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ChatAttachment, ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import type { + BranchNamingOptions, + ChatAttachment, + ModelSelection, + ProviderInstanceId, +} from "@t3tools/contracts"; import { TextGenerationError } from "@t3tools/contracts"; import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstanceRegistry.ts"; @@ -48,6 +53,7 @@ export interface PrContentGenerationResult { } export interface BranchNameGenerationInput { + naming?: BranchNamingOptions | undefined; cwd: string; message: string; attachments?: ReadonlyArray | undefined; diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 86f6b54ab533..fada659aded1 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -118,6 +118,39 @@ describe("buildPrContentPrompt", () => { }); describe("buildBranchNamePrompt", () => { + it("requests a semantic prefix as part of the same branch response", () => { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: "Add search", + naming: { mode: "semantic", prefix: "ignored", instructions: "ignored instruction" }, + }); + expect(prompt).toContain("feat/add-search"); + expect(prompt).not.toContain("ignored instruction"); + expect(toJsonSchemaObject(outputSchema)).toMatchObject({ required: ["branch"] }); + }); + it("appends custom instructions without imposing a prefix, case or word limit", () => { + const { prompt } = buildBranchNamePrompt({ + message: "Add search", + naming: { + mode: "custom", + prefix: "ignored", + instructions: "Use Julius/ABC-123 and preserve capitalization.", + }, + }); + expect(prompt).toContain("Use Julius/ABC-123 and preserve capitalization."); + expect(prompt).toContain("complete branch name"); + expect(prompt).not.toContain("2-6 words"); + expect(prompt).not.toContain("lowercase"); + expect(prompt).not.toContain("no issue prefixes"); + }); + it("asks for just the fragment in static mode", () => { + const { prompt } = buildBranchNamePrompt({ + message: "Add search", + naming: { mode: "static", prefix: "team", instructions: "ignored instruction" }, + }); + expect(prompt).toContain("without a prefix or namespace"); + expect(prompt).not.toContain("ignored instruction"); + }); + it("includes the user message in the prompt", () => { const result = buildBranchNamePrompt({ message: "Fix the login timeout bug", diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index bf572a4bdbe0..236636121095 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -9,7 +9,7 @@ import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; import { limitTitleMessage } from "./ThreadTitleContext.ts"; -import type { ChatAttachment } from "@t3tools/contracts"; +import type { BranchNamingOptions, ChatAttachment } from "@t3tools/contracts"; import { limitSection } from "./TextGenerationUtils.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; @@ -144,6 +144,7 @@ export function buildPrContentPrompt(input: PrContentPromptInput) { // --------------------------------------------------------------------------- export interface BranchNamePromptInput { + naming?: BranchNamingOptions | undefined; message: string; attachments?: ReadonlyArray | undefined; policy?: TextGenerationPolicy | undefined; @@ -190,13 +191,29 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) { responseShape: "Return a JSON object with key: branch.", rules: [ "Branch should describe the requested work from the user message.", - "Keep it short and specific (2-6 words).", - "Use plain words only, no issue prefixes and no punctuation-heavy text.", + "Return a valid Git branch name without spaces.", + ...(input.naming?.mode === "custom" + ? [ + "Return the complete branch name, following the user's naming instructions. No prefix or suffix will be added.", + ] + : [ + "Keep it short and specific (2-6 words), in lowercase with hyphen-separated words.", + ...(input.naming?.mode === "semantic" + ? [ + "Include a semantic prefix and a slash in the branch name, for example feat/add-search, fix/login-error, refactor/auth, docs/setup, or chore/update-deps. Choose the prefix that best describes the work.", + ] + : [ + "Return only the descriptive branch fragment, without a prefix or namespace. The application adds the configured prefix.", + ]), + ]), "If images are attached, use them as primary context for visual/UI issues.", ], message: input.message, attachments: input.attachments, - additionalInstructions: input.policy?.branchInstructions, + additionalInstructions: + input.naming?.mode === "custom" + ? input.naming.instructions + : input.policy?.branchInstructions, }); const outputSchema = Schema.Struct({ branch: Schema.String, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index bbae7f7bfff7..9603cc0d0ae9 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -193,6 +193,8 @@ export interface GitRangeContext { } export interface GitRenameBranchInput { + /** Fail on a name collision instead of appending a numeric suffix. */ + exactName?: boolean; cwd: string; oldBranch: string; newBranch: string; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 1fe9e3fc0c20..9414e2b0b23d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2156,6 +2156,32 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("preserves exact custom names and refuses collisions without a suffix", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createRef({ cwd, refName: "Julius/ABC-123" }); + const failed = yield* driver + .renameBranch({ + cwd, + oldBranch: initialBranch, + newBranch: "Julius/ABC-123", + exactName: true, + }) + .pipe(Effect.exit); + assert.equal(failed._tag, "Failure"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch); + const renamed = yield* driver.renameBranch({ + cwd, + oldBranch: initialBranch, + newBranch: "Julius/ABC-124.v2", + exactName: true, + }); + assert.equal(renamed.branch, "Julius/ABC-124.v2"); + }), + ); + it.effect("returns the existing refName when rename source and target match", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 2f8ad263183d..a6bdf6c1cb8c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3514,7 +3514,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (input.oldBranch === input.newBranch) { return { branch: input.newBranch }; } - const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); + const targetBranch = input.exactName + ? input.newBranch + : yield* resolveAvailableBranchName(input.cwd, input.newBranch); yield* executeGit( "GitVcsDriver.renameBranch", diff --git a/apps/web/src/components/settings/BranchNamingSettings.tsx b/apps/web/src/components/settings/BranchNamingSettings.tsx new file mode 100644 index 000000000000..527f798e148b --- /dev/null +++ b/apps/web/src/components/settings/BranchNamingSettings.tsx @@ -0,0 +1,162 @@ +import { useRef } from "react"; +import { BranchNamingMode, DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; + +import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Textarea } from "../ui/textarea"; +import { SettingsRow, SettingResetButton } from "./settingsLayout"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { searchableSetting } from "./settingsSearch"; +import { + useScopedSettings, + useScopedSettingsMixed, + useUpdateScopedSettings, +} from "./useScopedSettings"; + +const MODES = { + static: "Static prefix", + semantic: "Semantic prefix", + custom: "Custom instructions", +} satisfies Record; + +export function BranchNamingSettings() { + const settings = useScopedSettings(); + const { targets } = useSettingsScope(); + const scopeKey = targets.map((target) => `${target.environmentId}:${target.projectId}`).join(","); + const prefixEdited = useRef(false); + const instructionsEdited = useRef(false); + const updateSettings = useUpdateScopedSettings(); + const modeMixed = useScopedSettingsMixed(["branchNamingMode"]); + const prefixMixed = useScopedSettingsMixed(["branchNamePrefix"]); + const instructionsMixed = useScopedSettingsMixed(["branchNameInstructions"]); + + return ( + <> + + updateSettings({ branchNamingMode: DEFAULT_SERVER_SETTINGS.branchNamingMode }) + } + /> + ) : null + } + control={ + + } + /> + {!modeMixed && settings.branchNamingMode === "static" ? ( + + updateSettings({ branchNamePrefix: DEFAULT_SERVER_SETTINGS.branchNamePrefix }) + } + /> + ) : null + } + control={ + { + prefixEdited.current = true; + }} + placeholder={prefixMixed ? "Mixed" : "No prefix"} + defaultValue={prefixMixed ? "" : settings.branchNamePrefix} + onBlur={(event) => { + const value = event.target.value.trim(); + if (prefixEdited.current && (prefixMixed || value !== settings.branchNamePrefix)) + updateSettings({ branchNamePrefix: value }); + prefixEdited.current = false; + }} + /> + } + /> + ) : null} + {!modeMixed && settings.branchNamingMode === "semantic" ? ( +

+ The model chooses a prefix that describes the work, such as feat/add-search, + fix/login-timeout, or refactor/auth. +

+ ) : null} + {!modeMixed && settings.branchNamingMode === "custom" ? ( + updateSettings({ branchNameInstructions: "" })} + /> + ) : null + } + > +
+