From dd001a2e413b02616cef884798642562a882990a Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 8 Apr 2026 15:37:51 -0500 Subject: [PATCH] Allow custom feature branch names for PR workflows - Accept an optional `featureBranchName` in git action contracts - Thread the override through server branch creation and PR creation - Add UI support for naming the head branch in the commit+PR dialog --- apps/server/src/git/Layers/GitManager.test.ts | 94 +++++++++++++++++++ apps/server/src/git/Layers/GitManager.ts | 17 +++- apps/web/src/components/GitActionsControl.tsx | 55 ++++++++++- apps/web/src/lib/gitReactQuery.ts | 3 + packages/contracts/src/git.test.ts | 13 +++ packages/contracts/src/git.ts | 1 + 6 files changed, 177 insertions(+), 6 deletions(-) diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 683ab5c13..98cc1079b 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -491,6 +491,7 @@ function runStackedAction( actionId?: string; commitMessage?: string; featureBranch?: boolean; + featureBranchName?: string; rebaseBeforeCommit?: boolean; filePaths?: readonly string[]; }, @@ -1360,6 +1361,99 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("uses the requested feature branch name when creating a new PR head", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("okcode-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 99, + title: "Custom head branch PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/99", + baseRefName: "main", + headRefName: "feature/custom-head", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + featureBranch: true, + featureBranchName: "custom-head", + }); + + expect(result.branch.status).toBe("created"); + expect(result.branch.name).toBe("feature/custom-head"); + expect( + ghCalls.some((call) => call.includes("pr create --base main --head feature/custom-head")), + ).toBe(true); + }), + ); + + it.effect( + "defaults PR base to the local default branch when GitHub metadata is unavailable", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("okcode-git-manager-"); + yield* runGit(repoDir, ["init", "--initial-branch=master"]); + yield* runGit(repoDir, ["config", "user.email", "test@example.com"]); + yield* runGit(repoDir, ["config", "user.name", "Test User"]); + yield* runGit(repoDir, ["config", "commit.gpgsign", "false"]); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\n"); + yield* runGit(repoDir, ["add", "README.md"]); + yield* runGit(repoDir, ["commit", "-m", "Initial commit"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "master"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "master"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/local-default-base"]); + fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); + yield* runGit(repoDir, ["add", "feature.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/local-default-base"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 101, + title: "Local default branch PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/101", + baseRefName: "master", + headRefName: "feature/local-default-base", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect( + ghCalls.some((call) => + call.includes("pr create --base master --head feature/local-default-base"), + ), + ).toBe(true); + }), + ); + it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("okcode-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index a072dfc7a..85fbb6605 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -676,10 +676,17 @@ export const makeGitManager = Effect.gen(function* () { headContext: Pick, ) => Effect.gen(function* () { + const branchList = yield* gitCore.listBranches({ cwd }); + const localDefaultBranch = + branchList.branches.find((candidate) => !candidate.isRemote && candidate.isDefault)?.name ?? + null; const defaultFromGh = yield* gitHubCli .getDefaultBranch({ cwd }) .pipe(Effect.catch(() => Effect.succeed(null))); - const fallbackBase = defaultFromGh ?? "main"; + const fallbackBase = + [localDefaultBranch, defaultFromGh, "main"].find( + (candidate): candidate is string => typeof candidate === "string" && candidate.length > 0, + ) ?? "main"; const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); if (configured && configured !== headContext.headBranch && configured === fallbackBase) { return configured; @@ -1275,6 +1282,7 @@ export const makeGitManager = Effect.gen(function* () { commitMessage?: string, filePaths?: readonly string[], model?: string, + featureBranchName?: string, ) => Effect.gen(function* () { const suggestion = yield* resolveCommitAndBranchSuggestion({ @@ -1292,7 +1300,11 @@ export const makeGitManager = Effect.gen(function* () { ); } - const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); + const trimmedFeatureBranchName = featureBranchName?.trim() ?? ""; + const preferredBranch = + trimmedFeatureBranchName.length > 0 + ? trimmedFeatureBranchName + : (suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject)); const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd); const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); @@ -1379,6 +1391,7 @@ export const makeGitManager = Effect.gen(function* () { input.commitMessage, input.filePaths, input.textGenerationModel, + input.featureBranchName, ); branchStep = result.branchStep; commitMessageForStep = result.resolvedCommitMessage; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 1b4991486..807e33af2 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -44,6 +44,7 @@ import { useAppSettings } from "~/appSettings"; import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; +import { Input } from "~/components/ui/input"; import { Dialog, DialogDescription, @@ -98,6 +99,7 @@ interface PendingDefaultBranchAction { branchName: string; includesCommit: boolean; commitMessage?: string; + featureBranchName?: string; forcePushOnlyProgress: boolean; onConfirmed?: () => void; filePaths?: string[]; @@ -124,6 +126,7 @@ interface RunGitActionWithToastInput { skipDefaultBranchPrompt?: boolean; statusOverride?: GitStatusResult | null; featureBranch?: boolean; + featureBranchName?: string; isDefaultBranchOverride?: boolean; progressToastId?: GitActionToastId; filePaths?: string[]; @@ -134,6 +137,7 @@ type RetryableGitActionInput = Pick< | "action" | "commitMessage" | "featureBranch" + | "featureBranchName" | "filePaths" | "forcePushOnlyProgress" | "skipDefaultBranchPrompt" @@ -153,6 +157,7 @@ function toRetryableGitActionInput(input: RunGitActionWithToastInput): Retryable action: input.action, ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), + ...(input.featureBranchName ? { featureBranchName: input.featureBranchName } : {}), ...(input.filePaths ? { filePaths: input.filePaths } : {}), ...(input.forcePushOnlyProgress ? { forcePushOnlyProgress: input.forcePushOnlyProgress } : {}), ...(input.skipDefaultBranchPrompt @@ -360,6 +365,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const queryClient = useQueryClient(); const [activeDialogAction, setActiveDialogAction] = useState(null); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); + const [dialogFeatureBranchName, setDialogFeatureBranchName] = useState(""); const [excludedFiles, setExcludedFiles] = useState>(new Set()); const [isEditingFiles, setIsEditingFiles] = useState(false); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = @@ -625,6 +631,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions skipDefaultBranchPrompt = false, statusOverride, featureBranch = false, + featureBranchName, isDefaultBranchOverride, progressToastId, filePaths, @@ -648,6 +655,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions branchName: actionBranch, includesCommit, ...(commitMessage ? { commitMessage } : {}), + ...(featureBranchName ? { featureBranchName } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), @@ -702,6 +710,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), + ...(featureBranchName ? { featureBranchName } : {}), ...(settings.rebaseBeforeCommit ? { rebaseBeforeCommit: true } : {}), ...(filePaths ? { filePaths } : {}), }); @@ -798,6 +807,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), + ...(featureBranchName ? { featureBranchName } : {}), ...(filePaths ? { filePaths } : {}), ...(forcePushOnlyProgress ? { forcePushOnlyProgress } : {}), ...(skipDefaultBranchPrompt ? { skipDefaultBranchPrompt } : {}), @@ -831,12 +841,19 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const continuePendingDefaultBranchAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = - pendingDefaultBranchAction; + const { + action, + commitMessage, + featureBranchName, + forcePushOnlyProgress, + onConfirmed, + filePaths, + } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), + ...(featureBranchName ? { featureBranchName } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), @@ -876,12 +893,19 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = - pendingDefaultBranchAction; + const { + action, + commitMessage, + featureBranchName, + forcePushOnlyProgress, + onConfirmed, + filePaths, + } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), + ...(featureBranchName ? { featureBranchName } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), @@ -893,15 +917,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const runDialogActionOnNewBranch = useCallback(() => { if (!activeDialogAction || !activeDialogIncludesCommit) return; const commitMessage = dialogCommitMessage.trim(); + const featureBranchName = dialogFeatureBranchName.trim(); setActiveDialogAction(null); setDialogCommitMessage(""); + setDialogFeatureBranchName(""); setExcludedFiles(new Set()); setIsEditingFiles(false); void runGitActionWithToast({ action: activeDialogAction, ...(commitMessage ? { commitMessage } : {}), + ...(featureBranchName ? { featureBranchName } : {}), ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), featureBranch: true, skipDefaultBranchPrompt: true, @@ -911,6 +938,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions activeDialogIncludesCommit, allSelected, dialogCommitMessage, + dialogFeatureBranchName, selectedFiles, ]); @@ -1104,6 +1132,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions } if (quickAction.action) { setDialogCommitMessage(""); + setDialogFeatureBranchName(""); setExcludedFiles(new Set()); setIsEditingFiles(false); setActiveDialogAction(quickAction.action); @@ -1130,6 +1159,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions return; } setDialogCommitMessage(""); + setDialogFeatureBranchName(""); setExcludedFiles(new Set()); setIsEditingFiles(false); if (item.dialogAction === "push") { @@ -1151,6 +1181,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const includesCommit = dialogIncludesCommit(activeDialogAction, gitStatusForActions); setActiveDialogAction(null); setDialogCommitMessage(""); + setDialogFeatureBranchName(""); setExcludedFiles(new Set()); setIsEditingFiles(false); void runGitActionWithToast({ @@ -1427,6 +1458,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions if (!open) { setActiveDialogAction(null); setDialogCommitMessage(""); + setDialogFeatureBranchName(""); setExcludedFiles(new Set()); setIsEditingFiles(false); } @@ -1550,6 +1582,20 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions )} + {activeDialogIncludesCommit && activeDialogAction === "commit_push_pr" ? ( +
+

Head branch (optional)

+ setDialogFeatureBranchName(event.target.value)} + placeholder="feature/my-change" + size="sm" + /> +

+ Used when you choose to create a new feature branch for this PR. +

+
+ ) : null} {activeDialogIncludesCommit ? (

Commit message (optional)

@@ -1569,6 +1615,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions onClick={() => { setActiveDialogAction(null); setDialogCommitMessage(""); + setDialogFeatureBranchName(""); setExcludedFiles(new Set()); setIsEditingFiles(false); }} diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index abfaf49a1..13e4c0d15 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -156,6 +156,7 @@ export function gitRunStackedActionMutationOptions(input: { action, commitMessage, featureBranch, + featureBranchName, rebaseBeforeCommit, filePaths, }: { @@ -163,6 +164,7 @@ export function gitRunStackedActionMutationOptions(input: { action: GitStackedAction; commitMessage?: string; featureBranch?: boolean; + featureBranchName?: string; rebaseBeforeCommit?: boolean; filePaths?: string[]; }) => { @@ -174,6 +176,7 @@ export function gitRunStackedActionMutationOptions(input: { action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), + ...(featureBranchName ? { featureBranchName } : {}), ...(rebaseBeforeCommit ? { rebaseBeforeCommit } : {}), ...(filePaths ? { filePaths } : {}), ...(input.textGenerationModel ? { textGenerationModel: input.textGenerationModel } : {}), diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 4aced2a47..4d099d5ac 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -87,6 +87,19 @@ describe("GitRunStackedActionInput", () => { expect(parsed.action).toBe("commit"); expect(parsed.rebaseBeforeCommit).toBe(true); }); + + it("accepts an optional feature branch name override", () => { + const parsed = decodeRunStackedActionInput({ + actionId: "action-1", + cwd: "/repo", + action: "commit_push_pr", + featureBranch: true, + featureBranchName: "feature/custom-head", + }); + + expect(parsed.featureBranch).toBe(true); + expect(parsed.featureBranchName).toBe("feature/custom-head"); + }); }); describe("GitStatusResult", () => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 8ee8c1b2a..85158fb9a 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -125,6 +125,7 @@ export const GitRunStackedActionInput = Schema.Struct({ action: GitStackedAction, commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), featureBranch: Schema.optional(Schema.Boolean), + featureBranchName: Schema.optional(TrimmedNonEmptyStringSchema), rebaseBeforeCommit: Schema.optional(Schema.Boolean), filePaths: Schema.optional( Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),