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 @@ -462,7 +462,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 @@ -1763,6 +1763,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 @@ -872,8 +860,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 @@ -171,14 +171,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 @@ -848,4 +847,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";
});
});
});
39 changes: 9 additions & 30 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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 @@ -71,8 +76,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}$`);

function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
const error = Cause.squash(cause);
Expand All @@ -90,33 +93,6 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderService
);
}

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 @@ -402,7 +378,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,6 +1,7 @@
import { useCallback, useSyncExternalStore } from "react";
import { Option, Schema } from "effect";
import { type ProviderKind } from "@t3tools/contracts";
import { DEFAULT_WORKTREE_BRANCH_PREFIX, normalizeWorktreeBranchPrefix } from "@t3tools/shared/git";
import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";

const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
Expand All @@ -24,6 +25,9 @@ const AppSettingsSchema = Schema.Struct({
customCodexModels: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some([])),
),
worktreeBranchPrefix: Schema.String.check(Schema.isMaxLength(256)).pipe(
Schema.withConstructorDefault(() => Option.some(DEFAULT_WORKTREE_BRANCH_PREFIX)),
),
});
export type AppSettings = typeof AppSettingsSchema.Type;
export interface AppModelOption {
Expand Down Expand Up @@ -71,6 +75,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {
return {
...settings,
customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
worktreeBranchPrefix: normalizeWorktreeBranchPrefix(settings.worktreeBranchPrefix),
};
}

Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
normalizeModelSlug,
resolveModelSlugForProvider,
} from "@t3tools/shared/model";
import { normalizeWorktreeBranchPrefix } from "@t3tools/shared/git";
import {
memo,
useCallback,
Expand Down Expand Up @@ -264,7 +265,6 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw
const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
const SCRIPT_TERMINAL_COLS = 120;
const SCRIPT_TERMINAL_ROWS = 30;
const WORKTREE_BRANCH_PREFIX = "t3code";

function readLastInvokedScriptByProjectFromStorage(): Record<string, string> {
const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);
Expand Down Expand Up @@ -430,10 +430,10 @@ function readFileAsDataUrl(file: File): Promise<string> {
});
}

function buildTemporaryWorktreeBranchName(): string {
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}`;
}

function cloneComposerImageForRetry(image: ComposerImageAttachment): ComposerImageAttachment {
Expand Down Expand Up @@ -2659,7 +2659,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 @@ -4122,6 +4122,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 @@ -196,13 +196,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