diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts index 79b3ca4a9..b54b1bd99 100644 --- a/packages/server/src/server/agent/agent-response-loop.ts +++ b/packages/server/src/server/agent/agent-response-loop.ts @@ -96,7 +96,8 @@ export interface StructuredAgentGenerationWithFallbackOptions { export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [ { provider: "claude", model: "haiku" }, { provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" }, - { provider: "opencode", model: "opencode/gpt-5-nano" }, + { provider: "opencode", model: "opencode/minimax-m2.5-free" }, + { provider: "opencode", model: "opencode/nemotron-3-super-free" }, ] as const; interface SchemaValidator { diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 75efdf919..df7350287 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -173,6 +173,44 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex expect(branchAfterSecond).toBe("renamed-from-agent-context"); }); +test("falls back to a numeric suffix when the desired branch name already exists", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + + execFileSync("git", ["branch", "renamed-from-agent-context"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "renamed-from-agent-context-2"], { cwd: repoDir, stdio: "pipe" }); + + const created = await createPaseoWorktree( + { + cwd: repoDir, + worktreeSlug: "dazzling-yak", + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + createDeps(), + ); + + const result = await attemptFirstAgentBranchAutoName({ + cwd: created.worktree.worktreePath, + firstAgentContext: { prompt: "Build the agent context name" }, + generateBranchNameFromContext: async () => "renamed-from-agent-context", + }); + + expect(result).toEqual({ + attempted: true, + renamed: true, + branchName: "renamed-from-agent-context-3", + }); + expect( + execFileSync("git", ["branch", "--show-current"], { + cwd: created.worktree.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(), + ).toBe("renamed-from-agent-context-3"); +}); + test("renames the branch even when the app supplies a random placeholder slug", async () => { const { repoDir, tempDir } = createGitRepo(); cleanupPaths.push(tempDir); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 667c6c140..2b0951391 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -13,7 +13,7 @@ import { type CreateWorktreeCoreInput, } from "./worktree-core.js"; import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js"; -import { getCurrentBranch, renameCurrentBranch } from "../utils/checkout-git.js"; +import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js"; import { markPaseoWorktreeFirstAgentBranchAutoNameAttempted, readPaseoWorktreeMetadata, @@ -85,6 +85,7 @@ export async function attemptFirstAgentBranchAutoName(options: { }) => Promise; getCurrentBranch?: typeof getCurrentBranch; renameCurrentBranch?: typeof renameCurrentBranch; + localBranchExists?: typeof localBranchExists; }): Promise { const firstAgentContext = options.firstAgentContext; if (!firstAgentContext || !buildAgentBranchNameSeed(firstAgentContext)) { @@ -129,15 +130,50 @@ export async function attemptFirstAgentBranchAutoName(options: { return { attempted: true, renamed: false, branchName: null }; } + const localBranchExistsImpl = options.localBranchExists ?? localBranchExists; + const targetName = await findAvailableBranchName({ + cwd: options.cwd, + desiredName: branchName, + placeholderBranchName, + localBranchExists: localBranchExistsImpl, + }); + if (!targetName) { + return { attempted: true, renamed: false, branchName: null }; + } + const renameCurrentBranchImpl = options.renameCurrentBranch ?? renameCurrentBranch; - const renamedBranch = await renameCurrentBranchImpl(options.cwd, branchName); + const renamedBranch = await renameCurrentBranchImpl(options.cwd, targetName); return { attempted: true, renamed: true, - branchName: renamedBranch.currentBranch ?? branchName, + branchName: renamedBranch.currentBranch ?? targetName, }; } +const MAX_BRANCH_NAME_SUFFIX_ATTEMPTS = 50; + +async function findAvailableBranchName(options: { + cwd: string; + desiredName: string; + placeholderBranchName: string; + localBranchExists: (cwd: string, branchName: string) => Promise; +}): Promise { + const { cwd, desiredName, placeholderBranchName } = options; + if (!(await options.localBranchExists(cwd, desiredName))) { + return desiredName; + } + for (let suffix = 2; suffix <= MAX_BRANCH_NAME_SUFFIX_ATTEMPTS; suffix++) { + const candidate = `${desiredName}-${suffix}`; + if (candidate === placeholderBranchName) { + continue; + } + if (!(await options.localBranchExists(cwd, candidate))) { + return candidate; + } + } + return null; +} + function maybeMarkFirstAgentBranchAutoNameEligible(options: { createdWorktree: Awaited>; }): void { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 1f6dbbea5..51ea4cbce 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -870,6 +870,10 @@ async function getWorktreePathForBranch(cwd: string, branchName: string): Promis } } +export async function localBranchExists(cwd: string, branchName: string): Promise { + return doesGitRefExist(cwd, `refs/heads/${branchName}`); +} + export async function renameCurrentBranch( cwd: string, newName: string,