Skip to content
Merged
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
94 changes: 94 additions & 0 deletions apps/server/src/git/Layers/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ function runStackedAction(
actionId?: string;
commitMessage?: string;
featureBranch?: boolean;
featureBranchName?: string;
rebaseBeforeCommit?: boolean;
filePaths?: readonly string[];
},
Expand Down Expand Up @@ -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-");
Expand Down
17 changes: 15 additions & 2 deletions apps/server/src/git/Layers/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,10 +676,17 @@ export const makeGitManager = Effect.gen(function* () {
headContext: Pick<BranchHeadContext, "headBranch" | "isCrossRepository">,
) =>
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;
Expand Down Expand Up @@ -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({
Expand All @@ -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);

Expand Down Expand Up @@ -1379,6 +1391,7 @@ export const makeGitManager = Effect.gen(function* () {
input.commitMessage,
input.filePaths,
input.textGenerationModel,
input.featureBranchName,
);
branchStep = result.branchStep;
commitMessageForStep = result.resolvedCommitMessage;
Expand Down
55 changes: 51 additions & 4 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -98,6 +99,7 @@ interface PendingDefaultBranchAction {
branchName: string;
includesCommit: boolean;
commitMessage?: string;
featureBranchName?: string;
forcePushOnlyProgress: boolean;
onConfirmed?: () => void;
filePaths?: string[];
Expand All @@ -124,6 +126,7 @@ interface RunGitActionWithToastInput {
skipDefaultBranchPrompt?: boolean;
statusOverride?: GitStatusResult | null;
featureBranch?: boolean;
featureBranchName?: string;
isDefaultBranchOverride?: boolean;
progressToastId?: GitActionToastId;
filePaths?: string[];
Expand All @@ -134,6 +137,7 @@ type RetryableGitActionInput = Pick<
| "action"
| "commitMessage"
| "featureBranch"
| "featureBranchName"
| "filePaths"
| "forcePushOnlyProgress"
| "skipDefaultBranchPrompt"
Expand All @@ -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
Expand Down Expand Up @@ -360,6 +365,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
const queryClient = useQueryClient();
const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null);
const [dialogCommitMessage, setDialogCommitMessage] = useState("");
const [dialogFeatureBranchName, setDialogFeatureBranchName] = useState("");
const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
const [isEditingFiles, setIsEditingFiles] = useState(false);
const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
Expand Down Expand Up @@ -625,6 +631,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
skipDefaultBranchPrompt = false,
statusOverride,
featureBranch = false,
featureBranchName,
isDefaultBranchOverride,
progressToastId,
filePaths,
Expand All @@ -648,6 +655,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
branchName: actionBranch,
includesCommit,
...(commitMessage ? { commitMessage } : {}),
...(featureBranchName ? { featureBranchName } : {}),
forcePushOnlyProgress,
...(onConfirmed ? { onConfirmed } : {}),
...(filePaths ? { filePaths } : {}),
Expand Down Expand Up @@ -702,6 +710,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
action,
...(commitMessage ? { commitMessage } : {}),
...(featureBranch ? { featureBranch } : {}),
...(featureBranchName ? { featureBranchName } : {}),
...(settings.rebaseBeforeCommit ? { rebaseBeforeCommit: true } : {}),
...(filePaths ? { filePaths } : {}),
});
Expand Down Expand Up @@ -798,6 +807,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
action,
...(commitMessage ? { commitMessage } : {}),
...(featureBranch ? { featureBranch } : {}),
...(featureBranchName ? { featureBranchName } : {}),
...(filePaths ? { filePaths } : {}),
...(forcePushOnlyProgress ? { forcePushOnlyProgress } : {}),
...(skipDefaultBranchPrompt ? { skipDefaultBranchPrompt } : {}),
Expand Down Expand Up @@ -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 } : {}),
Expand Down Expand Up @@ -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 } : {}),
Expand All @@ -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,
Expand All @@ -911,6 +938,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
activeDialogIncludesCommit,
allSelected,
dialogCommitMessage,
dialogFeatureBranchName,
selectedFiles,
]);

Expand Down Expand Up @@ -1104,6 +1132,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
}
if (quickAction.action) {
setDialogCommitMessage("");
setDialogFeatureBranchName("");
setExcludedFiles(new Set());
setIsEditingFiles(false);
setActiveDialogAction(quickAction.action);
Expand All @@ -1130,6 +1159,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
return;
}
setDialogCommitMessage("");
setDialogFeatureBranchName("");
setExcludedFiles(new Set());
setIsEditingFiles(false);
if (item.dialogAction === "push") {
Expand All @@ -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({
Expand Down Expand Up @@ -1427,6 +1458,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
if (!open) {
setActiveDialogAction(null);
setDialogCommitMessage("");
setDialogFeatureBranchName("");
setExcludedFiles(new Set());
setIsEditingFiles(false);
}
Expand Down Expand Up @@ -1550,6 +1582,20 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
)}
</div>
</div>
{activeDialogIncludesCommit && activeDialogAction === "commit_push_pr" ? (
<div className="space-y-1">
<p className="text-xs font-medium">Head branch (optional)</p>
<Input
value={dialogFeatureBranchName}
onChange={(event) => setDialogFeatureBranchName(event.target.value)}
placeholder="feature/my-change"
size="sm"
/>
<p className="text-[11px] text-muted-foreground">
Used when you choose to create a new feature branch for this PR.
</p>
</div>
) : null}
{activeDialogIncludesCommit ? (
<div className="space-y-1">
<p className="text-xs font-medium">Commit message (optional)</p>
Expand All @@ -1569,6 +1615,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
onClick={() => {
setActiveDialogAction(null);
setDialogCommitMessage("");
setDialogFeatureBranchName("");
setExcludedFiles(new Set());
setIsEditingFiles(false);
}}
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/lib/gitReactQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,15 @@ export function gitRunStackedActionMutationOptions(input: {
action,
commitMessage,
featureBranch,
featureBranchName,
rebaseBeforeCommit,
filePaths,
}: {
actionId: string;
action: GitStackedAction;
commitMessage?: string;
featureBranch?: boolean;
featureBranchName?: string;
rebaseBeforeCommit?: boolean;
filePaths?: string[];
}) => {
Expand All @@ -174,6 +176,7 @@ export function gitRunStackedActionMutationOptions(input: {
action,
...(commitMessage ? { commitMessage } : {}),
...(featureBranch ? { featureBranch } : {}),
...(featureBranchName ? { featureBranchName } : {}),
...(rebaseBeforeCommit ? { rebaseBeforeCommit } : {}),
...(filePaths ? { filePaths } : {}),
...(input.textGenerationModel ? { textGenerationModel: input.textGenerationModel } : {}),
Expand Down
Loading
Loading