authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 19:40:55-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 20:46:05-08:00
logb2be7f4e4f0aed3c4b03e3986dd3507c501a4abb
tree86237676586e04ab6742c9b9da721241fbc41f67
parent94beb21a4358cda0590c448ba684f663ad476ec1

Address PR feedback on git workflows

Co-authored-by: codex <codex@users.noreply.github.com>

6 files changed, 56 insertions(+), 32 deletions(-)

apps/server/src/git/Layers/GitManager.test.ts+17-1
...@@ -546,8 +546,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -546,8 +546,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
546 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);546 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
547 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);547 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
548 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n");548 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n");
549 let generatedCount = 0;
549550
550 const { manager } = yield* makeManager();551 const { manager } = yield* makeManager({
552 textGeneration: {
553 generateCommitMessage: (input) =>
554 Effect.sync(() => {
555 generatedCount += 1;
556 return {
557 subject: "Implement stacked git actions",
558 body: "",
559 ...(input.includeBranch
560 ? { branch: "feature/implement-stacked-git-actions" }
561 : {}),
562 };
563 }),
564 },
565 });
551 const result = yield* runStackedAction(manager, {566 const result = yield* runStackedAction(manager, {
552 cwd: repoDir,567 cwd: repoDir,
553 action: "commit_push",568 action: "commit_push",
...@@ -571,6 +586,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -571,6 +586,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
571 Effect.map((r) => r.stdout.trim()),586 Effect.map((r) => r.stdout.trim()),
572 );587 );
573 expect(mergeBase).toBe(mainSha);588 expect(mergeBase).toBe(mainSha);
589 expect(generatedCount).toBe(1);
574 }),590 }),
575 );591 );
576592
apps/server/src/git/Layers/GitManager.ts+29-7
...@@ -105,6 +105,13 @@ function sanitizeCommitMessage(generated: {...@@ -105,6 +105,13 @@ function sanitizeCommitMessage(generated: {
105 };105 };
106}106}
107107
108interface CommitAndBranchSuggestion {
109 subject: string;
110 body: string;
111 branch?: string | undefined;
112 commitMessage: string;
113}
114
108function formatCommitMessage(subject: string, body: string): string {115function formatCommitMessage(subject: string, body: string): string {
109 const trimmedBody = body.trim();116 const trimmedBody = body.trim();
110 if (trimmedBody.length === 0) {117 if (trimmedBody.length === 0) {
...@@ -311,13 +318,20 @@ export const makeGitManager = Effect.gen(function* () {...@@ -311,13 +318,20 @@ export const makeGitManager = Effect.gen(function* () {
311 };318 };
312 });319 });
313320
314 const runCommitStep = (cwd: string, branch: string | null, commitMessage?: string) =>321 const runCommitStep = (
322 cwd: string,
323 branch: string | null,
324 commitMessage?: string,
325 preResolvedSuggestion?: CommitAndBranchSuggestion,
326 ) =>
315 Effect.gen(function* () {327 Effect.gen(function* () {
316 const suggestion = yield* resolveCommitAndBranchSuggestion({328 const suggestion =
317 cwd,329 preResolvedSuggestion ??
318 branch,330 (yield* resolveCommitAndBranchSuggestion({
319 ...(commitMessage ? { commitMessage } : {}),331 cwd,
320 });332 branch,
333 ...(commitMessage ? { commitMessage } : {}),
334 }));
321 if (!suggestion) {335 if (!suggestion) {
322 return { status: "skipped_no_changes" as const };336 return { status: "skipped_no_changes" as const };
323 }337 }
...@@ -456,6 +470,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -456,6 +470,7 @@ export const makeGitManager = Effect.gen(function* () {
456 return {470 return {
457 branchStep: { status: "created" as const, name: resolvedBranch },471 branchStep: { status: "created" as const, name: resolvedBranch },
458 resolvedCommitMessage: suggestion.commitMessage,472 resolvedCommitMessage: suggestion.commitMessage,
473 resolvedCommitSuggestion: suggestion,
459 };474 };
460 });475 });
461476
...@@ -477,6 +492,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -477,6 +492,7 @@ export const makeGitManager = Effect.gen(function* () {
477492
478 let branchStep: { status: "created" | "skipped_not_requested"; name?: string };493 let branchStep: { status: "created" | "skipped_not_requested"; name?: string };
479 let commitMessageForStep = input.commitMessage;494 let commitMessageForStep = input.commitMessage;
495 let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;
480496
481 if (input.featureBranch) {497 if (input.featureBranch) {
482 const result = yield* runFeatureBranchStep(498 const result = yield* runFeatureBranchStep(
...@@ -486,13 +502,19 @@ export const makeGitManager = Effect.gen(function* () {...@@ -486,13 +502,19 @@ export const makeGitManager = Effect.gen(function* () {
486 );502 );
487 branchStep = result.branchStep;503 branchStep = result.branchStep;
488 commitMessageForStep = result.resolvedCommitMessage;504 commitMessageForStep = result.resolvedCommitMessage;
505 preResolvedCommitSuggestion = result.resolvedCommitSuggestion;
489 } else {506 } else {
490 branchStep = { status: "skipped_not_requested" as const };507 branchStep = { status: "skipped_not_requested" as const };
491 }508 }
492509
493 const currentBranch = branchStep.name ?? initialStatus.branch;510 const currentBranch = branchStep.name ?? initialStatus.branch;
494511
495 const commit = yield* runCommitStep(input.cwd, currentBranch, commitMessageForStep);512 const commit = yield* runCommitStep(
513 input.cwd,
514 currentBranch,
515 commitMessageForStep,
516 preResolvedCommitSuggestion,
517 );
496518
497 const push = wantsPush519 const push = wantsPush
498 ? yield* gitCore.pushCurrentBranch(input.cwd, currentBranch)520 ? yield* gitCore.pushCurrentBranch(input.cwd, currentBranch)
apps/web/src/components/GitActionsControl.logic.test.ts+5
...@@ -943,6 +943,11 @@ describe("resolveAutoFeatureBranchName", () => {...@@ -943,6 +943,11 @@ describe("resolveAutoFeatureBranchName", () => {
943 assert.equal(branch, "feature/fix-toast-copy-3");943 assert.equal(branch, "feature/fix-toast-copy-3");
944 });944 });
945945
946 it("treats existing branch names as case-insensitive for collision checks", () => {
947 const branch = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1");
948 assert.equal(branch, "feature/ticket-1-2");
949 });
950
946 it("falls back to feature/update when no preferred name is provided", () => {951 it("falls back to feature/update when no preferred name is provided", () => {
947 const branch = resolveAutoFeatureBranchName(["main"]);952 const branch = resolveAutoFeatureBranchName(["main"]);
948 assert.equal(branch, "feature/update");953 assert.equal(branch, "feature/update");
apps/web/src/components/GitActionsControl.tsx+4-3
...@@ -254,8 +254,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -254,8 +254,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
254 !skipDefaultBranchPrompt &&254 !skipDefaultBranchPrompt &&
255 !featureBranch &&255 !featureBranch &&
256 requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) &&256 requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) &&
257 actionBranch &&257 actionBranch
258 !!actionStatus?.hasWorkingTreeChanges
259 ) {258 ) {
260 if (action !== "commit_push" && action !== "commit_push_pr") {259 if (action !== "commit_push" && action !== "commit_push_pr") {
261 return;260 return;
...@@ -416,6 +415,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -416,6 +415,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
416 (actionParams: {415 (actionParams: {
417 action: GitStackedAction;416 action: GitStackedAction;
418 commitMessage?: string;417 commitMessage?: string;
418 forcePushOnlyProgress?: boolean;
419 onConfirmed?: () => void;419 onConfirmed?: () => void;
420 }) => {420 }) => {
421 void runGitActionWithToast({421 void runGitActionWithToast({
...@@ -429,11 +429,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -429,11 +429,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
429429
430 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {430 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {
431 if (!pendingDefaultBranchAction) return;431 if (!pendingDefaultBranchAction) return;
432 const { action, commitMessage, onConfirmed } = pendingDefaultBranchAction;432 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction;
433 setPendingDefaultBranchAction(null);433 setPendingDefaultBranchAction(null);
434 checkoutNewBranchAndRunAction({434 checkoutNewBranchAndRunAction({
435 action,435 action,
436 ...(commitMessage ? { commitMessage } : {}),436 ...(commitMessage ? { commitMessage } : {}),
437 forcePushOnlyProgress,
437 ...(onConfirmed ? { onConfirmed } : {}),438 ...(onConfirmed ? { onConfirmed } : {}),
438 });439 });
439 }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]);440 }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]);
apps/web/src/lib/gitReactQuery.ts-20
...@@ -16,8 +16,6 @@ export const gitQueryKeys = {...@@ -16,8 +16,6 @@ export const gitQueryKeys = {
16export const gitMutationKeys = {16export const gitMutationKeys = {
17 init: (cwd: string | null) => ["git", "mutation", "init", cwd] as const,17 init: (cwd: string | null) => ["git", "mutation", "init", cwd] as const,
18 checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const,18 checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const,
19 createBranchAndCheckout: (cwd: string | null) =>
20 ["git", "mutation", "create-branch-and-checkout", cwd] as const,
21 runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const,19 runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const,
22 pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const,20 pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const,
23};21};
...@@ -89,24 +87,6 @@ export function gitCheckoutMutationOptions(input: {...@@ -89,24 +87,6 @@ export function gitCheckoutMutationOptions(input: {
89 });87 });
90}88}
9189
92export function gitCreateBranchAndCheckoutMutationOptions(input: {
93 cwd: string | null;
94 queryClient: QueryClient;
95}) {
96 return mutationOptions({
97 mutationKey: gitMutationKeys.createBranchAndCheckout(input.cwd),
98 mutationFn: async (branch: string) => {
99 const api = ensureNativeApi();
100 if (!input.cwd) throw new Error("Git branch creation is unavailable.");
101 await api.git.createBranch({ cwd: input.cwd, branch });
102 return api.git.checkout({ cwd: input.cwd, branch });
103 },
104 onSuccess: async () => {
105 await invalidateGitQueries(input.queryClient);
106 },
107 });
108}
109
110export function gitRunStackedActionMutationOptions(input: {90export function gitRunStackedActionMutationOptions(input: {
111 cwd: string | null;91 cwd: string | null;
112 queryClient: QueryClient;92 queryClient: QueryClient;
packages/shared/src/git.ts+1-1
...@@ -46,7 +46,7 @@ export function resolveAutoFeatureBranchName(...@@ -46,7 +46,7 @@ export function resolveAutoFeatureBranchName(
46 const resolvedBase = sanitizeFeatureBranchName(46 const resolvedBase = sanitizeFeatureBranchName(
47 preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK,47 preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK,
48 );48 );
49 const existingNames = new Set(existingBranchNames);49 const existingNames = new Set(existingBranchNames.map((branch) => branch.toLowerCase()));
5050
51 if (!existingNames.has(resolvedBase)) {51 if (!existingNames.has(resolvedBase)) {
52 return resolvedBase;52 return resolvedBase;