| author | |
| committer | |
| log | 906550f9af7d6fc37e1c9e4f21018dcb9ea03591 |
| tree | 5e1f4fccf8c614dc8153893d36b7bcfc4ed416a3 |
| parent | c2aaa89b57ae07095bc337cb5d0a7837eee31f99 |
Consolidate the fragile 3-RPC flow (suggestCommitAndBranch → createBranch +
checkout → runStackedAction) into a single server-side operation triggered by
`runStackedAction({ featureBranch: true })`. The server now generates the
branch name via AI, deduplicates it, creates + checks out the branch, then
runs the normal commit/push/PR pipeline atomically.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>19 files changed, 182 insertions(+), 301 deletions(-)
apps/server/src/git/Layers/GitCore.test.ts+1| ... | ... | @@ -110,6 +110,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => |
| 110 | 110 | createBranch: (input) => core.createBranch(input), |
| 111 | 111 | checkoutBranch: (input) => core.checkoutBranch(input), |
| 112 | 112 | initRepo: (input) => core.initRepo(input), |
| 113 | listLocalBranchNames: (cwd) => core.listLocalBranchNames(cwd), | |
| 113 | 114 | } satisfies GitCoreShape; |
| 114 | 115 | }); |
| 115 | 116 |
apps/server/src/git/Layers/GitCore.ts+15| ... | ... | @@ -1172,6 +1172,20 @@ const makeGitCore = Effect.gen(function* () { |
| 1172 | 1172 | fallbackErrorMessage: "git init failed", |
| 1173 | 1173 | }).pipe(Effect.asVoid); |
| 1174 | 1174 | |
| 1175 | const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) => | |
| 1176 | runGitStdout("GitCore.listLocalBranchNames", cwd, [ | |
| 1177 | "branch", | |
| 1178 | "--list", | |
| 1179 | "--format=%(refname:short)", | |
| 1180 | ]).pipe( | |
| 1181 | Effect.map((stdout) => | |
| 1182 | stdout | |
| 1183 | .split("\n") | |
| 1184 | .map((line) => line.trim()) | |
| 1185 | .filter((line) => line.length > 0), | |
| 1186 | ), | |
| 1187 | ); | |
| 1188 | ||
| 1175 | 1189 | return { |
| 1176 | 1190 | status, |
| 1177 | 1191 | statusDetails, |
| ... | ... | @@ -1188,6 +1202,7 @@ const makeGitCore = Effect.gen(function* () { |
| 1188 | 1202 | createBranch, |
| 1189 | 1203 | checkoutBranch, |
| 1190 | 1204 | initRepo, |
| 1205 | listLocalBranchNames, | |
| 1191 | 1206 | } satisfies GitCoreShape; |
| 1192 | 1207 | }); |
| 1193 | 1208 |
apps/server/src/git/Layers/GitManager.test.ts+39-26| ... | ... | @@ -290,21 +290,12 @@ function runStackedAction( |
| 290 | 290 | cwd: string; |
| 291 | 291 | action: "commit" | "commit_push" | "commit_push_pr"; |
| 292 | 292 | commitMessage?: string; |
| 293 | featureBranch?: boolean; | |
| 293 | 294 | }, |
| 294 | 295 | ) { |
| 295 | 296 | return manager.runStackedAction(input); |
| 296 | 297 | } |
| 297 | 298 | |
| 298 | function suggestCommitAndBranch( | |
| 299 | manager: GitManagerShape, | |
| 300 | input: { | |
| 301 | cwd: string; | |
| 302 | commitMessage?: string; | |
| 303 | }, | |
| 304 | ) { | |
| 305 | return manager.suggestCommitAndBranch(input); | |
| 306 | } | |
| 307 | ||
| 308 | 299 | function makeManager(input?: { |
| 309 | 300 | ghScenario?: FakeGhScenario; |
| 310 | 301 | textGeneration?: Partial<FakeGitTextGeneration>; |
| ... | ... | @@ -492,6 +483,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 492 | 483 | action: "commit", |
| 493 | 484 | }); |
| 494 | 485 | |
| 486 | expect(result.branch.status).toBe("skipped_not_requested"); | |
| 495 | 487 | expect(result.commit.status).toBe("created"); |
| 496 | 488 | expect(result.push.status).toBe("skipped_not_requested"); |
| 497 | 489 | expect(result.pr.status).toBe("skipped_not_requested"); |
| ... | ... | @@ -529,6 +521,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 529 | 521 | commitMessage: "feat: custom summary line\n\n- details from user", |
| 530 | 522 | }); |
| 531 | 523 | |
| 524 | expect(result.branch.status).toBe("skipped_not_requested"); | |
| 532 | 525 | expect(result.commit.status).toBe("created"); |
| 533 | 526 | expect(result.commit.subject).toBe("feat: custom summary line"); |
| 534 | 527 | expect(generatedCount).toBe(0); |
| ... | ... | @@ -545,29 +538,39 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 545 | 538 | }), |
| 546 | 539 | ); |
| 547 | 540 | |
| 548 | it.effect("suggests commit message and semantic branch for dirty worktree", () => | |
| 541 | it.effect("creates feature branch, commits, and pushes with featureBranch option", () => | |
| 549 | 542 | Effect.gen(function* () { |
| 550 | 543 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| 551 | 544 | yield* initRepo(repoDir); |
| 552 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nsuggest\n"); | |
| 545 | const remoteDir = yield* createBareRemote(); | |
| 546 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 547 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | |
| 548 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n"); | |
| 553 | 549 | |
| 554 | 550 | const { manager } = yield* makeManager(); |
| 555 | const suggestion = yield* suggestCommitAndBranch(manager, { | |
| 551 | const result = yield* runStackedAction(manager, { | |
| 556 | 552 | cwd: repoDir, |
| 553 | action: "commit_push", | |
| 554 | featureBranch: true, | |
| 557 | 555 | }); |
| 558 | 556 | |
| 559 | expect(suggestion).toEqual({ | |
| 560 | commitMessage: "Implement stacked git actions", | |
| 561 | branch: "feature/implement-stacked-git-actions", | |
| 562 | }); | |
| 557 | expect(result.branch.status).toBe("created"); | |
| 558 | expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); | |
| 559 | expect(result.commit.status).toBe("created"); | |
| 560 | expect(result.push.status).toBe("pushed"); | |
| 561 | expect( | |
| 562 | yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( | |
| 563 | Effect.map((result) => result.stdout.trim()), | |
| 564 | ), | |
| 565 | ).toBe("feature/implement-stacked-git-actions"); | |
| 563 | 566 | }), |
| 564 | 567 | ); |
| 565 | 568 | |
| 566 | it.effect("suggests branch from custom commit message without AI generation", () => | |
| 569 | it.effect("featureBranch uses custom commit message and derives branch name", () => | |
| 567 | 570 | Effect.gen(function* () { |
| 568 | 571 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| 569 | 572 | yield* initRepo(repoDir); |
| 570 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-suggest\n"); | |
| 573 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature\n"); | |
| 571 | 574 | let generatedCount = 0; |
| 572 | 575 | |
| 573 | 576 | const { manager } = yield* makeManager({ |
| ... | ... | @@ -583,15 +586,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 583 | 586 | }), |
| 584 | 587 | }, |
| 585 | 588 | }); |
| 586 | const suggestion = yield* suggestCommitAndBranch(manager, { | |
| 589 | const result = yield* runStackedAction(manager, { | |
| 587 | 590 | cwd: repoDir, |
| 591 | action: "commit", | |
| 592 | featureBranch: true, | |
| 588 | 593 | commitMessage: "feat: custom summary line\n\n- details from user", |
| 589 | 594 | }); |
| 590 | 595 | |
| 591 | expect(suggestion).toEqual({ | |
| 592 | commitMessage: "feat: custom summary line\n\n- details from user", | |
| 593 | branch: "feature/feat-custom-summary-line", | |
| 594 | }); | |
| 596 | expect(result.branch.status).toBe("created"); | |
| 597 | expect(result.branch.name).toBe("feature/feat-custom-summary-line"); | |
| 598 | expect(result.commit.status).toBe("created"); | |
| 599 | expect(result.commit.subject).toBe("feat: custom summary line"); | |
| 595 | 600 | expect(generatedCount).toBe(0); |
| 596 | 601 | }), |
| 597 | 602 | ); |
| ... | ... | @@ -607,20 +612,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 607 | 612 | action: "commit", |
| 608 | 613 | }); |
| 609 | 614 | |
| 615 | expect(result.branch.status).toBe("skipped_not_requested"); | |
| 610 | 616 | expect(result.commit.status).toBe("skipped_no_changes"); |
| 611 | 617 | expect(result.push.status).toBe("skipped_not_requested"); |
| 612 | 618 | expect(result.pr.status).toBe("skipped_not_requested"); |
| 613 | 619 | }), |
| 614 | 620 | ); |
| 615 | 621 | |
| 616 | it.effect("returns a clear error when suggesting from clean worktree", () => | |
| 622 | it.effect("featureBranch returns error when worktree is clean", () => | |
| 617 | 623 | Effect.gen(function* () { |
| 618 | 624 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| 619 | 625 | yield* initRepo(repoDir); |
| 620 | 626 | |
| 621 | 627 | const { manager } = yield* makeManager(); |
| 622 | const errorMessage = yield* suggestCommitAndBranch(manager, { | |
| 628 | const errorMessage = yield* runStackedAction(manager, { | |
| 623 | 629 | cwd: repoDir, |
| 630 | action: "commit", | |
| 631 | featureBranch: true, | |
| 624 | 632 | }).pipe( |
| 625 | 633 | Effect.flip, |
| 626 | 634 | Effect.map((error) => error.message), |
| ... | ... | @@ -645,6 +653,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 645 | 653 | action: "commit_push", |
| 646 | 654 | }); |
| 647 | 655 | |
| 656 | expect(result.branch.status).toBe("skipped_not_requested"); | |
| 648 | 657 | expect(result.commit.status).toBe("created"); |
| 649 | 658 | expect(result.push.status).toBe("pushed"); |
| 650 | 659 | expect(result.push.setUpstream).toBe(true); |
| ... | ... | @@ -689,6 +698,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 689 | 698 | action: "commit_push_pr", |
| 690 | 699 | }); |
| 691 | 700 | |
| 701 | expect(result.branch.status).toBe("skipped_not_requested"); | |
| 692 | 702 | expect(result.commit.status).toBe("created"); |
| 693 | 703 | expect(result.push.status).toBe("pushed"); |
| 694 | 704 | expect(result.push.setUpstream).toBe(true); |
| ... | ... | @@ -721,6 +731,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 721 | 731 | action: "commit_push", |
| 722 | 732 | }); |
| 723 | 733 | |
| 734 | expect(result.branch.status).toBe("skipped_not_requested"); | |
| 724 | 735 | expect(result.commit.status).toBe("skipped_no_changes"); |
| 725 | 736 | expect(result.push.status).toBe("skipped_up_to_date"); |
| 726 | 737 | }), |
| ... | ... | @@ -755,6 +766,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 755 | 766 | action: "commit_push_pr", |
| 756 | 767 | }); |
| 757 | 768 | |
| 769 | expect(result.branch.status).toBe("skipped_not_requested"); | |
| 758 | 770 | expect(result.pr.status).toBe("opened_existing"); |
| 759 | 771 | expect(result.pr.number).toBe(42); |
| 760 | 772 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); |
| ... | ... | @@ -795,6 +807,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 795 | 807 | action: "commit_push_pr", |
| 796 | 808 | }); |
| 797 | 809 | |
| 810 | expect(result.branch.status).toBe("skipped_not_requested"); | |
| 798 | 811 | expect(result.pr.status).toBe("created"); |
| 799 | 812 | expect(result.pr.number).toBe(88); |
| 800 | 813 | expect( |
apps/server/src/git/Layers/GitManager.ts+41-19| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | 2 | |
| 3 | 3 | import { Effect, FileSystem, Layer, Path } from "effect"; |
| 4 | import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; | |
| 4 | import { resolveAutoFeatureBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | |
| 5 | 5 | |
| 6 | 6 | import { GitManagerError } from "../Errors.ts"; |
| 7 | 7 | import { GitManager, type GitManagerShape } from "../Services/GitManager.ts"; |
| ... | ... | @@ -431,28 +431,33 @@ export const makeGitManager = Effect.gen(function* () { |
| 431 | 431 | }; |
| 432 | 432 | }); |
| 433 | 433 | |
| 434 | const suggestCommitAndBranch: GitManagerShape["suggestCommitAndBranch"] = Effect.fnUntraced( | |
| 435 | function* (input) { | |
| 436 | const details = yield* gitCore.statusDetails(input.cwd); | |
| 434 | const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) => | |
| 435 | Effect.gen(function* () { | |
| 437 | 436 | const suggestion = yield* resolveCommitAndBranchSuggestion({ |
| 438 | cwd: input.cwd, | |
| 439 | branch: details.branch, | |
| 440 | ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), | |
| 437 | cwd, | |
| 438 | branch, | |
| 439 | ...(commitMessage ? { commitMessage } : {}), | |
| 441 | 440 | includeBranch: true, |
| 442 | 441 | }); |
| 443 | 442 | if (!suggestion) { |
| 444 | 443 | return yield* gitManagerError( |
| 445 | "suggestCommitAndBranch", | |
| 446 | "Cannot suggest a commit and branch because there are no changes to commit.", | |
| 444 | "runFeatureBranchStep", | |
| 445 | "Cannot create a feature branch because there are no changes to commit.", | |
| 447 | 446 | ); |
| 448 | 447 | } |
| 449 | 448 | |
| 449 | const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); | |
| 450 | const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd); | |
| 451 | const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); | |
| 452 | ||
| 453 | yield* gitCore.createBranch({ cwd, branch: resolvedBranch }); | |
| 454 | yield* gitCore.checkoutBranch({ cwd, branch: resolvedBranch }); | |
| 455 | ||
| 450 | 456 | return { |
| 451 | commitMessage: suggestion.commitMessage, | |
| 452 | branch: suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject), | |
| 457 | branchStep: { status: "created" as const, name: resolvedBranch }, | |
| 458 | resolvedCommitMessage: suggestion.commitMessage, | |
| 453 | 459 | }; |
| 454 | }, | |
| 455 | ); | |
| 460 | }); | |
| 456 | 461 | |
| 457 | 462 | const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced( |
| 458 | 463 | function* (input) { |
| ... | ... | @@ -460,28 +465,46 @@ export const makeGitManager = Effect.gen(function* () { |
| 460 | 465 | const wantsPr = input.action === "commit_push_pr"; |
| 461 | 466 | |
| 462 | 467 | const initialStatus = yield* gitCore.statusDetails(input.cwd); |
| 463 | if (wantsPush && !initialStatus.branch) { | |
| 468 | if (!input.featureBranch && wantsPush && !initialStatus.branch) { | |
| 464 | 469 | return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); |
| 465 | 470 | } |
| 466 | if (wantsPr && !initialStatus.branch) { | |
| 471 | if (!input.featureBranch && wantsPr && !initialStatus.branch) { | |
| 467 | 472 | return yield* gitManagerError( |
| 468 | 473 | "runStackedAction", |
| 469 | 474 | "Cannot create a pull request from detached HEAD.", |
| 470 | 475 | ); |
| 471 | 476 | } |
| 472 | 477 | |
| 473 | const commit = yield* runCommitStep(input.cwd, initialStatus.branch, input.commitMessage); | |
| 478 | let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; | |
| 479 | let commitMessageForStep = input.commitMessage; | |
| 480 | ||
| 481 | if (input.featureBranch) { | |
| 482 | const result = yield* runFeatureBranchStep( | |
| 483 | input.cwd, | |
| 484 | initialStatus.branch, | |
| 485 | input.commitMessage, | |
| 486 | ); | |
| 487 | branchStep = result.branchStep; | |
| 488 | commitMessageForStep = result.resolvedCommitMessage; | |
| 489 | } else { | |
| 490 | branchStep = { status: "skipped_not_requested" as const }; | |
| 491 | } | |
| 492 | ||
| 493 | const currentBranch = branchStep.name ?? initialStatus.branch; | |
| 494 | ||
| 495 | const commit = yield* runCommitStep(input.cwd, currentBranch, commitMessageForStep); | |
| 474 | 496 | |
| 475 | 497 | const push = wantsPush |
| 476 | ? yield* gitCore.pushCurrentBranch(input.cwd, initialStatus.branch) | |
| 498 | ? yield* gitCore.pushCurrentBranch(input.cwd, currentBranch) | |
| 477 | 499 | : { status: "skipped_not_requested" as const }; |
| 478 | 500 | |
| 479 | 501 | const pr = wantsPr |
| 480 | ? yield* runPrStep(input.cwd, initialStatus.branch) | |
| 502 | ? yield* runPrStep(input.cwd, currentBranch) | |
| 481 | 503 | : { status: "skipped_not_requested" as const }; |
| 482 | 504 | |
| 483 | 505 | return { |
| 484 | 506 | action: input.action, |
| 507 | branch: branchStep, | |
| 485 | 508 | commit, |
| 486 | 509 | push, |
| 487 | 510 | pr, |
| ... | ... | @@ -492,7 +515,6 @@ export const makeGitManager = Effect.gen(function* () { |
| 492 | 515 | return { |
| 493 | 516 | status, |
| 494 | 517 | runStackedAction, |
| 495 | suggestCommitAndBranch, | |
| 496 | 518 | } satisfies GitManagerShape; |
| 497 | 519 | }); |
| 498 | 520 |
apps/server/src/git/Services/GitCore.ts+5| ... | ... | @@ -157,6 +157,11 @@ export interface GitCoreShape { |
| 157 | 157 | * Initialize a repository in the provided directory. |
| 158 | 158 | */ |
| 159 | 159 | readonly initRepo: (input: GitInitInput) => Effect.Effect<void, GitCommandError>; |
| 160 | ||
| 161 | /** | |
| 162 | * List local branch names (short format). | |
| 163 | */ | |
| 164 | readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>; | |
| 160 | 165 | } |
| 161 | 166 | |
| 162 | 167 | /** |
apps/server/src/git/Services/GitManager.ts+1-9| ... | ... | @@ -9,8 +9,6 @@ |
| 9 | 9 | import { |
| 10 | 10 | GitRunStackedActionInput, |
| 11 | 11 | GitRunStackedActionResult, |
| 12 | GitSuggestCommitAndBranchInput, | |
| 13 | GitSuggestCommitAndBranchResult, | |
| 14 | 12 | GitStatusInput, |
| 15 | 13 | GitStatusResult, |
| 16 | 14 | } from "@t3tools/contracts"; |
| ... | ... | @@ -31,17 +29,11 @@ export interface GitManagerShape { |
| 31 | 29 | |
| 32 | 30 | /** |
| 33 | 31 | * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). |
| 32 | * When `featureBranch` is set, creates and checks out a feature branch first. | |
| 34 | 33 | */ |
| 35 | 34 | readonly runStackedAction: ( |
| 36 | 35 | input: GitRunStackedActionInput, |
| 37 | 36 | ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>; |
| 38 | ||
| 39 | /** | |
| 40 | * Suggest a semantic branch name and commit message for current staged changes. | |
| 41 | */ | |
| 42 | readonly suggestCommitAndBranch: ( | |
| 43 | input: GitSuggestCommitAndBranchInput, | |
| 44 | ) => Effect.Effect<GitSuggestCommitAndBranchResult, GitManagerServiceError>; | |
| 45 | 37 | } |
| 46 | 38 | |
| 47 | 39 | /** |
apps/server/src/wsServer.test.ts+1-31| ... | ... | @@ -1555,8 +1555,7 @@ describe("WebSocket Server", () => { |
| 1555 | 1555 | |
| 1556 | 1556 | const status = vi.fn(() => Effect.succeed(statusResult)); |
| 1557 | 1557 | const runStackedAction = vi.fn(() => Effect.void as any); |
| 1558 | const suggestCommitAndBranch = vi.fn(() => Effect.void as any); | |
| 1559 | const gitManager: GitManagerShape = { status, runStackedAction, suggestCommitAndBranch }; | |
| 1558 | const gitManager: GitManagerShape = { status, runStackedAction }; | |
| 1560 | 1559 | |
| 1561 | 1560 | server = await createTestServer({ cwd: "/test", gitManager }); |
| 1562 | 1561 | const addr = server.address(); |
| ... | ... | @@ -1586,7 +1585,6 @@ describe("WebSocket Server", () => { |
| 1586 | 1585 | const gitManager: GitManagerShape = { |
| 1587 | 1586 | status: vi.fn(() => Effect.void as any), |
| 1588 | 1587 | runStackedAction, |
| 1589 | suggestCommitAndBranch: vi.fn(() => Effect.void as any), | |
| 1590 | 1588 | }; |
| 1591 | 1589 | |
| 1592 | 1590 | server = await createTestServer({ cwd: "/test", gitManager }); |
| ... | ... | @@ -1609,34 +1607,6 @@ describe("WebSocket Server", () => { |
| 1609 | 1607 | }); |
| 1610 | 1608 | }); |
| 1611 | 1609 | |
| 1612 | it("supports git.suggestCommitAndBranch over websocket", async () => { | |
| 1613 | const suggestCommitAndBranchResult = { | |
| 1614 | commitMessage: "feat: improve git actions", | |
| 1615 | branch: "feature/improve-git-actions", | |
| 1616 | }; | |
| 1617 | const suggestCommitAndBranch = vi.fn(() => Effect.succeed(suggestCommitAndBranchResult)); | |
| 1618 | const gitManager: GitManagerShape = { | |
| 1619 | status: vi.fn(() => Effect.void as any), | |
| 1620 | runStackedAction: vi.fn(() => Effect.void as any), | |
| 1621 | suggestCommitAndBranch, | |
| 1622 | }; | |
| 1623 | ||
| 1624 | server = await createTestServer({ cwd: "/test", gitManager }); | |
| 1625 | const addr = server.address(); | |
| 1626 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| 1627 | ||
| 1628 | const ws = await connectWs(port); | |
| 1629 | connections.push(ws); | |
| 1630 | await waitForMessage(ws); | |
| 1631 | ||
| 1632 | const response = await sendRequest(ws, WS_METHODS.gitSuggestCommitAndBranch, { | |
| 1633 | cwd: "/test", | |
| 1634 | }); | |
| 1635 | expect(response.error).toBeUndefined(); | |
| 1636 | expect(response.result).toEqual(suggestCommitAndBranchResult); | |
| 1637 | expect(suggestCommitAndBranch).toHaveBeenCalledWith({ cwd: "/test" }); | |
| 1638 | }); | |
| 1639 | ||
| 1640 | 1610 | it("rejects websocket connections without a valid auth token", async () => { |
| 1641 | 1611 | server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); |
| 1642 | 1612 | const addr = server.address(); |
apps/server/src/wsServer.ts-5| ... | ... | @@ -668,11 +668,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 668 | 668 | return yield* gitManager.runStackedAction(body); |
| 669 | 669 | } |
| 670 | 670 | |
| 671 | case WS_METHODS.gitSuggestCommitAndBranch: { | |
| 672 | const body = stripRequestTag(request.body); | |
| 673 | return yield* gitManager.suggestCommitAndBranch(body); | |
| 674 | } | |
| 675 | ||
| 676 | 671 | case WS_METHODS.gitListBranches: { |
| 677 | 672 | const body = stripRequestTag(request.body); |
| 678 | 673 | return yield* git.listBranches(body); |
apps/web/src/components/GitActionsControl.logic.test.ts+4| ... | ... | @@ -823,6 +823,7 @@ describe("summarizeGitResult", () => { |
| 823 | 823 | it("returns commit-focused toast for commit action", () => { |
| 824 | 824 | const result = summarizeGitResult({ |
| 825 | 825 | action: "commit", |
| 826 | branch: { status: "skipped_not_requested" }, | |
| 826 | 827 | commit: { |
| 827 | 828 | status: "created", |
| 828 | 829 | commitSha: "0123456789abcdef", |
| ... | ... | @@ -841,6 +842,7 @@ describe("summarizeGitResult", () => { |
| 841 | 842 | it("returns push-focused toast for push action", () => { |
| 842 | 843 | const result = summarizeGitResult({ |
| 843 | 844 | action: "commit_push", |
| 845 | branch: { status: "skipped_not_requested" }, | |
| 844 | 846 | commit: { |
| 845 | 847 | status: "created", |
| 846 | 848 | commitSha: "abcdef0123456789", |
| ... | ... | @@ -863,6 +865,7 @@ describe("summarizeGitResult", () => { |
| 863 | 865 | it("returns PR-focused toast for created PR action", () => { |
| 864 | 866 | const result = summarizeGitResult({ |
| 865 | 867 | action: "commit_push_pr", |
| 868 | branch: { status: "skipped_not_requested" }, | |
| 866 | 869 | commit: { |
| 867 | 870 | status: "created", |
| 868 | 871 | commitSha: "89abcdef01234567", |
| ... | ... | @@ -888,6 +891,7 @@ describe("summarizeGitResult", () => { |
| 888 | 891 | it("truncates long description text", () => { |
| 889 | 892 | const result = summarizeGitResult({ |
| 890 | 893 | action: "commit_push_pr", |
| 894 | branch: { status: "skipped_not_requested" }, | |
| 891 | 895 | commit: { |
| 892 | 896 | status: "created", |
| 893 | 897 | commitSha: "89abcdef01234567", |
apps/web/src/components/GitActionsControl.logic.ts+7-31| ... | ... | @@ -3,7 +3,6 @@ import type { |
| 3 | 3 | GitStackedAction, |
| 4 | 4 | GitStatusResult, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | import { sanitizeBranchFragment } from "@t3tools/shared/git"; | |
| 7 | 6 | |
| 8 | 7 | export type GitActionIconName = "commit" | "push" | "pr"; |
| 9 | 8 | |
| ... | ... | @@ -36,7 +35,6 @@ export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr"; |
| 36 | 35 | |
| 37 | 36 | const SHORT_SHA_LENGTH = 7; |
| 38 | 37 | const TOAST_DESCRIPTION_MAX = 72; |
| 39 | const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; | |
| 40 | 38 | |
| 41 | 39 | function shortenSha(sha: string | undefined): string | null { |
| 42 | 40 | if (!sha) return null; |
| ... | ... | @@ -59,7 +57,9 @@ export function buildGitActionProgressStages(input: { |
| 59 | 57 | hasWorkingTreeChanges: boolean; |
| 60 | 58 | forcePushOnly?: boolean; |
| 61 | 59 | pushTarget?: string; |
| 60 | featureBranch?: boolean; | |
| 62 | 61 | }): string[] { |
| 62 | const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; | |
| 63 | 63 | const shouldIncludeCommitStages = |
| 64 | 64 | !input.forcePushOnly && (input.action === "commit" || input.hasWorkingTreeChanges); |
| 65 | 65 | const commitStages = !shouldIncludeCommitStages |
| ... | ... | @@ -69,12 +69,12 @@ export function buildGitActionProgressStages(input: { |
| 69 | 69 | : ["Generating commit message...", "Committing..."]; |
| 70 | 70 | const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; |
| 71 | 71 | if (input.action === "commit") { |
| 72 | return commitStages; | |
| 72 | return [...branchStages, ...commitStages]; | |
| 73 | 73 | } |
| 74 | 74 | if (input.action === "commit_push") { |
| 75 | return [...commitStages, pushStage]; | |
| 75 | return [...branchStages, ...commitStages, pushStage]; | |
| 76 | 76 | } |
| 77 | return [...commitStages, pushStage, "Creating PR..."]; | |
| 77 | return [...branchStages, ...commitStages, pushStage, "Creating PR..."]; | |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | 80 | const withDescription = (title: string, description: string | undefined) => |
| ... | ... | @@ -322,29 +322,5 @@ export function resolveDefaultBranchActionDialogCopy(input: { |
| 322 | 322 | }; |
| 323 | 323 | } |
| 324 | 324 | |
| 325 | export function resolveAutoFeatureBranchName( | |
| 326 | existingBranchNames: readonly string[], | |
| 327 | preferredBranch?: string, | |
| 328 | ): string { | |
| 329 | const preferred = preferredBranch?.trim(); | |
| 330 | const normalized = sanitizeBranchFragment( | |
| 331 | preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK, | |
| 332 | ); | |
| 333 | const resolvedBase = normalized.includes("/") | |
| 334 | ? normalized.startsWith("feature/") | |
| 335 | ? normalized | |
| 336 | : `feature/${normalized}` | |
| 337 | : `feature/${normalized}`; | |
| 338 | const existingNames = new Set(existingBranchNames); | |
| 339 | ||
| 340 | if (!existingNames.has(resolvedBase)) { | |
| 341 | return resolvedBase; | |
| 342 | } | |
| 343 | ||
| 344 | let suffix = 2; | |
| 345 | while (existingNames.has(`${resolvedBase}-${suffix}`)) { | |
| 346 | suffix += 1; | |
| 347 | } | |
| 348 | ||
| 349 | return `${resolvedBase}-${suffix}`; | |
| 350 | } | |
| 325 | // Re-export from shared for backwards compatibility in this module's exports | |
| 326 | export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; |
apps/web/src/components/GitActionsControl.tsx+26-129| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { type GitStatusResult, type GitStackedAction, type ThreadId } from "@t3tools/contracts"; | |
| 1 | import type { GitStackedAction, GitStatusResult, ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | 3 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 4 | 4 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; |
| ... | ... | @@ -11,7 +11,6 @@ import { |
| 11 | 11 | type GitQuickAction, |
| 12 | 12 | type DefaultBranchConfirmableAction, |
| 13 | 13 | requiresDefaultBranchConfirmation, |
| 14 | resolveAutoFeatureBranchName, | |
| 15 | 14 | resolveDefaultBranchActionDialogCopy, |
| 16 | 15 | resolveQuickAction, |
| 17 | 16 | summarizeGitResult, |
| ... | ... | @@ -34,12 +33,10 @@ import { Textarea } from "~/components/ui/textarea"; |
| 34 | 33 | import { toastManager } from "~/components/ui/toast"; |
| 35 | 34 | import { |
| 36 | 35 | gitBranchesQueryOptions, |
| 37 | gitCreateBranchAndCheckoutMutationOptions, | |
| 38 | 36 | gitInitMutationOptions, |
| 39 | 37 | gitMutationKeys, |
| 40 | 38 | gitPullMutationOptions, |
| 41 | 39 | gitRunStackedActionMutationOptions, |
| 42 | gitSuggestCommitAndBranchMutationOptions, | |
| 43 | 40 | gitStatusQueryOptions, |
| 44 | 41 | invalidateGitQueries, |
| 45 | 42 | } from "~/lib/gitReactQuery"; |
| ... | ... | @@ -60,8 +57,6 @@ interface PendingDefaultBranchAction { |
| 60 | 57 | onConfirmed?: () => void; |
| 61 | 58 | } |
| 62 | 59 | |
| 63 | type GitActionToastId = ReturnType<typeof toastManager.add>; | |
| 64 | ||
| 65 | 60 | function getMenuActionDisabledReason( |
| 66 | 61 | item: GitActionMenuItem, |
| 67 | 62 | gitStatus: GitStatusResult | null, |
| ... | ... | @@ -173,26 +168,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 173 | 168 | const runImmediateGitActionMutation = useMutation( |
| 174 | 169 | gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), |
| 175 | 170 | ); |
| 176 | const suggestCommitAndBranchMutation = useMutation( | |
| 177 | gitSuggestCommitAndBranchMutationOptions({ cwd: gitCwd }), | |
| 178 | ); | |
| 179 | const createBranchAndCheckoutMutation = useMutation( | |
| 180 | gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }), | |
| 181 | ); | |
| 182 | 171 | const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); |
| 183 | 172 | |
| 184 | 173 | const isRunStackedActionRunning = |
| 185 | 174 | useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; |
| 186 | const isSuggestCommitAndBranchRunning = | |
| 187 | useIsMutating({ mutationKey: gitMutationKeys.suggestCommitAndBranch(gitCwd) }) > 0; | |
| 188 | const isCreateBranchAndCheckoutRunning = | |
| 189 | useIsMutating({ mutationKey: gitMutationKeys.createBranchAndCheckout(gitCwd) }) > 0; | |
| 190 | 175 | const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; |
| 191 | const isGitActionRunning = | |
| 192 | isRunStackedActionRunning || | |
| 193 | isSuggestCommitAndBranchRunning || | |
| 194 | isCreateBranchAndCheckoutRunning || | |
| 195 | isPullRunning; | |
| 176 | const isGitActionRunning = isRunStackedActionRunning || isPullRunning; | |
| 196 | 177 | const isDefaultBranch = useMemo(() => { |
| 197 | 178 | const branchName = gitStatusForActions?.branch; |
| 198 | 179 | if (!branchName) return false; |
| ... | ... | @@ -255,26 +236,23 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 255 | 236 | forcePushOnlyProgress = false, |
| 256 | 237 | onConfirmed, |
| 257 | 238 | skipDefaultBranchPrompt = false, |
| 258 | statusOverride, | |
| 259 | isDefaultBranchOverride, | |
| 260 | progressToastId, | |
| 239 | featureBranch = false, | |
| 261 | 240 | }: { |
| 262 | 241 | action: GitStackedAction; |
| 263 | 242 | commitMessage?: string; |
| 264 | 243 | forcePushOnlyProgress?: boolean; |
| 265 | 244 | onConfirmed?: () => void; |
| 266 | 245 | skipDefaultBranchPrompt?: boolean; |
| 267 | statusOverride?: GitStatusResult | null; | |
| 268 | isDefaultBranchOverride?: boolean; | |
| 269 | progressToastId?: GitActionToastId; | |
| 246 | featureBranch?: boolean; | |
| 270 | 247 | }) => { |
| 271 | const actionStatus = statusOverride ?? gitStatusForActions; | |
| 272 | const actionIsDefaultBranch = isDefaultBranchOverride ?? isDefaultBranch; | |
| 248 | const actionStatus = gitStatusForActions; | |
| 249 | const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch; | |
| 273 | 250 | const actionBranch = actionStatus?.branch ?? null; |
| 274 | 251 | const includesCommit = |
| 275 | 252 | !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges); |
| 276 | 253 | if ( |
| 277 | 254 | !skipDefaultBranchPrompt && |
| 255 | !featureBranch && | |
| 278 | 256 | requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && |
| 279 | 257 | actionBranch |
| 280 | 258 | ) { |
| ... | ... | @@ -293,36 +271,26 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 293 | 271 | } |
| 294 | 272 | onConfirmed?.(); |
| 295 | 273 | |
| 296 | const pushTarget = actionBranch ? `origin/${actionBranch}` : undefined; | |
| 274 | const pushTarget = !featureBranch && actionBranch ? `origin/${actionBranch}` : undefined; | |
| 297 | 275 | const progressStages = buildGitActionProgressStages({ |
| 298 | 276 | action, |
| 299 | 277 | hasCustomCommitMessage: !!commitMessage?.trim(), |
| 300 | 278 | hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, |
| 301 | 279 | forcePushOnly: forcePushOnlyProgress, |
| 280 | featureBranch, | |
| 302 | 281 | ...(pushTarget ? { pushTarget } : {}), |
| 303 | 282 | }); |
| 304 | const resolvedProgressToastId = | |
| 305 | progressToastId ?? | |
| 306 | toastManager.add({ | |
| 307 | type: "loading", | |
| 308 | title: progressStages[0] ?? "Running git action...", | |
| 309 | timeout: 0, | |
| 310 | data: threadToastData, | |
| 311 | }); | |
| 312 | ||
| 313 | if (progressToastId) { | |
| 314 | toastManager.update(progressToastId, { | |
| 315 | type: "loading", | |
| 316 | title: progressStages[0] ?? "Running git action...", | |
| 317 | timeout: 0, | |
| 318 | data: threadToastData, | |
| 319 | }); | |
| 320 | } | |
| 283 | const progressToastId = toastManager.add({ | |
| 284 | type: "loading", | |
| 285 | title: progressStages[0] ?? "Running git action...", | |
| 286 | timeout: 0, | |
| 287 | data: threadToastData, | |
| 288 | }); | |
| 321 | 289 | |
| 322 | 290 | let stageIndex = 0; |
| 323 | 291 | const stageInterval = setInterval(() => { |
| 324 | 292 | stageIndex = Math.min(stageIndex + 1, progressStages.length - 1); |
| 325 | toastManager.update(resolvedProgressToastId, { | |
| 293 | toastManager.update(progressToastId, { | |
| 326 | 294 | title: progressStages[stageIndex] ?? "Running git action...", |
| 327 | 295 | type: "loading", |
| 328 | 296 | timeout: 0, |
| ... | ... | @@ -337,6 +305,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 337 | 305 | const promise = runImmediateGitActionMutation.mutateAsync({ |
| 338 | 306 | action, |
| 339 | 307 | ...(commitMessage ? { commitMessage } : {}), |
| 308 | ...(featureBranch ? { featureBranch } : {}), | |
| 340 | 309 | }); |
| 341 | 310 | |
| 342 | 311 | try { |
| ... | ... | @@ -357,10 +326,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 357 | 326 | result.push.status === "pushed" && |
| 358 | 327 | !actionIsDefaultBranch; |
| 359 | 328 | const closeResultToast = () => { |
| 360 | toastManager.close(resolvedProgressToastId); | |
| 329 | toastManager.close(progressToastId); | |
| 361 | 330 | }; |
| 362 | 331 | |
| 363 | toastManager.update(resolvedProgressToastId, { | |
| 332 | toastManager.update(progressToastId, { | |
| 364 | 333 | type: "success", |
| 365 | 334 | title: resultToast.title, |
| 366 | 335 | description: resultToast.description, |
| ... | ... | @@ -378,8 +347,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 378 | 347 | action: "commit_push", |
| 379 | 348 | forcePushOnlyProgress: true, |
| 380 | 349 | onConfirmed: closeResultToast, |
| 381 | statusOverride: actionStatus, | |
| 382 | isDefaultBranchOverride: actionIsDefaultBranch, | |
| 383 | 350 | }); |
| 384 | 351 | }, |
| 385 | 352 | }, |
| ... | ... | @@ -404,8 +371,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 404 | 371 | closeResultToast(); |
| 405 | 372 | void runGitActionWithToast({ |
| 406 | 373 | action: "commit_push_pr", |
| 407 | statusOverride: actionStatus, | |
| 408 | isDefaultBranchOverride: actionIsDefaultBranch, | |
| 409 | 374 | }); |
| 410 | 375 | }, |
| 411 | 376 | }, |
| ... | ... | @@ -414,7 +379,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 414 | 379 | }); |
| 415 | 380 | } catch (err) { |
| 416 | 381 | stopProgressUpdates(); |
| 417 | toastManager.update(resolvedProgressToastId, { | |
| 382 | toastManager.update(progressToastId, { | |
| 418 | 383 | type: "error", |
| 419 | 384 | title: "Action failed", |
| 420 | 385 | description: err instanceof Error ? err.message : "An error occurred.", |
| ... | ... | @@ -449,92 +414,24 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 449 | 414 | (actionParams: { |
| 450 | 415 | action: GitStackedAction; |
| 451 | 416 | commitMessage?: string; |
| 452 | forcePushOnlyProgress?: boolean; | |
| 453 | 417 | onConfirmed?: () => void; |
| 454 | 418 | }) => { |
| 455 | const existingBranchNames = branchList?.branches.map((branch) => branch.name) ?? []; | |
| 456 | const providedCommitMessage = actionParams.commitMessage?.trim() || undefined; | |
| 457 | const shouldSuggestCommitAndBranch = | |
| 458 | !providedCommitMessage && !!gitStatusForActions?.hasWorkingTreeChanges; | |
| 459 | ||
| 460 | const checkoutToastId = toastManager.add({ | |
| 461 | type: "loading", | |
| 462 | title: "Preparing feature branch...", | |
| 463 | timeout: 0, | |
| 464 | data: threadToastData, | |
| 419 | void runGitActionWithToast({ | |
| 420 | ...actionParams, | |
| 421 | featureBranch: true, | |
| 422 | skipDefaultBranchPrompt: true, | |
| 465 | 423 | }); |
| 466 | ||
| 467 | const suggestionPromise = shouldSuggestCommitAndBranch | |
| 468 | ? suggestCommitAndBranchMutation.mutateAsync({}).catch(() => null) | |
| 469 | : Promise.resolve(null); | |
| 470 | ||
| 471 | void suggestionPromise | |
| 472 | .then((suggestion) => { | |
| 473 | const preferredBranch = | |
| 474 | suggestion?.branch ?? | |
| 475 | (providedCommitMessage ? providedCommitMessage.split(/\r?\n/g)[0]?.trim() : undefined); | |
| 476 | const branchName = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); | |
| 477 | const resolvedCommitMessage = suggestion?.commitMessage ?? providedCommitMessage; | |
| 478 | ||
| 479 | toastManager.update(checkoutToastId, { | |
| 480 | type: "loading", | |
| 481 | title: `Creating ${branchName}...`, | |
| 482 | timeout: 0, | |
| 483 | data: threadToastData, | |
| 484 | }); | |
| 485 | ||
| 486 | return createBranchAndCheckoutMutation.mutateAsync(branchName).then(() => ({ | |
| 487 | branchName, | |
| 488 | resolvedCommitMessage, | |
| 489 | })); | |
| 490 | }) | |
| 491 | .then(({ branchName, resolvedCommitMessage }) => { | |
| 492 | const statusOverride = gitStatusForActions | |
| 493 | ? { | |
| 494 | ...gitStatusForActions, | |
| 495 | branch: branchName, | |
| 496 | pr: null, | |
| 497 | hasUpstream: false, | |
| 498 | aheadCount: 0, | |
| 499 | behindCount: 0, | |
| 500 | } | |
| 501 | : null; | |
| 502 | return runGitActionWithToast({ | |
| 503 | ...actionParams, | |
| 504 | ...(resolvedCommitMessage ? { commitMessage: resolvedCommitMessage } : {}), | |
| 505 | skipDefaultBranchPrompt: true, | |
| 506 | statusOverride, | |
| 507 | isDefaultBranchOverride: false, | |
| 508 | progressToastId: checkoutToastId, | |
| 509 | }); | |
| 510 | }) | |
| 511 | .catch((error) => { | |
| 512 | toastManager.update(checkoutToastId, { | |
| 513 | type: "error", | |
| 514 | title: "Failed to checkout feature branch", | |
| 515 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 516 | data: threadToastData, | |
| 517 | }); | |
| 518 | }); | |
| 519 | 424 | }, |
| 520 | [ | |
| 521 | branchList?.branches, | |
| 522 | createBranchAndCheckoutMutation, | |
| 523 | gitStatusForActions, | |
| 524 | runGitActionWithToast, | |
| 525 | suggestCommitAndBranchMutation, | |
| 526 | threadToastData, | |
| 527 | ], | |
| 425 | [runGitActionWithToast], | |
| 528 | 426 | ); |
| 529 | 427 | |
| 530 | 428 | const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { |
| 531 | 429 | if (!pendingDefaultBranchAction) return; |
| 532 | const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction; | |
| 430 | const { action, commitMessage, onConfirmed } = pendingDefaultBranchAction; | |
| 533 | 431 | setPendingDefaultBranchAction(null); |
| 534 | 432 | checkoutNewBranchAndRunAction({ |
| 535 | 433 | action, |
| 536 | 434 | ...(commitMessage ? { commitMessage } : {}), |
| 537 | forcePushOnlyProgress, | |
| 538 | 435 | ...(onConfirmed ? { onConfirmed } : {}), |
| 539 | 436 | }); |
| 540 | 437 | }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]); |
apps/web/src/lib/gitReactQuery.test.ts-12| ... | ... | @@ -4,7 +4,6 @@ import { |
| 4 | 4 | gitMutationKeys, |
| 5 | 5 | gitPullMutationOptions, |
| 6 | 6 | gitRunStackedActionMutationOptions, |
| 7 | gitSuggestCommitAndBranchMutationOptions, | |
| 8 | 7 | } from "./gitReactQuery"; |
| 9 | 8 | |
| 10 | 9 | describe("gitMutationKeys", () => { |
| ... | ... | @@ -17,12 +16,6 @@ describe("gitMutationKeys", () => { |
| 17 | 16 | it("scopes pull keys by cwd", () => { |
| 18 | 17 | expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b")); |
| 19 | 18 | }); |
| 20 | ||
| 21 | it("scopes suggest-commit-and-branch keys by cwd", () => { | |
| 22 | expect(gitMutationKeys.suggestCommitAndBranch("/repo/a")).not.toEqual( | |
| 23 | gitMutationKeys.suggestCommitAndBranch("/repo/b"), | |
| 24 | ); | |
| 25 | }); | |
| 26 | 19 | }); |
| 27 | 20 | |
| 28 | 21 | describe("git mutation options", () => { |
| ... | ... | @@ -37,9 +30,4 @@ describe("git mutation options", () => { |
| 37 | 30 | const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); |
| 38 | 31 | expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); |
| 39 | 32 | }); |
| 40 | ||
| 41 | it("attaches cwd-scoped mutation key for suggestCommitAndBranch", () => { | |
| 42 | const options = gitSuggestCommitAndBranchMutationOptions({ cwd: "/repo/a" }); | |
| 43 | expect(options.mutationKey).toEqual(gitMutationKeys.suggestCommitAndBranch("/repo/a")); | |
| 44 | }); | |
| 45 | 33 | }); |
apps/web/src/lib/gitReactQuery.ts+3-16| ... | ... | @@ -19,8 +19,6 @@ export const gitMutationKeys = { |
| 19 | 19 | createBranchAndCheckout: (cwd: string | null) => |
| 20 | 20 | ["git", "mutation", "create-branch-and-checkout", cwd] as const, |
| 21 | 21 | runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, |
| 22 | suggestCommitAndBranch: (cwd: string | null) => | |
| 23 | ["git", "mutation", "suggest-commit-and-branch", cwd] as const, | |
| 24 | 22 | pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, |
| 25 | 23 | }; |
| 26 | 24 | |
| ... | ... | @@ -118,9 +116,11 @@ export function gitRunStackedActionMutationOptions(input: { |
| 118 | 116 | mutationFn: async ({ |
| 119 | 117 | action, |
| 120 | 118 | commitMessage, |
| 119 | featureBranch, | |
| 121 | 120 | }: { |
| 122 | 121 | action: GitStackedAction; |
| 123 | 122 | commitMessage?: string; |
| 123 | featureBranch?: boolean; | |
| 124 | 124 | }) => { |
| 125 | 125 | const api = ensureNativeApi(); |
| 126 | 126 | if (!input.cwd) throw new Error("Git action is unavailable."); |
| ... | ... | @@ -128,6 +128,7 @@ export function gitRunStackedActionMutationOptions(input: { |
| 128 | 128 | cwd: input.cwd, |
| 129 | 129 | action, |
| 130 | 130 | ...(commitMessage ? { commitMessage } : {}), |
| 131 | ...(featureBranch ? { featureBranch } : {}), | |
| 131 | 132 | }); |
| 132 | 133 | }, |
| 133 | 134 | onSettled: async () => { |
| ... | ... | @@ -136,20 +137,6 @@ export function gitRunStackedActionMutationOptions(input: { |
| 136 | 137 | }); |
| 137 | 138 | } |
| 138 | 139 | |
| 139 | export function gitSuggestCommitAndBranchMutationOptions(input: { cwd: string | null }) { | |
| 140 | return mutationOptions({ | |
| 141 | mutationKey: gitMutationKeys.suggestCommitAndBranch(input.cwd), | |
| 142 | mutationFn: async ({ commitMessage }: { commitMessage?: string }) => { | |
| 143 | const api = ensureNativeApi(); | |
| 144 | if (!input.cwd) throw new Error("Git action is unavailable."); | |
| 145 | return api.git.suggestCommitAndBranch({ | |
| 146 | cwd: input.cwd, | |
| 147 | ...(commitMessage ? { commitMessage } : {}), | |
| 148 | }); | |
| 149 | }, | |
| 150 | }); | |
| 151 | } | |
| 152 | ||
| 153 | 140 | export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { |
| 154 | 141 | return mutationOptions({ |
| 155 | 142 | mutationKey: gitMutationKeys.pull(input.cwd), |
apps/web/src/wsNativeApi.test.ts-1| ... | ... | @@ -6,7 +6,6 @@ import { |
| 6 | 6 | ProjectId, |
| 7 | 7 | ThreadId, |
| 8 | 8 | WS_CHANNELS, |
| 9 | WS_METHODS, | |
| 10 | 9 | } from "@t3tools/contracts"; |
| 11 | 10 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 12 | 11 |
apps/web/src/wsNativeApi.ts-2| ... | ... | @@ -163,8 +163,6 @@ export function createWsNativeApi(): NativeApi { |
| 163 | 163 | pull: (input) => transport.request(WS_METHODS.gitPull, input), |
| 164 | 164 | status: (input) => transport.request(WS_METHODS.gitStatus, input), |
| 165 | 165 | runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), |
| 166 | suggestCommitAndBranch: (input) => | |
| 167 | transport.request(WS_METHODS.gitSuggestCommitAndBranch, input), | |
| 168 | 166 | listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), |
| 169 | 167 | createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), |
| 170 | 168 | removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), |
packages/contracts/src/git.ts+6-12| ... | ... | @@ -13,6 +13,7 @@ export const GitPushStepStatus = Schema.Literals([ |
| 13 | 13 | "skipped_not_requested", |
| 14 | 14 | "skipped_up_to_date", |
| 15 | 15 | ]); |
| 16 | export const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]); | |
| 16 | 17 | export const GitPrStepStatus = Schema.Literals([ |
| 17 | 18 | "created", |
| 18 | 19 | "opened_existing", |
| ... | ... | @@ -53,15 +54,10 @@ export const GitRunStackedActionInput = Schema.Struct({ |
| 53 | 54 | cwd: TrimmedNonEmptyStringSchema, |
| 54 | 55 | action: GitStackedAction, |
| 55 | 56 | commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), |
| 57 | featureBranch: Schema.optional(Schema.Boolean), | |
| 56 | 58 | }); |
| 57 | 59 | export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; |
| 58 | 60 | |
| 59 | export const GitSuggestCommitAndBranchInput = Schema.Struct({ | |
| 60 | cwd: TrimmedNonEmptyStringSchema, | |
| 61 | commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), | |
| 62 | }); | |
| 63 | export type GitSuggestCommitAndBranchInput = typeof GitSuggestCommitAndBranchInput.Type; | |
| 64 | ||
| 65 | 61 | export const GitListBranchesInput = Schema.Struct({ |
| 66 | 62 | cwd: TrimmedNonEmptyStringSchema, |
| 67 | 63 | }); |
| ... | ... | @@ -145,6 +141,10 @@ export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; |
| 145 | 141 | |
| 146 | 142 | export const GitRunStackedActionResult = Schema.Struct({ |
| 147 | 143 | action: GitStackedAction, |
| 144 | branch: Schema.Struct({ | |
| 145 | status: GitBranchStepStatus, | |
| 146 | name: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 147 | }), | |
| 148 | 148 | commit: Schema.Struct({ |
| 149 | 149 | status: GitCommitStepStatus, |
| 150 | 150 | commitSha: Schema.optional(TrimmedNonEmptyStringSchema), |
| ... | ... | @@ -167,12 +167,6 @@ export const GitRunStackedActionResult = Schema.Struct({ |
| 167 | 167 | }); |
| 168 | 168 | export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; |
| 169 | 169 | |
| 170 | export const GitSuggestCommitAndBranchResult = Schema.Struct({ | |
| 171 | commitMessage: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000)), | |
| 172 | branch: TrimmedNonEmptyStringSchema, | |
| 173 | }); | |
| 174 | export type GitSuggestCommitAndBranchResult = typeof GitSuggestCommitAndBranchResult.Type; | |
| 175 | ||
| 176 | 170 | export const GitPullResult = Schema.Struct({ |
| 177 | 171 | status: Schema.Literals(["pulled", "skipped_up_to_date"]), |
| 178 | 172 | branch: TrimmedNonEmptyStringSchema, |
packages/contracts/src/ipc.ts-5| ... | ... | @@ -11,8 +11,6 @@ import type { |
| 11 | 11 | GitRemoveWorktreeInput, |
| 12 | 12 | GitRunStackedActionInput, |
| 13 | 13 | GitRunStackedActionResult, |
| 14 | GitSuggestCommitAndBranchInput, | |
| 15 | GitSuggestCommitAndBranchResult, | |
| 16 | 14 | GitStatusInput, |
| 17 | 15 | GitStatusResult, |
| 18 | 16 | } from "./git"; |
| ... | ... | @@ -90,9 +88,6 @@ export interface NativeApi { |
| 90 | 88 | pull: (input: GitPullInput) => Promise<GitPullResult>; |
| 91 | 89 | status: (input: GitStatusInput) => Promise<GitStatusResult>; |
| 92 | 90 | runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>; |
| 93 | suggestCommitAndBranch: ( | |
| 94 | input: GitSuggestCommitAndBranchInput, | |
| 95 | ) => Promise<GitSuggestCommitAndBranchResult>; | |
| 96 | 91 | }; |
| 97 | 92 | contextMenu: { |
| 98 | 93 | show: <T extends string>( |
packages/contracts/src/ws.ts-3| ... | ... | @@ -18,7 +18,6 @@ import { |
| 18 | 18 | GitPullInput, |
| 19 | 19 | GitRemoveWorktreeInput, |
| 20 | 20 | GitRunStackedActionInput, |
| 21 | GitSuggestCommitAndBranchInput, | |
| 22 | 21 | GitStatusInput, |
| 23 | 22 | } from "./git"; |
| 24 | 23 | import { |
| ... | ... | @@ -49,7 +48,6 @@ export const WS_METHODS = { |
| 49 | 48 | gitPull: "git.pull", |
| 50 | 49 | gitStatus: "git.status", |
| 51 | 50 | gitRunStackedAction: "git.runStackedAction", |
| 52 | gitSuggestCommitAndBranch: "git.suggestCommitAndBranch", | |
| 53 | 51 | gitListBranches: "git.listBranches", |
| 54 | 52 | gitCreateWorktree: "git.createWorktree", |
| 55 | 53 | gitRemoveWorktree: "git.removeWorktree", |
| ... | ... | @@ -111,7 +109,6 @@ export const WebSocketRequestBody = Schema.Union([ |
| 111 | 109 | tagRequestBody(WS_METHODS.gitPull, GitPullInput), |
| 112 | 110 | tagRequestBody(WS_METHODS.gitStatus, GitStatusInput), |
| 113 | 111 | tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), |
| 114 | tagRequestBody(WS_METHODS.gitSuggestCommitAndBranch, GitSuggestCommitAndBranchInput), | |
| 115 | 112 | tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), |
| 116 | 113 | tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), |
| 117 | 114 | tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), |
packages/shared/src/git.ts+33| ... | ... | @@ -31,3 +31,36 @@ export function sanitizeFeatureBranchName(raw: string): string { |
| 31 | 31 | } |
| 32 | 32 | return `feature/${sanitized}`; |
| 33 | 33 | } |
| 34 | ||
| 35 | const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; | |
| 36 | ||
| 37 | /** | |
| 38 | * Resolve a unique `feature/…` branch name that doesn't collide with | |
| 39 | * any existing branch. Appends a numeric suffix when needed. | |
| 40 | */ | |
| 41 | export function resolveAutoFeatureBranchName( | |
| 42 | existingBranchNames: readonly string[], | |
| 43 | preferredBranch?: string, | |
| 44 | ): string { | |
| 45 | const preferred = preferredBranch?.trim(); | |
| 46 | const normalized = sanitizeBranchFragment( | |
| 47 | preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK, | |
| 48 | ); | |
| 49 | const resolvedBase = normalized.includes("/") | |
| 50 | ? normalized.startsWith("feature/") | |
| 51 | ? normalized | |
| 52 | : `feature/${normalized}` | |
| 53 | : `feature/${normalized}`; | |
| 54 | const existingNames = new Set(existingBranchNames); | |
| 55 | ||
| 56 | if (!existingNames.has(resolvedBase)) { | |
| 57 | return resolvedBase; | |
| 58 | } | |
| 59 | ||
| 60 | let suffix = 2; | |
| 61 | while (existingNames.has(`${resolvedBase}-${suffix}`)) { | |
| 62 | suffix += 1; | |
| 63 | } | |
| 64 | ||
| 65 | return `${resolvedBase}-${suffix}`; | |
| 66 | } |