Skip to content

Commit 4185d01

Browse files
authored
Allow custom feature branch names for PR workflows (#367)
- 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
1 parent 0379cf1 commit 4185d01

6 files changed

Lines changed: 177 additions & 6 deletions

File tree

apps/server/src/git/Layers/GitManager.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@ function runStackedAction(
491491
actionId?: string;
492492
commitMessage?: string;
493493
featureBranch?: boolean;
494+
featureBranchName?: string;
494495
rebaseBeforeCommit?: boolean;
495496
filePaths?: readonly string[];
496497
},
@@ -1360,6 +1361,99 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
13601361
}),
13611362
);
13621363

1364+
it.effect("uses the requested feature branch name when creating a new PR head", () =>
1365+
Effect.gen(function* () {
1366+
const repoDir = yield* makeTempDir("okcode-git-manager-");
1367+
yield* initRepo(repoDir);
1368+
const remoteDir = yield* createBareRemote();
1369+
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1370+
fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");
1371+
1372+
const { manager, ghCalls } = yield* makeManager({
1373+
ghScenario: {
1374+
prListSequence: [
1375+
"[]",
1376+
JSON.stringify([
1377+
{
1378+
number: 99,
1379+
title: "Custom head branch PR",
1380+
url: "https://github.com/pingdotgg/codething-mvp/pull/99",
1381+
baseRefName: "main",
1382+
headRefName: "feature/custom-head",
1383+
},
1384+
]),
1385+
],
1386+
},
1387+
});
1388+
1389+
const result = yield* runStackedAction(manager, {
1390+
cwd: repoDir,
1391+
action: "commit_push_pr",
1392+
featureBranch: true,
1393+
featureBranchName: "custom-head",
1394+
});
1395+
1396+
expect(result.branch.status).toBe("created");
1397+
expect(result.branch.name).toBe("feature/custom-head");
1398+
expect(
1399+
ghCalls.some((call) => call.includes("pr create --base main --head feature/custom-head")),
1400+
).toBe(true);
1401+
}),
1402+
);
1403+
1404+
it.effect(
1405+
"defaults PR base to the local default branch when GitHub metadata is unavailable",
1406+
() =>
1407+
Effect.gen(function* () {
1408+
const repoDir = yield* makeTempDir("okcode-git-manager-");
1409+
yield* runGit(repoDir, ["init", "--initial-branch=master"]);
1410+
yield* runGit(repoDir, ["config", "user.email", "test@example.com"]);
1411+
yield* runGit(repoDir, ["config", "user.name", "Test User"]);
1412+
yield* runGit(repoDir, ["config", "commit.gpgsign", "false"]);
1413+
fs.writeFileSync(path.join(repoDir, "README.md"), "hello\n");
1414+
yield* runGit(repoDir, ["add", "README.md"]);
1415+
yield* runGit(repoDir, ["commit", "-m", "Initial commit"]);
1416+
const remoteDir = yield* createBareRemote();
1417+
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1418+
yield* runGit(repoDir, ["push", "-u", "origin", "master"]);
1419+
yield* runGit(repoDir, ["remote", "set-head", "origin", "master"]);
1420+
yield* runGit(repoDir, ["checkout", "-b", "feature/local-default-base"]);
1421+
fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n");
1422+
yield* runGit(repoDir, ["add", "feature.txt"]);
1423+
yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
1424+
yield* runGit(repoDir, ["push", "-u", "origin", "feature/local-default-base"]);
1425+
1426+
const { manager, ghCalls } = yield* makeManager({
1427+
ghScenario: {
1428+
prListSequence: [
1429+
"[]",
1430+
JSON.stringify([
1431+
{
1432+
number: 101,
1433+
title: "Local default branch PR",
1434+
url: "https://github.com/pingdotgg/codething-mvp/pull/101",
1435+
baseRefName: "master",
1436+
headRefName: "feature/local-default-base",
1437+
},
1438+
]),
1439+
],
1440+
},
1441+
});
1442+
1443+
const result = yield* runStackedAction(manager, {
1444+
cwd: repoDir,
1445+
action: "commit_push_pr",
1446+
});
1447+
1448+
expect(result.pr.status).toBe("created");
1449+
expect(
1450+
ghCalls.some((call) =>
1451+
call.includes("pr create --base master --head feature/local-default-base"),
1452+
),
1453+
).toBe(true);
1454+
}),
1455+
);
1456+
13631457
it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () =>
13641458
Effect.gen(function* () {
13651459
const repoDir = yield* makeTempDir("okcode-git-manager-");

apps/server/src/git/Layers/GitManager.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -676,10 +676,17 @@ export const makeGitManager = Effect.gen(function* () {
676676
headContext: Pick<BranchHeadContext, "headBranch" | "isCrossRepository">,
677677
) =>
678678
Effect.gen(function* () {
679+
const branchList = yield* gitCore.listBranches({ cwd });
680+
const localDefaultBranch =
681+
branchList.branches.find((candidate) => !candidate.isRemote && candidate.isDefault)?.name ??
682+
null;
679683
const defaultFromGh = yield* gitHubCli
680684
.getDefaultBranch({ cwd })
681685
.pipe(Effect.catch(() => Effect.succeed(null)));
682-
const fallbackBase = defaultFromGh ?? "main";
686+
const fallbackBase =
687+
[localDefaultBranch, defaultFromGh, "main"].find(
688+
(candidate): candidate is string => typeof candidate === "string" && candidate.length > 0,
689+
) ?? "main";
683690
const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
684691
if (configured && configured !== headContext.headBranch && configured === fallbackBase) {
685692
return configured;
@@ -1275,6 +1282,7 @@ export const makeGitManager = Effect.gen(function* () {
12751282
commitMessage?: string,
12761283
filePaths?: readonly string[],
12771284
model?: string,
1285+
featureBranchName?: string,
12781286
) =>
12791287
Effect.gen(function* () {
12801288
const suggestion = yield* resolveCommitAndBranchSuggestion({
@@ -1292,7 +1300,11 @@ export const makeGitManager = Effect.gen(function* () {
12921300
);
12931301
}
12941302

1295-
const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject);
1303+
const trimmedFeatureBranchName = featureBranchName?.trim() ?? "";
1304+
const preferredBranch =
1305+
trimmedFeatureBranchName.length > 0
1306+
? trimmedFeatureBranchName
1307+
: (suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject));
12961308
const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd);
12971309
const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch);
12981310

@@ -1379,6 +1391,7 @@ export const makeGitManager = Effect.gen(function* () {
13791391
input.commitMessage,
13801392
input.filePaths,
13811393
input.textGenerationModel,
1394+
input.featureBranchName,
13821395
);
13831396
branchStep = result.branchStep;
13841397
commitMessageForStep = result.resolvedCommitMessage;

apps/web/src/components/GitActionsControl.tsx

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { useAppSettings } from "~/appSettings";
4444
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
4545
import { Button } from "~/components/ui/button";
4646
import { Checkbox } from "~/components/ui/checkbox";
47+
import { Input } from "~/components/ui/input";
4748
import {
4849
Dialog,
4950
DialogDescription,
@@ -98,6 +99,7 @@ interface PendingDefaultBranchAction {
9899
branchName: string;
99100
includesCommit: boolean;
100101
commitMessage?: string;
102+
featureBranchName?: string;
101103
forcePushOnlyProgress: boolean;
102104
onConfirmed?: () => void;
103105
filePaths?: string[];
@@ -124,6 +126,7 @@ interface RunGitActionWithToastInput {
124126
skipDefaultBranchPrompt?: boolean;
125127
statusOverride?: GitStatusResult | null;
126128
featureBranch?: boolean;
129+
featureBranchName?: string;
127130
isDefaultBranchOverride?: boolean;
128131
progressToastId?: GitActionToastId;
129132
filePaths?: string[];
@@ -134,6 +137,7 @@ type RetryableGitActionInput = Pick<
134137
| "action"
135138
| "commitMessage"
136139
| "featureBranch"
140+
| "featureBranchName"
137141
| "filePaths"
138142
| "forcePushOnlyProgress"
139143
| "skipDefaultBranchPrompt"
@@ -153,6 +157,7 @@ function toRetryableGitActionInput(input: RunGitActionWithToastInput): Retryable
153157
action: input.action,
154158
...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
155159
...(input.featureBranch ? { featureBranch: input.featureBranch } : {}),
160+
...(input.featureBranchName ? { featureBranchName: input.featureBranchName } : {}),
156161
...(input.filePaths ? { filePaths: input.filePaths } : {}),
157162
...(input.forcePushOnlyProgress ? { forcePushOnlyProgress: input.forcePushOnlyProgress } : {}),
158163
...(input.skipDefaultBranchPrompt
@@ -360,6 +365,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
360365
const queryClient = useQueryClient();
361366
const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null);
362367
const [dialogCommitMessage, setDialogCommitMessage] = useState("");
368+
const [dialogFeatureBranchName, setDialogFeatureBranchName] = useState("");
363369
const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
364370
const [isEditingFiles, setIsEditingFiles] = useState(false);
365371
const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
@@ -625,6 +631,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
625631
skipDefaultBranchPrompt = false,
626632
statusOverride,
627633
featureBranch = false,
634+
featureBranchName,
628635
isDefaultBranchOverride,
629636
progressToastId,
630637
filePaths,
@@ -648,6 +655,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
648655
branchName: actionBranch,
649656
includesCommit,
650657
...(commitMessage ? { commitMessage } : {}),
658+
...(featureBranchName ? { featureBranchName } : {}),
651659
forcePushOnlyProgress,
652660
...(onConfirmed ? { onConfirmed } : {}),
653661
...(filePaths ? { filePaths } : {}),
@@ -702,6 +710,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
702710
action,
703711
...(commitMessage ? { commitMessage } : {}),
704712
...(featureBranch ? { featureBranch } : {}),
713+
...(featureBranchName ? { featureBranchName } : {}),
705714
...(settings.rebaseBeforeCommit ? { rebaseBeforeCommit: true } : {}),
706715
...(filePaths ? { filePaths } : {}),
707716
});
@@ -798,6 +807,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
798807
action,
799808
...(commitMessage ? { commitMessage } : {}),
800809
...(featureBranch ? { featureBranch } : {}),
810+
...(featureBranchName ? { featureBranchName } : {}),
801811
...(filePaths ? { filePaths } : {}),
802812
...(forcePushOnlyProgress ? { forcePushOnlyProgress } : {}),
803813
...(skipDefaultBranchPrompt ? { skipDefaultBranchPrompt } : {}),
@@ -831,12 +841,19 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
831841

832842
const continuePendingDefaultBranchAction = useCallback(() => {
833843
if (!pendingDefaultBranchAction) return;
834-
const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } =
835-
pendingDefaultBranchAction;
844+
const {
845+
action,
846+
commitMessage,
847+
featureBranchName,
848+
forcePushOnlyProgress,
849+
onConfirmed,
850+
filePaths,
851+
} = pendingDefaultBranchAction;
836852
setPendingDefaultBranchAction(null);
837853
void runGitActionWithToast({
838854
action,
839855
...(commitMessage ? { commitMessage } : {}),
856+
...(featureBranchName ? { featureBranchName } : {}),
840857
forcePushOnlyProgress,
841858
...(onConfirmed ? { onConfirmed } : {}),
842859
...(filePaths ? { filePaths } : {}),
@@ -876,12 +893,19 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
876893

877894
const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {
878895
if (!pendingDefaultBranchAction) return;
879-
const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } =
880-
pendingDefaultBranchAction;
896+
const {
897+
action,
898+
commitMessage,
899+
featureBranchName,
900+
forcePushOnlyProgress,
901+
onConfirmed,
902+
filePaths,
903+
} = pendingDefaultBranchAction;
881904
setPendingDefaultBranchAction(null);
882905
void runGitActionWithToast({
883906
action,
884907
...(commitMessage ? { commitMessage } : {}),
908+
...(featureBranchName ? { featureBranchName } : {}),
885909
forcePushOnlyProgress,
886910
...(onConfirmed ? { onConfirmed } : {}),
887911
...(filePaths ? { filePaths } : {}),
@@ -893,15 +917,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
893917
const runDialogActionOnNewBranch = useCallback(() => {
894918
if (!activeDialogAction || !activeDialogIncludesCommit) return;
895919
const commitMessage = dialogCommitMessage.trim();
920+
const featureBranchName = dialogFeatureBranchName.trim();
896921

897922
setActiveDialogAction(null);
898923
setDialogCommitMessage("");
924+
setDialogFeatureBranchName("");
899925
setExcludedFiles(new Set());
900926
setIsEditingFiles(false);
901927

902928
void runGitActionWithToast({
903929
action: activeDialogAction,
904930
...(commitMessage ? { commitMessage } : {}),
931+
...(featureBranchName ? { featureBranchName } : {}),
905932
...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}),
906933
featureBranch: true,
907934
skipDefaultBranchPrompt: true,
@@ -911,6 +938,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
911938
activeDialogIncludesCommit,
912939
allSelected,
913940
dialogCommitMessage,
941+
dialogFeatureBranchName,
914942
selectedFiles,
915943
]);
916944

@@ -1104,6 +1132,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
11041132
}
11051133
if (quickAction.action) {
11061134
setDialogCommitMessage("");
1135+
setDialogFeatureBranchName("");
11071136
setExcludedFiles(new Set());
11081137
setIsEditingFiles(false);
11091138
setActiveDialogAction(quickAction.action);
@@ -1130,6 +1159,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
11301159
return;
11311160
}
11321161
setDialogCommitMessage("");
1162+
setDialogFeatureBranchName("");
11331163
setExcludedFiles(new Set());
11341164
setIsEditingFiles(false);
11351165
if (item.dialogAction === "push") {
@@ -1151,6 +1181,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
11511181
const includesCommit = dialogIncludesCommit(activeDialogAction, gitStatusForActions);
11521182
setActiveDialogAction(null);
11531183
setDialogCommitMessage("");
1184+
setDialogFeatureBranchName("");
11541185
setExcludedFiles(new Set());
11551186
setIsEditingFiles(false);
11561187
void runGitActionWithToast({
@@ -1427,6 +1458,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
14271458
if (!open) {
14281459
setActiveDialogAction(null);
14291460
setDialogCommitMessage("");
1461+
setDialogFeatureBranchName("");
14301462
setExcludedFiles(new Set());
14311463
setIsEditingFiles(false);
14321464
}
@@ -1550,6 +1582,20 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
15501582
)}
15511583
</div>
15521584
</div>
1585+
{activeDialogIncludesCommit && activeDialogAction === "commit_push_pr" ? (
1586+
<div className="space-y-1">
1587+
<p className="text-xs font-medium">Head branch (optional)</p>
1588+
<Input
1589+
value={dialogFeatureBranchName}
1590+
onChange={(event) => setDialogFeatureBranchName(event.target.value)}
1591+
placeholder="feature/my-change"
1592+
size="sm"
1593+
/>
1594+
<p className="text-[11px] text-muted-foreground">
1595+
Used when you choose to create a new feature branch for this PR.
1596+
</p>
1597+
</div>
1598+
) : null}
15531599
{activeDialogIncludesCommit ? (
15541600
<div className="space-y-1">
15551601
<p className="text-xs font-medium">Commit message (optional)</p>
@@ -1569,6 +1615,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
15691615
onClick={() => {
15701616
setActiveDialogAction(null);
15711617
setDialogCommitMessage("");
1618+
setDialogFeatureBranchName("");
15721619
setExcludedFiles(new Set());
15731620
setIsEditingFiles(false);
15741621
}}

apps/web/src/lib/gitReactQuery.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,13 +156,15 @@ export function gitRunStackedActionMutationOptions(input: {
156156
action,
157157
commitMessage,
158158
featureBranch,
159+
featureBranchName,
159160
rebaseBeforeCommit,
160161
filePaths,
161162
}: {
162163
actionId: string;
163164
action: GitStackedAction;
164165
commitMessage?: string;
165166
featureBranch?: boolean;
167+
featureBranchName?: string;
166168
rebaseBeforeCommit?: boolean;
167169
filePaths?: string[];
168170
}) => {
@@ -174,6 +176,7 @@ export function gitRunStackedActionMutationOptions(input: {
174176
action,
175177
...(commitMessage ? { commitMessage } : {}),
176178
...(featureBranch ? { featureBranch } : {}),
179+
...(featureBranchName ? { featureBranchName } : {}),
177180
...(rebaseBeforeCommit ? { rebaseBeforeCommit } : {}),
178181
...(filePaths ? { filePaths } : {}),
179182
...(input.textGenerationModel ? { textGenerationModel: input.textGenerationModel } : {}),

0 commit comments

Comments
 (0)