| author | |
| committer | |
| log | b2be7f4e4f0aed3c4b03e3986dd3507c501a4abb |
| tree | 86237676586e04ab6742c9b9da721241fbc41f67 |
| parent | 94beb21a4358cda0590c448ba684f663ad476ec1 |
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 | 546 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); |
| 547 | 547 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); |
| 548 | 548 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n"); |
| 549 | let generatedCount = 0; | |
| 549 | 550 | |
| 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 | 566 | const result = yield* runStackedAction(manager, { |
| 552 | 567 | cwd: repoDir, |
| 553 | 568 | action: "commit_push", |
| ... | ... | @@ -571,6 +586,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 571 | 586 | Effect.map((r) => r.stdout.trim()), |
| 572 | 587 | ); |
| 573 | 588 | expect(mergeBase).toBe(mainSha); |
| 589 | expect(generatedCount).toBe(1); | |
| 574 | 590 | }), |
| 575 | 591 | ); |
| 576 | 592 |
apps/server/src/git/Layers/GitManager.ts+29-7| ... | ... | @@ -105,6 +105,13 @@ function sanitizeCommitMessage(generated: { |
| 105 | 105 | }; |
| 106 | 106 | } |
| 107 | 107 | |
| 108 | interface CommitAndBranchSuggestion { | |
| 109 | subject: string; | |
| 110 | body: string; | |
| 111 | branch?: string | undefined; | |
| 112 | commitMessage: string; | |
| 113 | } | |
| 114 | ||
| 108 | 115 | function formatCommitMessage(subject: string, body: string): string { |
| 109 | 116 | const trimmedBody = body.trim(); |
| 110 | 117 | if (trimmedBody.length === 0) { |
| ... | ... | @@ -311,13 +318,20 @@ export const makeGitManager = Effect.gen(function* () { |
| 311 | 318 | }; |
| 312 | 319 | }); |
| 313 | 320 | |
| 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 | 327 | Effect.gen(function* () { |
| 316 | const suggestion = yield* resolveCommitAndBranchSuggestion({ | |
| 317 | cwd, | |
| 318 | branch, | |
| 319 | ...(commitMessage ? { commitMessage } : {}), | |
| 320 | }); | |
| 328 | const suggestion = | |
| 329 | preResolvedSuggestion ?? | |
| 330 | (yield* resolveCommitAndBranchSuggestion({ | |
| 331 | cwd, | |
| 332 | branch, | |
| 333 | ...(commitMessage ? { commitMessage } : {}), | |
| 334 | })); | |
| 321 | 335 | if (!suggestion) { |
| 322 | 336 | return { status: "skipped_no_changes" as const }; |
| 323 | 337 | } |
| ... | ... | @@ -456,6 +470,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 456 | 470 | return { |
| 457 | 471 | branchStep: { status: "created" as const, name: resolvedBranch }, |
| 458 | 472 | resolvedCommitMessage: suggestion.commitMessage, |
| 473 | resolvedCommitSuggestion: suggestion, | |
| 459 | 474 | }; |
| 460 | 475 | }); |
| 461 | 476 | |
| ... | ... | @@ -477,6 +492,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 477 | 492 | |
| 478 | 493 | let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; |
| 479 | 494 | let commitMessageForStep = input.commitMessage; |
| 495 | let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; | |
| 480 | 496 | |
| 481 | 497 | if (input.featureBranch) { |
| 482 | 498 | const result = yield* runFeatureBranchStep( |
| ... | ... | @@ -486,13 +502,19 @@ export const makeGitManager = Effect.gen(function* () { |
| 486 | 502 | ); |
| 487 | 503 | branchStep = result.branchStep; |
| 488 | 504 | commitMessageForStep = result.resolvedCommitMessage; |
| 505 | preResolvedCommitSuggestion = result.resolvedCommitSuggestion; | |
| 489 | 506 | } else { |
| 490 | 507 | branchStep = { status: "skipped_not_requested" as const }; |
| 491 | 508 | } |
| 492 | 509 | |
| 493 | 510 | const currentBranch = branchStep.name ?? initialStatus.branch; |
| 494 | 511 | |
| 495 | const commit = yield* runCommitStep(input.cwd, currentBranch, commitMessageForStep); | |
| 512 | const commit = yield* runCommitStep( | |
| 513 | input.cwd, | |
| 514 | currentBranch, | |
| 515 | commitMessageForStep, | |
| 516 | preResolvedCommitSuggestion, | |
| 517 | ); | |
| 496 | 518 | |
| 497 | 519 | const push = wantsPush |
| 498 | 520 | ? yield* gitCore.pushCurrentBranch(input.cwd, currentBranch) |
apps/web/src/components/GitActionsControl.logic.test.ts+5| ... | ... | @@ -943,6 +943,11 @@ describe("resolveAutoFeatureBranchName", () => { |
| 943 | 943 | assert.equal(branch, "feature/fix-toast-copy-3"); |
| 944 | 944 | }); |
| 945 | 945 | |
| 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 | 951 | it("falls back to feature/update when no preferred name is provided", () => { |
| 947 | 952 | const branch = resolveAutoFeatureBranchName(["main"]); |
| 948 | 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 | 254 | !skipDefaultBranchPrompt && |
| 255 | 255 | !featureBranch && |
| 256 | 256 | requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && |
| 257 | actionBranch && | |
| 258 | !!actionStatus?.hasWorkingTreeChanges | |
| 257 | actionBranch | |
| 259 | 258 | ) { |
| 260 | 259 | if (action !== "commit_push" && action !== "commit_push_pr") { |
| 261 | 260 | return; |
| ... | ... | @@ -416,6 +415,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 416 | 415 | (actionParams: { |
| 417 | 416 | action: GitStackedAction; |
| 418 | 417 | commitMessage?: string; |
| 418 | forcePushOnlyProgress?: boolean; | |
| 419 | 419 | onConfirmed?: () => void; |
| 420 | 420 | }) => { |
| 421 | 421 | void runGitActionWithToast({ |
| ... | ... | @@ -429,11 +429,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 429 | 429 | |
| 430 | 430 | const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { |
| 431 | 431 | if (!pendingDefaultBranchAction) return; |
| 432 | const { action, commitMessage, onConfirmed } = pendingDefaultBranchAction; | |
| 432 | const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction; | |
| 433 | 433 | setPendingDefaultBranchAction(null); |
| 434 | 434 | checkoutNewBranchAndRunAction({ |
| 435 | 435 | action, |
| 436 | 436 | ...(commitMessage ? { commitMessage } : {}), |
| 437 | forcePushOnlyProgress, | |
| 437 | 438 | ...(onConfirmed ? { onConfirmed } : {}), |
| 438 | 439 | }); |
| 439 | 440 | }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]); |
apps/web/src/lib/gitReactQuery.ts-20| ... | ... | @@ -16,8 +16,6 @@ export const gitQueryKeys = { |
| 16 | 16 | export const gitMutationKeys = { |
| 17 | 17 | init: (cwd: string | null) => ["git", "mutation", "init", cwd] as const, |
| 18 | 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 | 19 | runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, |
| 22 | 20 | pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, |
| 23 | 21 | }; |
| ... | ... | @@ -89,24 +87,6 @@ export function gitCheckoutMutationOptions(input: { |
| 89 | 87 | }); |
| 90 | 88 | } |
| 91 | 89 | |
| 92 | export 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 | ||
| 110 | 90 | export function gitRunStackedActionMutationOptions(input: { |
| 111 | 91 | cwd: string | null; |
| 112 | 92 | queryClient: QueryClient; |
packages/shared/src/git.ts+1-1| ... | ... | @@ -46,7 +46,7 @@ export function resolveAutoFeatureBranchName( |
| 46 | 46 | const resolvedBase = sanitizeFeatureBranchName( |
| 47 | 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())); | |
| 50 | 50 | |
| 51 | 51 | if (!existingNames.has(resolvedBase)) { |
| 52 | 52 | return resolvedBase; |