Skip to content
Open
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
55 changes: 54 additions & 1 deletion apps/server/src/git/Layers/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; refe

function preparePullRequestThread(
manager: GitManagerShape,
input: { cwd: string; reference: string; mode: "local" | "worktree" },
input: { cwd: string; reference: string; mode: "local" | "worktree"; branchPrefix?: string },
) {
return manager.preparePullRequestThread(input);
}
Expand Down Expand Up @@ -1789,6 +1789,59 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("uses a custom prefix for cross-repo PR worktree branches", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const originDir = yield* createBareRemote();
const forkDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
fs.writeFileSync(path.join(repoDir, "fork-custom-prefix.txt"), "fork custom prefix\n");
yield* runGit(repoDir, ["add", "fork-custom-prefix.txt"]);
yield* runGit(repoDir, ["commit", "-m", "Fork custom prefix branch"]);
yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
yield* runGit(repoDir, ["checkout", "main"]);

const { manager } = yield* makeManager({
ghScenario: {
pullRequest: {
number: 93,
title: "Fork main custom prefix PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/93",
baseRefName: "main",
headRefName: "main",
state: "open",
isCrossRepository: true,
headRepositoryNameWithOwner: "octocat/codething-mvp",
headRepositoryOwnerLogin: "octocat",
},
repositoryCloneUrls: {
"octocat/codething-mvp": {
url: forkDir,
sshUrl: forkDir,
},
},
},
});

const result = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "93",
mode: "worktree",
branchPrefix: "custom/team",
});

expect(result.branch).toBe("custom/team/pr-93/main");
expect(result.worktreePath).not.toBeNull();
expect(
(yield* runGit(result.worktreePath as string, ["branch", "--show-current"])).stdout.trim(),
).toBe("custom/team/pr-93/main");
}),
);

it.effect(
"does not overwrite an existing local main branch when preparing a fork PR worktree",
() =>
Expand Down
24 changes: 9 additions & 15 deletions apps/server/src/git/Layers/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { realpathSync } from "node:fs";
import { Effect, FileSystem, Layer, Path } from "effect";
import {
resolveAutoFeatureBranchName,
sanitizeBranchFragment,
resolvePullRequestWorktreeLocalBranchName,
sanitizeFeatureBranchName,
} from "@t3tools/shared/git";

Expand Down Expand Up @@ -81,18 +81,6 @@ function resolveHeadRepositoryNameWithOwner(
return `${ownerLogin}/${repositoryName}`;
}

function resolvePullRequestWorktreeLocalBranchName(
pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
): string {
if (!pullRequest.isCrossRepository) {
return pullRequest.headBranch;
}

const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim();
const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head";
return `t3code/pr-${pullRequest.number}/${suffix}`;
}

function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {
const trimmed = url?.trim() ?? "";
if (trimmed.length === 0) {
Expand Down Expand Up @@ -880,8 +868,14 @@ export const makeGitManager = Effect.gen(function* () {
...pullRequest,
...toPullRequestHeadRemoteInfo(pullRequestSummary),
} as const;
const localPullRequestBranch =
resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
const localPullRequestBranch = resolvePullRequestWorktreeLocalBranchName({
number: pullRequestWithRemoteInfo.number,
headBranch: pullRequestWithRemoteInfo.headBranch,
...(pullRequestWithRemoteInfo.isCrossRepository !== undefined
? { isCrossRepository: pullRequestWithRemoteInfo.isCrossRepository }
: {}),
...(input.branchPrefix ? { branchPrefix: input.branchPrefix } : {}),
});

const findLocalHeadBranch = (cwd: string) =>
gitCore.listBranches({ cwd }).pipe(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,14 +175,13 @@ describe("ProviderCommandReactor", () => {
: "renamed-branch",
}),
);
const generateBranchName = vi.fn(() =>
const generateBranchName = vi.fn((() =>
Effect.fail(
new TextGenerationError({
operation: "generateBranchName",
detail: "disabled in test harness",
}),
),
);
)) as TextGenerationShape["generateBranchName"]);

const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
const service: ProviderServiceShape = {
Expand Down Expand Up @@ -1299,4 +1298,56 @@ describe("ProviderCommandReactor", () => {
expect(thread?.session?.threadId).toBe("thread-1");
expect(thread?.session?.activeTurnId).toBeNull();
});

it("preserves a custom temporary worktree prefix when renaming the first-turn branch", async () => {
const harness = await createHarness();
const now = new Date().toISOString();

harness.generateBranchName.mockImplementation(() =>
Effect.succeed({
branch: "feat/refine-toolbar",
}),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.makeUnsafe("cmd-thread-meta-custom-prefix"),
threadId: ThreadId.makeUnsafe("thread-1"),
branch: "custom/team/1a2b3c4d",
worktreePath: "/tmp/provider-project-worktree",
}),
);

await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.makeUnsafe("cmd-turn-start-custom-prefix"),
threadId: ThreadId.makeUnsafe("thread-1"),
message: {
messageId: asMessageId("user-message-custom-prefix"),
role: "user",
text: "refine the branch toolbar interactions",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);

await waitFor(() => harness.renameBranch.mock.calls.length === 1);
expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({
oldBranch: "custom/team/1a2b3c4d",
newBranch: "custom/team/feat/refine-toolbar",
});

await waitFor(async () => {
const readModel = await Effect.runPromise(harness.engine.getReadModel());
const thread = readModel.threads.find(
(entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
);
return thread?.branch === "custom/team/feat/refine-toolbar";
});
});
});
40 changes: 9 additions & 31 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import {
} from "@t3tools/contracts";
import { Cache, Cause, Duration, Effect, Layer, Option, Schema, Stream } from "effect";
import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
import {
buildGeneratedWorktreeBranchName,
extractTemporaryWorktreeBranchPrefix,
isTemporaryWorktreeBranch,
} from "@t3tools/shared/git";

import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
import { GitCore } from "../../git/Services/GitCore.ts";
Expand Down Expand Up @@ -73,8 +78,6 @@ const serverCommandId = (tag: string): CommandId =>
const HANDLED_TURN_START_KEY_MAX = 10_000;
const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
const WORKTREE_BRANCH_PREFIX = "t3code";
const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);

const sameModelOptions = (
left: ProviderModelOptions | undefined,
Expand Down Expand Up @@ -111,34 +114,6 @@ function stalePendingRequestDetail(
): string {
return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`;
}

function isTemporaryWorktreeBranch(branch: string): boolean {
return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase());
}

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 orchestrationEngine = yield* OrchestrationEngineService;
const providerService = yield* ProviderService;
Expand Down Expand Up @@ -461,7 +436,10 @@ const make = Effect.gen(function* () {
Effect.flatMap((generated) => {
if (!generated) return Effect.void;

const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
const targetBranch = buildGeneratedWorktreeBranchName(
generated.branch,
extractTemporaryWorktreeBranchPrefix(oldBranch),
);
if (targetBranch === oldBranch) return Effect.void;

return Effect.flatMap(
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { Option, Schema } from "effect";
import { DEFAULT_WORKTREE_BRANCH_PREFIX, normalizeWorktreeBranchPrefix } from "@t3tools/shared/git";
import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts";
import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
import { useLocalStorage } from "./hooks/useLocalStorage";
Expand Down Expand Up @@ -41,6 +42,9 @@ export const AppSettingsSchema = Schema.Struct({
customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])),
textGenerationModel: Schema.optional(TrimmedNonEmptyString),
worktreeBranchPrefix: Schema.String.check(Schema.isMaxLength(256)).pipe(
withDefaults(() => DEFAULT_WORKTREE_BRANCH_PREFIX),
),
});
export type AppSettings = typeof AppSettingsSchema.Type;
export interface AppModelOption {
Expand Down Expand Up @@ -85,6 +89,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {
...settings,
customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"),
worktreeBranchPrefix: normalizeWorktreeBranchPrefix(settings.worktreeBranchPrefix),
};
}
export function getAppModelOptions(
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts";
import { normalizeWorktreeBranchPrefix } from "@t3tools/shared/git";
import { type ChatMessage, type Thread } from "../types";
import { randomUUID } from "~/lib/utils";
import { getAppModelOptions } from "../appSettings";
Expand All @@ -11,7 +12,6 @@ import {
} from "../lib/terminalContext";

export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
const WORKTREE_BRANCH_PREFIX = "t3code";

export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);

Expand Down Expand Up @@ -99,10 +99,10 @@ export function readFileAsDataUrl(file: File): Promise<string> {
});
}

export function buildTemporaryWorktreeBranchName(): string {
export function buildTemporaryWorktreeBranchName(prefix: string): string {
// Keep the 8-hex suffix shape for backend temporary-branch detection.
const token = randomUUID().slice(0, 8).toLowerCase();
return `${WORKTREE_BRANCH_PREFIX}/${token}`;
return `${normalizeWorktreeBranchPrefix(prefix)}/${token}`;
}

export function cloneComposerImageForRetry(
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2543,7 +2543,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
// On first message: lock in branch + create worktree if needed.
if (baseBranchForWorktree) {
beginSendPhase("preparing-worktree");
const newBranch = buildTemporaryWorktreeBranchName();
const newBranch = buildTemporaryWorktreeBranchName(settings.worktreeBranchPrefix);
const result = await createWorktreeMutation.mutateAsync({
cwd: activeProject.cwd,
branch: baseBranchForWorktree,
Expand Down Expand Up @@ -4123,6 +4123,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
key={pullRequestDialogState.key}
open
cwd={activeProject?.cwd ?? null}
branchPrefix={settings.worktreeBranchPrefix}
initialReference={pullRequestDialogState.initialReference}
onOpenChange={(open) => {
if (!open) {
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/PullRequestThreadDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Spinner } from "./ui/spinner";
interface PullRequestThreadDialogProps {
open: boolean;
cwd: string | null;
branchPrefix: string;
initialReference: string | null;
onOpenChange: (open: boolean) => void;
onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise<void> | void;
Expand All @@ -33,6 +34,7 @@ interface PullRequestThreadDialogProps {
export function PullRequestThreadDialog({
open,
cwd,
branchPrefix,
initialReference,
onOpenChange,
onPrepared,
Expand Down Expand Up @@ -130,6 +132,7 @@ export function PullRequestThreadDialog({
const result = await preparePullRequestThreadMutation.mutateAsync({
reference: parsedReference,
mode,
branchPrefix,
});
await onPrepared({
branch: result.branch,
Expand All @@ -141,6 +144,7 @@ export function PullRequestThreadDialog({
}
},
[
branchPrefix,
cwd,
onOpenChange,
onPrepared,
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/lib/gitReactQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,22 @@ export function gitPreparePullRequestThreadMutationOptions(input: {
queryClient: QueryClient;
}) {
return mutationOptions({
mutationFn: async ({ reference, mode }: { reference: string; mode: "local" | "worktree" }) => {
mutationFn: async ({
reference,
mode,
branchPrefix,
}: {
reference: string;
mode: "local" | "worktree";
branchPrefix?: string;
}) => {
const api = ensureNativeApi();
if (!input.cwd) throw new Error("Pull request thread preparation is unavailable.");
return api.git.preparePullRequestThread({
cwd: input.cwd,
reference,
mode,
...(branchPrefix ? { branchPrefix } : {}),
});
},
mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd),
Expand Down
Loading