Skip to content
Merged
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
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 @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
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 @@ -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"]
: []),
Expand Down Expand Up @@ -674,6 +677,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.addProjectBaseDirectory,
settings.defaultThreadEnvMode,
settings.newWorktreesStartFromOrigin,
settings.worktreeBranchPrefix,
settings.diffFilesCollapsed,
settings.diffIgnoreWhitespace,
settings.diffLayout,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -3049,6 +3055,35 @@ export function GeneralSettingsPanel() {
/>
}
/>
<SettingsRow
serverScoped
settingKeys={["worktreeBranchPrefix"]}
{...searchableSetting("worktree-branch-prefix")}
description="Prepended to generated worktree branch names. Leave empty for no prefix."
resetAction={
settings.worktreeBranchPrefix !== DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix ? (
<SettingResetButton
label="branch prefix"
onClick={() =>
updateSettings({
worktreeBranchPrefix: DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix,
})
}
/>
) : null
}
control={
<DraftInput
size="sm"
className="w-full sm:w-72"
value={mixedWorktreeBranchPrefix ? "" : settings.worktreeBranchPrefix}
onCommit={(next) => updateSettings({ worktreeBranchPrefix: next })}
placeholder={mixedWorktreeBranchPrefix ? "Mixed" : "No prefix"}
spellCheck={false}
aria-label="Branch prefix"
/>
}
/>
<SettingsRow
serverScoped
settingKeys={["addProjectBaseDirectory"]}
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,13 @@ export const SETTINGS_SEARCH_ITEMS = [
scope: "project-defaults",
searchTerms: ["new worktrees latest matching remote branch local"],
},
{
id: "worktree-branch-prefix",
title: "Branch prefix",
to: "/settings/general",
scope: "project-defaults",
searchTerms: ["worktree branch name prefix namespace t3code git"],
},
{
id: "add-project-starts-in",
title: "Add project starts in",
Expand Down
10 changes: 10 additions & 0 deletions docs/fork-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ Code: `apps/server/src/storageCleanup.ts` and
`apps/web/src/components/settings/StorageSettings.tsx`. User guide:
[project-settings.md](./user/project-settings.md#storage-cleanup).

## Worktree branch prefix

Generated worktree branch names use a configurable prefix instead of a fixed `t3code/`.
**Settings → General → Branch prefix** takes any namespace, or none, and projects can override it.
The short-lived `t3code/<id>` 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:
Expand Down
5 changes: 5 additions & 0 deletions docs/user/project-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>`
placeholder branch.

## Storage cleanup

Open **Settings → Storage** to enable automatic cleanup on one machine or all connected
Expand Down
5 changes: 5 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
"defaultRuntimeMode",
"defaultThreadEnvMode",
"newWorktreesStartFromOrigin",
"worktreeBranchPrefix",
"worktreeSubmodules",
"defaultAutoPull",
"defaultProjectScripts",
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
25 changes: 25 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 @@ -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 = {
Expand Down
36 changes: 36 additions & 0 deletions packages/shared/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down