diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 587a3e4abbde..0d3e91e54263 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1283,6 +1283,28 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("matches only colliding refs in collision query mode", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "feature/alpha"]); + yield* git(cwd, ["branch", "unrelated-feature"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const collisions = (query: string) => + driver + .listRefs({ cwd, query, queryMode: "collision", refKind: "local" }) + .pipe(Effect.map((result) => result.refs.map((ref) => ref.name).toSorted())); + + // Exact, plus both halves of a directory/file collision. + assert.deepStrictEqual(yield* collisions("feature/alpha"), ["feature/alpha"]); + assert.deepStrictEqual(yield* collisions("feature"), ["feature/alpha"]); + assert.deepStrictEqual(yield* collisions("feature/alpha/beta"), ["feature/alpha"]); + // A substring match is not a collision. + assert.deepStrictEqual(yield* collisions("feat"), []); + assert.deepStrictEqual(yield* collisions("alpha"), []); + }), + ); + it.effect("marks the origin default ref as default when no local copy exists", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 71e478cbaa3d..e40a46ef571f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -208,14 +208,27 @@ function parsePorcelainPath(line: string): string | null { return filePath.length > 0 ? filePath : null; } +/** + * Refs git refuses to let `name` coexist with: the same ref, one nested under + * it, or one it would nest under (`feat` and `feat/foo` collide either way). + */ +function refCollidesWithName(refName: string, name: string): boolean { + return refName === name || refName.startsWith(`${name}/`) || name.startsWith(`${refName}/`); +} + function filterBranchesForListQuery( refs: ReadonlyArray, query?: string, + queryMode?: "substring" | "collision", ): ReadonlyArray { if (!query) { return refs; } + if (queryMode === "collision") { + return refs.filter((refName) => refCollidesWithName(refName.name, query)); + } + const normalizedQuery = query.toLowerCase(); return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery)); } @@ -2805,7 +2818,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? allBranches.filter((ref) => ref.isRemote) : allBranches; const refs = paginateBranches({ - refs: filterBranchesForListQuery(branchesForKind, input.query), + refs: filterBranchesForListQuery(branchesForKind, input.query, input.queryMode), cursor: input.cursor, limit: input.limit, }); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 5d11cce11fbe..99724fa9bd23 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -28,6 +28,10 @@ import { import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; +import { + BranchToolbarWorktreeNameInput, + type WorktreeBranchNameStatus, +} from "./BranchToolbarWorktreeNameInput"; import { Button } from "./ui/button"; import { Menu, @@ -52,6 +56,7 @@ interface BranchToolbarProps { onActiveThreadBranchOverrideChange?: (branch: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + onWorktreeBranchNameStatusChange?: (status: WorktreeBranchNameStatus | null) => void; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -126,7 +131,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ if (isLocked) { return ( - + {triggerContent} ); @@ -136,7 +141,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ } - className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" + className="min-w-0 max-w-[48%] shrink justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" > {triggerContent} @@ -384,6 +389,7 @@ export const BranchToolbar = memo(function BranchToolbar({ onActiveThreadBranchOverrideChange, startFromOrigin, onStartFromOriginChange, + onWorktreeBranchNameStatusChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, @@ -463,8 +469,40 @@ export const BranchToolbar = memo(function BranchToolbar({ const [stripElement, setStripElement] = useState(null); const labelsOverflow = useLabelsOverflow(stripElement); + // Naming the next worktree's branch only makes sense before the first send, + // when a brand-new worktree (not a reused one) is about to be created. + const showWorktreeNameInput = + showGitControls && + serverThread === null && + draftThread !== null && + effectiveEnvMode === "worktree" && + activeWorktreePath === null && + !envLocked; + const onWorktreeBranchNameChange = useCallback( + (value: string) => { + setDraftThreadContext(draftId ?? threadRef, { + worktreeBranchName: value.length > 0 ? value : null, + }); + }, + [draftId, setDraftThreadContext, threadRef], + ); + if (!hasActiveThread || !activeProject) return null; + // Rendered in both layouts: a stored name the user can't see is a name they + // can't clear, and the send path ignores it while the input is unmounted. + const worktreeNameInput = showWorktreeNameInput ? ( + + ) : null; + return (
{isMobile && showGitControls ? ( - + <> + + {worktreeNameInput} + ) : (
{showEnvironmentIndicator && availableEnvironments && ( @@ -515,12 +556,13 @@ export const BranchToolbar = memo(function BranchToolbar({ onUsePreviousWorktree={onUsePreviousWorktree} /> ) : null} + {worktreeNameInput}
)} {showGitControls ? ( void; + onStatusChange?: (status: WorktreeBranchNameStatus | null) => void; +} + +/** + * Low-profile input naming the branch the next worktree is created with. + * Left empty, the branch name is generated from the first message instead. + * Marks itself invalid when the name collides with an existing local branch. + */ +export function BranchToolbarWorktreeNameInput({ + environmentId, + cwd, + value, + onValueChange, + onStatusChange, +}: BranchToolbarWorktreeNameInputProps) { + const normalizedValue = normalizeWorktreeBranchName(value); + const deferredNormalizedValue = useDeferredValue(normalizedValue); + const conflictRefsQuery = useEnvironmentQuery( + deferredNormalizedValue === null + ? null + : vcsEnvironment.listRefs({ + environmentId, + input: { + cwd, + query: deferredNormalizedValue, + // `collision` makes the server answer over every local ref, so + // `totalCount` is the whole answer and paging can't hide a match. + // Locals only — a remote ref can't block worktree creation. + queryMode: "collision", + refKind: "local", + limit: 1, + }, + }), + ); + // Only a settled lookup for the value currently typed can clear a name for + // send; while the deferred value lags or the query is in flight the answer + // belongs to a different name. A failed lookup counts as settled — the + // server rejects a duplicate branch anyway. + const checked = + deferredNormalizedValue === normalizedValue && + (conflictRefsQuery.data !== null || conflictRefsQuery.error !== null); + const conflict = checked && (conflictRefsQuery.data?.totalCount ?? 0) > 0; + // The collision may be a parent or child of the typed name rather than the + // name itself, so the message names the ref actually in the way. + const conflictingRef = conflict ? (conflictRefsQuery.data?.refs[0]?.name ?? null) : null; + const conflictMessage = + conflictingRef === null + ? null + : conflictingRef === normalizedValue + ? `Branch "${conflictingRef}" already exists.` + : `Branch "${conflictingRef}" already exists, so "${normalizedValue}" can't be created.`; + const state = checked ? (conflict ? "conflict" : "available") : "checking"; + + const onStatusChangeRef = useRef(onStatusChange); + onStatusChangeRef.current = onStatusChange; + useEffect(() => { + onStatusChangeRef.current?.(normalizedValue === null ? null : { name: normalizedValue, state }); + }, [normalizedValue, state]); + // The send gate must not outlive the input (e.g. switching back to + // "Current checkout"), or it would block sends it no longer applies to. + useEffect(() => () => onStatusChangeRef.current?.(null), []); + + // The tooltip stays mounted and merely disabled so a collision appearing + // mid-keystroke can't remount the input out from under the caret. + return ( + + onValueChange(sanitizeWorktreeBranchNameInput(event.target.value))} + placeholder="custom branch name" + spellCheck={false} + autoComplete="off" + aria-label="Branch name for the new worktree" + data-composer-context-control + aria-invalid={conflict || undefined} + className={cn( + "h-7 w-44 min-w-0 shrink rounded-md bg-transparent px-2 font-mono text-xs outline-none transition-colors sm:h-6", + "placeholder:font-sans placeholder:text-muted-foreground/50", + "hover:bg-muted/40 focus:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring", + conflict ? "text-destructive" : "text-muted-foreground/70 focus:text-foreground/80", + )} + /> + } + /> + {conflictMessage} + + ); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0188af478c0..f0894836b529 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -134,7 +134,7 @@ import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; -import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { buildTemporaryWorktreeBranchName, normalizeWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { @@ -173,6 +173,7 @@ import { } from "@t3tools/client-runtime/state/subagentRuntime"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; +import type { WorktreeBranchNameStatus } from "./BranchToolbarWorktreeNameInput"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { @@ -1478,6 +1479,8 @@ function ChatViewContent(props: ChatViewProps) { pendingServerThreadStartFromOriginByThreadId, setPendingServerThreadStartFromOriginByThreadId, ] = useState>({}); + const [worktreeBranchNameStatus, setWorktreeBranchNameStatus] = + useState(null); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, @@ -4364,6 +4367,15 @@ function ChatViewContent(props: ChatViewProps) { ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? primaryServerSettings.newWorktreesStartFromOrigin) : false; + const draftWorktreeBranchName = isLocalDraftThread + ? normalizeWorktreeBranchName(draftThread?.worktreeBranchName ?? "") + : null; + // Only a name the toolbar input is currently showing (and has checked for + // conflicts) can name the worktree branch; otherwise it stays generated. + const customWorktreeBranchName = + draftWorktreeBranchName !== null && worktreeBranchNameStatus?.name === draftWorktreeBranchName + ? draftWorktreeBranchName + : null; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, isGitRepo, @@ -5605,6 +5617,27 @@ function ChatViewContent(props: ChatViewProps) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } + // A typed name is never silently dropped: sending waits for its conflict + // lookup to settle instead of trusting the last reported answer. + if (shouldCreateWorktree && draftWorktreeBranchName !== null && worktreeBranchNameStatus) { + if ( + worktreeBranchNameStatus.name !== draftWorktreeBranchName || + worktreeBranchNameStatus.state === "checking" + ) { + setThreadError( + threadIdForSend, + `Still checking whether branch "${draftWorktreeBranchName}" is available. Try again in a moment.`, + ); + return; + } + if (worktreeBranchNameStatus.state === "conflict") { + setThreadError( + threadIdForSend, + `Branch "${draftWorktreeBranchName}" collides with an existing branch. Pick a different worktree branch name.`, + ); + return; + } + } const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; @@ -5840,7 +5873,10 @@ function ChatViewContent(props: ChatViewProps) { prepareWorktree: { projectCwd: activeProject.workspaceRoot, baseBranch: baseBranchForWorktree, - branch: buildTemporaryWorktreeBranchName(randomHex), + // A custom name skips the server's LLM branch naming: + // only temporary-pattern branches get renamed. + branch: + customWorktreeBranchName ?? buildTemporaryWorktreeBranchName(randomHex), ...(startFromOrigin ? { startFromOrigin: true } : {}), }, runSetupScript: true, @@ -7125,6 +7161,7 @@ function ChatViewContent(props: ChatViewProps) { onEnvModeChange={onEnvModeChange} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} + onWorktreeBranchNameStatusChange={setWorktreeBranchNameStatus} {...(canOverrideServerThreadEnvMode ? { effectiveEnvModeOverride: envMode } : {})} diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f20385ee04f4..dc5cf7fa553b 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -217,6 +217,9 @@ const PersistedDraftThreadState = Schema.Struct({ worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + worktreeBranchName: Schema.NullOr(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -321,6 +324,8 @@ export interface DraftSessionState { worktreePath: string | null; envMode: DraftThreadEnvMode; startFromOrigin: boolean; + /** User-typed branch name for the next worktree; null falls back to the generated name. */ + worktreeBranchName: string | null; promotedTo?: ScopedThreadRef | null; } @@ -384,6 +389,7 @@ interface ComposerDraftStoreState { createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + worktreeBranchName?: string | null; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -399,6 +405,7 @@ interface ComposerDraftStoreState { createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + worktreeBranchName?: string | null; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -413,6 +420,7 @@ interface ComposerDraftStoreState { createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + worktreeBranchName?: string | null; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1372,6 +1380,7 @@ function createDraftThreadState( createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; + worktreeBranchName?: string | null; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1400,6 +1409,10 @@ function createDraftThreadState( options?.startFromOrigin === undefined ? (existingThread?.startFromOrigin ?? false) : options.startFromOrigin; + const nextWorktreeBranchName = + options?.worktreeBranchName === undefined + ? (existingThread?.worktreeBranchName ?? null) + : options.worktreeBranchName; return { threadId, environmentId: projectRef.environmentId, @@ -1414,6 +1427,7 @@ function createDraftThreadState( envMode: options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + worktreeBranchName: nextWorktreeBranchName, promotedTo: null, }; } @@ -1446,6 +1460,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.worktreePath === right.worktreePath && left.envMode === right.envMode && left.startFromOrigin === right.startFromOrigin && + left.worktreeBranchName === right.worktreeBranchName && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) ); } @@ -1541,6 +1556,11 @@ function normalizePersistedDraftThreads( const branch = candidateDraftThread.branch; const worktreePath = candidateDraftThread.worktreePath; const startFromOrigin = candidateDraftThread.startFromOrigin === true; + const worktreeBranchName = + typeof candidateDraftThread.worktreeBranchName === "string" && + candidateDraftThread.worktreeBranchName.length > 0 + ? candidateDraftThread.worktreeBranchName + : null; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; const promotedToCandidate = candidateDraftThread.promotedTo; const promotedToRecord = @@ -1589,6 +1609,7 @@ function normalizePersistedDraftThreads( worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), startFromOrigin, + worktreeBranchName, promotedTo, }; } @@ -1635,6 +1656,7 @@ function normalizePersistedDraftThreads( worktreePath: null, envMode: "local", startFromOrigin: false, + worktreeBranchName: null, promotedTo: null, }; } else if ( @@ -2238,6 +2260,7 @@ function toHydratedDraftThreadState( worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, startFromOrigin: persistedDraftThread.startFromOrigin, + worktreeBranchName: persistedDraftThread.worktreeBranchName, promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2457,6 +2480,10 @@ const composerDraftStore = create()( options.startFromOrigin === undefined ? existing.startFromOrigin : options.startFromOrigin; + const nextWorktreeBranchName = + options.worktreeBranchName === undefined + ? existing.worktreeBranchName + : options.worktreeBranchName; const nextDraftThread: DraftThreadState = { threadId: existing.threadId, environmentId: nextProjectRef.environmentId, @@ -2473,6 +2500,7 @@ const composerDraftStore = create()( envMode: options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + worktreeBranchName: nextWorktreeBranchName, promotedTo: existing.promotedTo ?? null, }; const isUnchanged = @@ -2486,6 +2514,7 @@ const composerDraftStore = create()( nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && nextDraftThread.startFromOrigin === existing.startFromOrigin && + nextDraftThread.worktreeBranchName === existing.worktreeBranchName && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); if (isUnchanged) { return state; diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 915c3627c9b9..70eb8736fa25 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -4,7 +4,7 @@ import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceC import { VcsDriverKind } from "./vcs.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; -const GIT_LIST_BRANCHES_MAX_LIMIT = 200; +export const GIT_LIST_BRANCHES_MAX_LIMIT = 200; // Domain Types @@ -127,6 +127,7 @@ export const VcsListRefsInput = Schema.Struct({ cursor: Schema.optional(NonNegativeInt), includeMatchingRemoteRefs: Schema.optional(Schema.Boolean), refKind: Schema.optional(Schema.Literals(["all", "local", "remote"])), + queryMode: Schema.optional(Schema.Literals(["substring", "collision"])), refresh: Schema.optional(Schema.Boolean), limit: Schema.optional( PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)), diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 8dea20f0b423..6218ddc6a0ad 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -6,7 +6,9 @@ import { buildTemporaryWorktreeBranchName, isTemporaryWorktreeBranch, normalizeGitRemoteUrl, + normalizeWorktreeBranchName, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, + sanitizeWorktreeBranchNameInput, WORKTREE_BRANCH_PREFIX, } from "./git.ts"; @@ -172,3 +174,62 @@ describe("applyGitStatusStreamEvent", () => { }); }); }); + +describe("sanitizeWorktreeBranchNameInput", () => { + it("preserves case and dashes invalid characters while typing", () => { + expect(sanitizeWorktreeBranchNameInput("Feat/My Fix!")).toBe("Feat/My-Fix-"); + }); + + it("allows dots but blocks the ref shapes git refuses", () => { + expect(sanitizeWorktreeBranchNameInput("release/v1.2")).toBe("release/v1.2"); + // ".." is invalid anywhere in a ref. + expect(sanitizeWorktreeBranchNameInput("a..b")).toBe("a.b"); + // No component can begin with a dot. + expect(sanitizeWorktreeBranchNameInput(".hidden")).toBe("hidden"); + expect(sanitizeWorktreeBranchNameInput("feat/.hidden")).toBe("feat/hidden"); + }); + + it("keeps edge separators so slashes and dashes can be typed mid-name", () => { + expect(sanitizeWorktreeBranchNameInput("feat/")).toBe("feat/"); + expect(sanitizeWorktreeBranchNameInput("feat-")).toBe("feat-"); + }); + + it("collapses repeated separators and strips quotes", () => { + expect(sanitizeWorktreeBranchNameInput(`fe"at//my--fix`)).toBe("feat/my-fix"); + }); + + it("caps the length at 64 characters", () => { + expect(sanitizeWorktreeBranchNameInput("a".repeat(80))).toHaveLength(64); + }); +}); + +describe("normalizeWorktreeBranchName", () => { + it("trims edge separators from the final name", () => { + expect(normalizeWorktreeBranchName("feat/my-fix-")).toBe("feat/my-fix"); + expect(normalizeWorktreeBranchName("/feat/my-fix")).toBe("feat/my-fix"); + // A ref cannot end with a dot. + expect(normalizeWorktreeBranchName("release/v1.")).toBe("release/v1"); + }); + + it("drops the .lock suffixes git refuses on a ref component", () => { + expect(normalizeWorktreeBranchName("feature.lock")).toBe("feature"); + expect(normalizeWorktreeBranchName("team.lock/topic")).toBe("team/topic"); + expect(normalizeWorktreeBranchName("feat/my-fix.lock.lock")).toBe("feat/my-fix"); + expect(normalizeWorktreeBranchName("feat/my-fix.lock-")).toBe("feat/my-fix"); + // Only a trailing ".lock" is invalid; mid-name is fine. + expect(normalizeWorktreeBranchName("feat/my.locked-fix")).toBe("feat/my.locked-fix"); + }); + + it("returns null when nothing usable remains", () => { + expect(normalizeWorktreeBranchName("")).toBeNull(); + expect(normalizeWorktreeBranchName(" -/- ")).toBeNull(); + expect(normalizeWorktreeBranchName("///")).toBeNull(); + }); + + it("passes through an already-valid name unchanged", () => { + expect(normalizeWorktreeBranchName("feat/custom-worktree-location")).toBe( + "feat/custom-worktree-location", + ); + expect(normalizeWorktreeBranchName("Feat/JIRA-123_v1.2")).toBe("Feat/JIRA-123_v1.2"); + }); +}); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 7c088970d583..25562f590db3 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -108,6 +108,37 @@ export function isTemporaryWorktreeBranch(refName: string): boolean { return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); } +/** + * Live per-keystroke sanitizer for a user-typed worktree branch name. Unlike + * `sanitizeBranchFragment` this preserves case — a hand-typed name is used + * verbatim — and only blocks what git itself refuses in a ref, while keeping + * edge separators so slashes and dashes can still be typed mid-name. + */ +export function sanitizeWorktreeBranchNameInput(raw: string): string { + return raw + .replace(/['"`]/g, "") + .replace(/[^A-Za-z0-9./_-]+/g, "-") + .replace(/(^|\/)\.+/g, "$1") + .replace(/\.{2,}/g, ".") + .replace(/\/+/g, "/") + .replace(/-+/g, "-") + .slice(0, 64); +} + +/** + * Final form of a user-typed worktree branch name: live sanitization, edge + * trimming, and dropping the `.lock` suffixes git refuses on any ref + * component. Null when nothing usable remains. + */ +export function normalizeWorktreeBranchName(raw: string): string | null { + const normalized = sanitizeWorktreeBranchNameInput(raw) + .split("/") + .map((component) => component.replace(/^[._-]+|(?:\.lock)*[._-]*$/g, "")) + .filter((component) => component.length > 0) + .join("/"); + return normalized.length > 0 ? normalized : null; +} + /** * Normalize a git remote URL into a stable comparison key. */