diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 77022a8518d7f..1e0fa7d712ec3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -15,7 +15,7 @@ import { } from "@t3tools/contracts"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; -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"; @@ -187,29 +187,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; @@ -943,7 +920,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 }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2b6f0f961a5c5..80ee6d747fbbe 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -629,6 +629,9 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? ["New worktrees start from origin"] : []), + ...(settings.worktreeBranchPrefix !== DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix + ? ["Branch prefix"] + : []), ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), @@ -674,6 +677,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, + settings.worktreeBranchPrefix, settings.diffFilesCollapsed, settings.diffIgnoreWhitespace, settings.diffLayout, @@ -808,6 +812,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, @@ -2233,6 +2238,7 @@ export function GeneralSettingsPanel() { const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings); const mixedBackgroundActivity = useScopedSettingsMixed(["backgroundActivity"]); const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]); + const mixedWorktreeBranchPrefix = useScopedSettingsMixed(["worktreeBranchPrefix"]); const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]); const backgroundActivityDescription = backgroundActivityProfileOption === "advanced" @@ -3049,6 +3055,35 @@ export function GeneralSettingsPanel() { /> } /> + + updateSettings({ + worktreeBranchPrefix: DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix, + }) + } + /> + ) : null + } + control={ + updateSettings({ worktreeBranchPrefix: next })} + placeholder={mixedWorktreeBranchPrefix ? "Mixed" : "No prefix"} + spellCheck={false} + aria-label="Branch prefix" + /> + } + /> ` placeholder branch a worktree starts on is unchanged. + +Code: `buildGeneratedWorktreeBranchName` in `packages/shared/src/git.ts` and `worktreeBranchPrefix` +in `packages/contracts/src/settings.ts`. User guide: +[project-settings.md](./user/project-settings.md#defaults-and-inheritance). + ## Desktop mock-update loop A `Makefile` at the repository root drives a local auto-update test loop for the desktop app: diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 086e3b013d5a3..651b1c60b8494 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -62,6 +62,11 @@ itself, or **Skip** to leave them for a setup script. It resolves in the same or workspace default: a `"worktreeSubmodules"` value in the `t3.json` of the branch being checked out applies when the project and environment are both on **Inherit**. +Worktree branches are renamed after the first message, under `t3code/` by default. Change +**Branch prefix** in **Settings → General** to use your own namespace, such as `adam`, or clear it +to get bare branch names. Until the rename, a new worktree briefly sits on a `t3code/` +placeholder branch. + ## Storage cleanup Open **Settings → Storage** to enable automatic cleanup on one machine or all connected diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 787c046a47759..85d13445985dc 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1113,6 +1113,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "defaultRuntimeMode", "defaultThreadEnvMode", "newWorktreesStartFromOrigin", + "worktreeBranchPrefix", "worktreeSubmodules", "defaultAutoPull", "defaultProjectScripts", @@ -1140,6 +1141,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ defaultRuntimeMode: Schema.optionalKey(RuntimeMode), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + worktreeBranchPrefix: Schema.optionalKey(TrimmedString), worktreeSubmodules: ForwardCompatibleOptional(WorktreeSubmodules), defaultAutoPull: Schema.optionalKey(Schema.Boolean), defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), @@ -1323,6 +1325,8 @@ export const ServerSettings = Schema.Struct({ newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + /** Namespace for generated worktree branch names. Empty means no prefix. */ + worktreeBranchPrefix: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("t3code"))), /** * Null defers to the repository's t3.json, then to recursive. A value * picked on a newer server decodes as null here rather than failing the @@ -1649,6 +1653,7 @@ export const ServerSettingsPatch = Schema.Struct({ environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + worktreeBranchPrefix: Schema.optionalKey(TrimmedString), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index ce0defd0587b3..b2e22b326d1cb 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { applyGitStatusStreamEvent, + buildGeneratedWorktreeBranchName, buildTemporaryWorktreeBranchName, isTemporaryWorktreeBranch, normalizeGitRemoteUrl, @@ -212,6 +213,30 @@ describe("isTemporaryWorktreeBranch", () => { }); }); +describe("buildGeneratedWorktreeBranchName", () => { + it("namespaces the generated name under the configured prefix", () => { + expect(buildGeneratedWorktreeBranchName("Fix Reconnect Backoff", "t3code")).toBe( + "t3code/fix-reconnect-backoff", + ); + expect(buildGeneratedWorktreeBranchName("fix/reconnect", "adam/")).toBe("adam/fix/reconnect"); + }); + + it("does not double a prefix the generator already included", () => { + expect(buildGeneratedWorktreeBranchName("refs/heads/adam/fix-login", "adam")).toBe( + "adam/fix-login", + ); + }); + + it("drops the namespace when the prefix is empty", () => { + expect(buildGeneratedWorktreeBranchName("feature/fix-login", "")).toBe("feature/fix-login"); + expect(buildGeneratedWorktreeBranchName("feature/fix-login", " / ")).toBe("feature/fix-login"); + }); + + it("sanitizes an invalid prefix into a valid ref fragment", () => { + expect(buildGeneratedWorktreeBranchName("fix", "My Team..")).toBe("my-team/fix"); + }); +}); + describe("applyGitStatusStreamEvent", () => { it("treats a remote-only update as a repository when local state is missing", () => { const remote: VcsStatusRemoteResult = { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 61d1831417975..c9cc610d0fa82 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -104,6 +104,42 @@ export function buildTemporaryWorktreeBranchName( return `${WORKTREE_BRANCH_PREFIX}/${token}`; } +/** + * The branch a thread's temporary worktree branch is renamed to once a name has + * been generated. `prefix` is the user's branch prefix setting; an empty prefix + * yields a bare branch name. + */ +export function buildGeneratedWorktreeBranchName(raw: string, prefix: string): string { + const namespace = prefix + .trim() + .toLowerCase() + .replace(/[^a-z0-9/_-]+/g, "-") + .replace(/\/+/g, "/") + .replace(/^[./_-]+|[./_-]+$/g, ""); + + const normalized = raw + .trim() + .toLowerCase() + .replace(/^refs\/heads\//, "") + .replace(/['"`]/g, ""); + + const withoutPrefix = + namespace.length > 0 && normalized.startsWith(`${namespace}/`) + ? normalized.slice(`${namespace}/`.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 namespace.length > 0 ? `${namespace}/${safeFragment}` : safeFragment; +} + export function isTemporaryWorktreeBranch(refName: string): boolean { return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); }