| 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,6 +110,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => |
| 110 | createBranch: (input) => core.createBranch(input), | 110 | createBranch: (input) => core.createBranch(input), |
| 111 | checkoutBranch: (input) => core.checkoutBranch(input), | 111 | checkoutBranch: (input) => core.checkoutBranch(input), |
| 112 | initRepo: (input) => core.initRepo(input), | 112 | initRepo: (input) => core.initRepo(input), |
| 113 | listLocalBranchNames: (cwd) => core.listLocalBranchNames(cwd), | ||
| 113 | } satisfies GitCoreShape; | 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,6 +1172,20 @@ const makeGitCore = Effect.gen(function* () { |
| 1172 | fallbackErrorMessage: "git init failed", | 1172 | fallbackErrorMessage: "git init failed", |
| 1173 | }).pipe(Effect.asVoid); | 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 | return { | 1189 | return { |
| 1176 | status, | 1190 | status, |
| 1177 | statusDetails, | 1191 | statusDetails, |
| ... | @@ -1188,6 +1202,7 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -1188,6 +1202,7 @@ const makeGitCore = Effect.gen(function* () { |
| 1188 | createBranch, | 1202 | createBranch, |
| 1189 | checkoutBranch, | 1203 | checkoutBranch, |
| 1190 | initRepo, | 1204 | initRepo, |
| 1205 | listLocalBranchNames, | ||
| 1191 | } satisfies GitCoreShape; | 1206 | } satisfies GitCoreShape; |
| 1192 | }); | 1207 | }); |
| 1193 | 1208 |
apps/server/src/git/Layers/GitManager.test.ts+39-26| ... | @@ -290,21 +290,12 @@ function runStackedAction( | ... | @@ -290,21 +290,12 @@ function runStackedAction( |
| 290 | cwd: string; | 290 | cwd: string; |
| 291 | action: "commit" | "commit_push" | "commit_push_pr"; | 291 | action: "commit" | "commit_push" | "commit_push_pr"; |
| 292 | commitMessage?: string; | 292 | commitMessage?: string; |
| 293 | featureBranch?: boolean; | ||
| 293 | }, | 294 | }, |
| 294 | ) { | 295 | ) { |
| 295 | return manager.runStackedAction(input); | 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 | function makeManager(input?: { | 299 | function makeManager(input?: { |
| 309 | ghScenario?: FakeGhScenario; | 300 | ghScenario?: FakeGhScenario; |
| 310 | textGeneration?: Partial<FakeGitTextGeneration>; | 301 | textGeneration?: Partial<FakeGitTextGeneration>; |
| ... | @@ -492,6 +483,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -492,6 +483,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 492 | action: "commit", | 483 | action: "commit", |
| 493 | }); | 484 | }); |
| 494 | 485 | ||
| 486 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 495 | expect(result.commit.status).toBe("created"); | 487 | expect(result.commit.status).toBe("created"); |
| 496 | expect(result.push.status).toBe("skipped_not_requested"); | 488 | expect(result.push.status).toBe("skipped_not_requested"); |
| 497 | expect(result.pr.status).toBe("skipped_not_requested"); | 489 | expect(result.pr.status).toBe("skipped_not_requested"); |
| ... | @@ -529,6 +521,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -529,6 +521,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 529 | commitMessage: "feat: custom summary line\n\n- details from user", | 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 | expect(result.commit.status).toBe("created"); | 525 | expect(result.commit.status).toBe("created"); |
| 533 | expect(result.commit.subject).toBe("feat: custom summary line"); | 526 | expect(result.commit.subject).toBe("feat: custom summary line"); |
| 534 | expect(generatedCount).toBe(0); | 527 | expect(generatedCount).toBe(0); |
| ... | @@ -545,29 +538,39 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -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 | Effect.gen(function* () { | 542 | Effect.gen(function* () { |
| 550 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | 543 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| 551 | yield* initRepo(repoDir); | 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 | const { manager } = yield* makeManager(); | 550 | const { manager } = yield* makeManager(); |
| 555 | const suggestion = yield* suggestCommitAndBranch(manager, { | 551 | const result = yield* runStackedAction(manager, { |
| 556 | cwd: repoDir, | 552 | cwd: repoDir, |
| 553 | action: "commit_push", | ||
| 554 | featureBranch: true, | ||
| 557 | }); | 555 | }); |
| 558 | 556 | ||
| 559 | expect(suggestion).toEqual({ | 557 | expect(result.branch.status).toBe("created"); |
| 560 | commitMessage: "Implement stacked git actions", | 558 | expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); |
| 561 | branch: "feature/implement-stacked-git-actions", | 559 | expect(result.commit.status).toBe("created"); |
| 562 | }); | 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 | Effect.gen(function* () { | 570 | Effect.gen(function* () { |
| 568 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | 571 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| 569 | yield* initRepo(repoDir); | 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 | let generatedCount = 0; | 574 | let generatedCount = 0; |
| 572 | 575 | ||
| 573 | const { manager } = yield* makeManager({ | 576 | const { manager } = yield* makeManager({ |
| ... | @@ -583,15 +586,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -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 | cwd: repoDir, | 590 | cwd: repoDir, |
| 591 | action: "commit", | ||
| 592 | featureBranch: true, | ||
| 588 | commitMessage: "feat: custom summary line\n\n- details from user", | 593 | commitMessage: "feat: custom summary line\n\n- details from user", |
| 589 | }); | 594 | }); |
| 590 | 595 | ||
| 591 | expect(suggestion).toEqual({ | 596 | expect(result.branch.status).toBe("created"); |
| 592 | commitMessage: "feat: custom summary line\n\n- details from user", | 597 | expect(result.branch.name).toBe("feature/feat-custom-summary-line"); |
| 593 | branch: "feature/feat-custom-summary-line", | 598 | expect(result.commit.status).toBe("created"); |
| 594 | }); | 599 | expect(result.commit.subject).toBe("feat: custom summary line"); |
| 595 | expect(generatedCount).toBe(0); | 600 | expect(generatedCount).toBe(0); |
| 596 | }), | 601 | }), |
| 597 | ); | 602 | ); |
| ... | @@ -607,20 +612,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -607,20 +612,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 607 | action: "commit", | 612 | action: "commit", |
| 608 | }); | 613 | }); |
| 609 | 614 | ||
| 615 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 610 | expect(result.commit.status).toBe("skipped_no_changes"); | 616 | expect(result.commit.status).toBe("skipped_no_changes"); |
| 611 | expect(result.push.status).toBe("skipped_not_requested"); | 617 | expect(result.push.status).toBe("skipped_not_requested"); |
| 612 | expect(result.pr.status).toBe("skipped_not_requested"); | 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 | Effect.gen(function* () { | 623 | Effect.gen(function* () { |
| 618 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | 624 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| 619 | yield* initRepo(repoDir); | 625 | yield* initRepo(repoDir); |
| 620 | 626 | ||
| 621 | const { manager } = yield* makeManager(); | 627 | const { manager } = yield* makeManager(); |
| 622 | const errorMessage = yield* suggestCommitAndBranch(manager, { | 628 | const errorMessage = yield* runStackedAction(manager, { |
| 623 | cwd: repoDir, | 629 | cwd: repoDir, |
| 630 | action: "commit", | ||
| 631 | featureBranch: true, | ||
| 624 | }).pipe( | 632 | }).pipe( |
| 625 | Effect.flip, | 633 | Effect.flip, |
| 626 | Effect.map((error) => error.message), | 634 | Effect.map((error) => error.message), |
| ... | @@ -645,6 +653,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -645,6 +653,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 645 | action: "commit_push", | 653 | action: "commit_push", |
| 646 | }); | 654 | }); |
| 647 | 655 | ||
| 656 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 648 | expect(result.commit.status).toBe("created"); | 657 | expect(result.commit.status).toBe("created"); |
| 649 | expect(result.push.status).toBe("pushed"); | 658 | expect(result.push.status).toBe("pushed"); |
| 650 | expect(result.push.setUpstream).toBe(true); | 659 | expect(result.push.setUpstream).toBe(true); |
| ... | @@ -689,6 +698,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -689,6 +698,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 689 | action: "commit_push_pr", | 698 | action: "commit_push_pr", |
| 690 | }); | 699 | }); |
| 691 | 700 | ||
| 701 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 692 | expect(result.commit.status).toBe("created"); | 702 | expect(result.commit.status).toBe("created"); |
| 693 | expect(result.push.status).toBe("pushed"); | 703 | expect(result.push.status).toBe("pushed"); |
| 694 | expect(result.push.setUpstream).toBe(true); | 704 | expect(result.push.setUpstream).toBe(true); |
| ... | @@ -721,6 +731,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -721,6 +731,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 721 | action: "commit_push", | 731 | action: "commit_push", |
| 722 | }); | 732 | }); |
| 723 | 733 | ||
| 734 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 724 | expect(result.commit.status).toBe("skipped_no_changes"); | 735 | expect(result.commit.status).toBe("skipped_no_changes"); |
| 725 | expect(result.push.status).toBe("skipped_up_to_date"); | 736 | expect(result.push.status).toBe("skipped_up_to_date"); |
| 726 | }), | 737 | }), |
| ... | @@ -755,6 +766,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -755,6 +766,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 755 | action: "commit_push_pr", | 766 | action: "commit_push_pr", |
| 756 | }); | 767 | }); |
| 757 | 768 | ||
| 769 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 758 | expect(result.pr.status).toBe("opened_existing"); | 770 | expect(result.pr.status).toBe("opened_existing"); |
| 759 | expect(result.pr.number).toBe(42); | 771 | expect(result.pr.number).toBe(42); |
| 760 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); | 772 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); |
| ... | @@ -795,6 +807,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -795,6 +807,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 795 | action: "commit_push_pr", | 807 | action: "commit_push_pr", |
| 796 | }); | 808 | }); |
| 797 | 809 | ||
| 810 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 798 | expect(result.pr.status).toBe("created"); | 811 | expect(result.pr.status).toBe("created"); |
| 799 | expect(result.pr.number).toBe(88); | 812 | expect(result.pr.number).toBe(88); |
| 800 | expect( | 813 | expect( |
apps/server/src/git/Layers/GitManager.ts+41-19| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { randomUUID } from "node:crypto"; | 1 | import { randomUUID } from "node:crypto"; |
| 2 | 2 | ||
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | 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 | import { GitManagerError } from "../Errors.ts"; | 6 | import { GitManagerError } from "../Errors.ts"; |
| 7 | import { GitManager, type GitManagerShape } from "../Services/GitManager.ts"; | 7 | import { GitManager, type GitManagerShape } from "../Services/GitManager.ts"; |
| ... | @@ -431,28 +431,33 @@ export const makeGitManager = Effect.gen(function* () { | ... | @@ -431,28 +431,33 @@ export const makeGitManager = Effect.gen(function* () { |
| 431 | }; | 431 | }; |
| 432 | }); | 432 | }); |
| 433 | 433 | ||
| 434 | const suggestCommitAndBranch: GitManagerShape["suggestCommitAndBranch"] = Effect.fnUntraced( | 434 | const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) => |
| 435 | function* (input) { | 435 | Effect.gen(function* () { |
| 436 | const details = yield* gitCore.statusDetails(input.cwd); | ||
| 437 | const suggestion = yield* resolveCommitAndBranchSuggestion({ | 436 | const suggestion = yield* resolveCommitAndBranchSuggestion({ |
| 438 | cwd: input.cwd, | 437 | cwd, |
| 439 | branch: details.branch, | 438 | branch, |
| 440 | ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), | 439 | ...(commitMessage ? { commitMessage } : {}), |
| 441 | includeBranch: true, | 440 | includeBranch: true, |
| 442 | }); | 441 | }); |
| 443 | if (!suggestion) { | 442 | if (!suggestion) { |
| 444 | return yield* gitManagerError( | 443 | return yield* gitManagerError( |
| 445 | "suggestCommitAndBranch", | 444 | "runFeatureBranchStep", |
| 446 | "Cannot suggest a commit and branch because there are no changes to commit.", | 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 | return { | 456 | return { |
| 451 | commitMessage: suggestion.commitMessage, | 457 | branchStep: { status: "created" as const, name: resolvedBranch }, |
| 452 | branch: suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject), | 458 | resolvedCommitMessage: suggestion.commitMessage, |
| 453 | }; | 459 | }; |
| 454 | }, | 460 | }); |
| 455 | ); | ||
| 456 | 461 | ||
| 457 | const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced( | 462 | const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced( |
| 458 | function* (input) { | 463 | function* (input) { |
| ... | @@ -460,28 +465,46 @@ export const makeGitManager = Effect.gen(function* () { | ... | @@ -460,28 +465,46 @@ export const makeGitManager = Effect.gen(function* () { |
| 460 | const wantsPr = input.action === "commit_push_pr"; | 465 | const wantsPr = input.action === "commit_push_pr"; |
| 461 | 466 | ||
| 462 | const initialStatus = yield* gitCore.statusDetails(input.cwd); | 467 | const initialStatus = yield* gitCore.statusDetails(input.cwd); |
| 463 | if (wantsPush && !initialStatus.branch) { | 468 | if (!input.featureBranch && wantsPush && !initialStatus.branch) { |
| 464 | return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); | 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 | return yield* gitManagerError( | 472 | return yield* gitManagerError( |
| 468 | "runStackedAction", | 473 | "runStackedAction", |
| 469 | "Cannot create a pull request from detached HEAD.", | 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 | const push = wantsPush | 497 | const push = wantsPush |
| 476 | ? yield* gitCore.pushCurrentBranch(input.cwd, initialStatus.branch) | 498 | ? yield* gitCore.pushCurrentBranch(input.cwd, currentBranch) |
| 477 | : { status: "skipped_not_requested" as const }; | 499 | : { status: "skipped_not_requested" as const }; |
| 478 | 500 | ||
| 479 | const pr = wantsPr | 501 | const pr = wantsPr |
| 480 | ? yield* runPrStep(input.cwd, initialStatus.branch) | 502 | ? yield* runPrStep(input.cwd, currentBranch) |
| 481 | : { status: "skipped_not_requested" as const }; | 503 | : { status: "skipped_not_requested" as const }; |
| 482 | 504 | ||
| 483 | return { | 505 | return { |
| 484 | action: input.action, | 506 | action: input.action, |
| 507 | branch: branchStep, | ||
| 485 | commit, | 508 | commit, |
| 486 | push, | 509 | push, |
| 487 | pr, | 510 | pr, |
| ... | @@ -492,7 +515,6 @@ export const makeGitManager = Effect.gen(function* () { | ... | @@ -492,7 +515,6 @@ export const makeGitManager = Effect.gen(function* () { |
| 492 | return { | 515 | return { |
| 493 | status, | 516 | status, |
| 494 | runStackedAction, | 517 | runStackedAction, |
| 495 | suggestCommitAndBranch, | ||
| 496 | } satisfies GitManagerShape; | 518 | } satisfies GitManagerShape; |
| 497 | }); | 519 | }); |
| 498 | 520 |
apps/server/src/git/Services/GitCore.ts+5| ... | @@ -157,6 +157,11 @@ export interface GitCoreShape { | ... | @@ -157,6 +157,11 @@ export interface GitCoreShape { |
| 157 | * Initialize a repository in the provided directory. | 157 | * Initialize a repository in the provided directory. |
| 158 | */ | 158 | */ |
| 159 | readonly initRepo: (input: GitInitInput) => Effect.Effect<void, GitCommandError>; | 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,8 +9,6 @@ |
| 9 | import { | 9 | import { |
| 10 | GitRunStackedActionInput, | 10 | GitRunStackedActionInput, |
| 11 | GitRunStackedActionResult, | 11 | GitRunStackedActionResult, |
| 12 | GitSuggestCommitAndBranchInput, | ||
| 13 | GitSuggestCommitAndBranchResult, | ||
| 14 | GitStatusInput, | 12 | GitStatusInput, |
| 15 | GitStatusResult, | 13 | GitStatusResult, |
| 16 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| ... | @@ -31,17 +29,11 @@ export interface GitManagerShape { | ... | @@ -31,17 +29,11 @@ export interface GitManagerShape { |
| 31 | 29 | ||
| 32 | /** | 30 | /** |
| 33 | * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). | 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 | readonly runStackedAction: ( | 34 | readonly runStackedAction: ( |
| 36 | input: GitRunStackedActionInput, | 35 | input: GitRunStackedActionInput, |
| 37 | ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>; | 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,8 +1555,7 @@ describe("WebSocket Server", () => { |
| 1555 | 1555 | ||
| 1556 | const status = vi.fn(() => Effect.succeed(statusResult)); | 1556 | const status = vi.fn(() => Effect.succeed(statusResult)); |
| 1557 | const runStackedAction = vi.fn(() => Effect.void as any); | 1557 | const runStackedAction = vi.fn(() => Effect.void as any); |
| 1558 | const suggestCommitAndBranch = vi.fn(() => Effect.void as any); | 1558 | const gitManager: GitManagerShape = { status, runStackedAction }; |
| 1559 | const gitManager: GitManagerShape = { status, runStackedAction, suggestCommitAndBranch }; | ||
| 1560 | 1559 | ||
| 1561 | server = await createTestServer({ cwd: "/test", gitManager }); | 1560 | server = await createTestServer({ cwd: "/test", gitManager }); |
| 1562 | const addr = server.address(); | 1561 | const addr = server.address(); |
| ... | @@ -1586,7 +1585,6 @@ describe("WebSocket Server", () => { | ... | @@ -1586,7 +1585,6 @@ describe("WebSocket Server", () => { |
| 1586 | const gitManager: GitManagerShape = { | 1585 | const gitManager: GitManagerShape = { |
| 1587 | status: vi.fn(() => Effect.void as any), | 1586 | status: vi.fn(() => Effect.void as any), |
| 1588 | runStackedAction, | 1587 | runStackedAction, |
| 1589 | suggestCommitAndBranch: vi.fn(() => Effect.void as any), | ||
| 1590 | }; | 1588 | }; |
| 1591 | 1589 | ||
| 1592 | server = await createTestServer({ cwd: "/test", gitManager }); | 1590 | server = await createTestServer({ cwd: "/test", gitManager }); |
| ... | @@ -1609,34 +1607,6 @@ describe("WebSocket Server", () => { | ... | @@ -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 | it("rejects websocket connections without a valid auth token", async () => { | 1610 | it("rejects websocket connections without a valid auth token", async () => { |
| 1641 | server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); | 1611 | server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); |
| 1642 | const addr = server.address(); | 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,11 +668,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 668 | return yield* gitManager.runStackedAction(body); | 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 | case WS_METHODS.gitListBranches: { | 671 | case WS_METHODS.gitListBranches: { |
| 677 | const body = stripRequestTag(request.body); | 672 | const body = stripRequestTag(request.body); |
| 678 | return yield* git.listBranches(body); | 673 | return yield* git.listBranches(body); |
apps/web/src/components/GitActionsControl.logic.test.ts+4| ... | @@ -823,6 +823,7 @@ describe("summarizeGitResult", () => { | ... | @@ -823,6 +823,7 @@ describe("summarizeGitResult", () => { |
| 823 | it("returns commit-focused toast for commit action", () => { | 823 | it("returns commit-focused toast for commit action", () => { |
| 824 | const result = summarizeGitResult({ | 824 | const result = summarizeGitResult({ |
| 825 | action: "commit", | 825 | action: "commit", |
| 826 | branch: { status: "skipped_not_requested" }, | ||
| 826 | commit: { | 827 | commit: { |
| 827 | status: "created", | 828 | status: "created", |
| 828 | commitSha: "0123456789abcdef", | 829 | commitSha: "0123456789abcdef", |
| ... | @@ -841,6 +842,7 @@ describe("summarizeGitResult", () => { | ... | @@ -841,6 +842,7 @@ describe("summarizeGitResult", () => { |
| 841 | it("returns push-focused toast for push action", () => { | 842 | it("returns push-focused toast for push action", () => { |
| 842 | const result = summarizeGitResult({ | 843 | const result = summarizeGitResult({ |
| 843 | action: "commit_push", | 844 | action: "commit_push", |
| 845 | branch: { status: "skipped_not_requested" }, | ||
| 844 | commit: { | 846 | commit: { |
| 845 | status: "created", | 847 | status: "created", |
| 846 | commitSha: "abcdef0123456789", | 848 | commitSha: "abcdef0123456789", |
| ... | @@ -863,6 +865,7 @@ describe("summarizeGitResult", () => { | ... | @@ -863,6 +865,7 @@ describe("summarizeGitResult", () => { |
| 863 | it("returns PR-focused toast for created PR action", () => { | 865 | it("returns PR-focused toast for created PR action", () => { |
| 864 | const result = summarizeGitResult({ | 866 | const result = summarizeGitResult({ |
| 865 | action: "commit_push_pr", | 867 | action: "commit_push_pr", |
| 868 | branch: { status: "skipped_not_requested" }, | ||
| 866 | commit: { | 869 | commit: { |
| 867 | status: "created", | 870 | status: "created", |
| 868 | commitSha: "89abcdef01234567", | 871 | commitSha: "89abcdef01234567", |
| ... | @@ -888,6 +891,7 @@ describe("summarizeGitResult", () => { | ... | @@ -888,6 +891,7 @@ describe("summarizeGitResult", () => { |
| 888 | it("truncates long description text", () => { | 891 | it("truncates long description text", () => { |
| 889 | const result = summarizeGitResult({ | 892 | const result = summarizeGitResult({ |
| 890 | action: "commit_push_pr", | 893 | action: "commit_push_pr", |
| 894 | branch: { status: "skipped_not_requested" }, | ||
| 891 | commit: { | 895 | commit: { |
| 892 | status: "created", | 896 | status: "created", |
| 893 | commitSha: "89abcdef01234567", | 897 | commitSha: "89abcdef01234567", |
apps/web/src/components/GitActionsControl.logic.ts+7-31| ... | @@ -3,7 +3,6 @@ import type { | ... | @@ -3,7 +3,6 @@ import type { |
| 3 | GitStackedAction, | 3 | GitStackedAction, |
| 4 | GitStatusResult, | 4 | GitStatusResult, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { sanitizeBranchFragment } from "@t3tools/shared/git"; | ||
| 7 | 6 | ||
| 8 | export type GitActionIconName = "commit" | "push" | "pr"; | 7 | export type GitActionIconName = "commit" | "push" | "pr"; |
| 9 | 8 | ||
| ... | @@ -36,7 +35,6 @@ export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr"; | ... | @@ -36,7 +35,6 @@ export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr"; |
| 36 | 35 | ||
| 37 | const SHORT_SHA_LENGTH = 7; | 36 | const SHORT_SHA_LENGTH = 7; |
| 38 | const TOAST_DESCRIPTION_MAX = 72; | 37 | const TOAST_DESCRIPTION_MAX = 72; |
| 39 | const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; | ||
| 40 | 38 | ||
| 41 | function shortenSha(sha: string | undefined): string | null { | 39 | function shortenSha(sha: string | undefined): string | null { |
| 42 | if (!sha) return null; | 40 | if (!sha) return null; |
| ... | @@ -59,7 +57,9 @@ export function buildGitActionProgressStages(input: { | ... | @@ -59,7 +57,9 @@ export function buildGitActionProgressStages(input: { |
| 59 | hasWorkingTreeChanges: boolean; | 57 | hasWorkingTreeChanges: boolean; |
| 60 | forcePushOnly?: boolean; | 58 | forcePushOnly?: boolean; |
| 61 | pushTarget?: string; | 59 | pushTarget?: string; |
| 60 | featureBranch?: boolean; | ||
| 62 | }): string[] { | 61 | }): string[] { |
| 62 | const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; | ||
| 63 | const shouldIncludeCommitStages = | 63 | const shouldIncludeCommitStages = |
| 64 | !input.forcePushOnly && (input.action === "commit" || input.hasWorkingTreeChanges); | 64 | !input.forcePushOnly && (input.action === "commit" || input.hasWorkingTreeChanges); |
| 65 | const commitStages = !shouldIncludeCommitStages | 65 | const commitStages = !shouldIncludeCommitStages |
| ... | @@ -69,12 +69,12 @@ export function buildGitActionProgressStages(input: { | ... | @@ -69,12 +69,12 @@ export function buildGitActionProgressStages(input: { |
| 69 | : ["Generating commit message...", "Committing..."]; | 69 | : ["Generating commit message...", "Committing..."]; |
| 70 | const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; | 70 | const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; |
| 71 | if (input.action === "commit") { | 71 | if (input.action === "commit") { |
| 72 | return commitStages; | 72 | return [...branchStages, ...commitStages]; |
| 73 | } | 73 | } |
| 74 | if (input.action === "commit_push") { | 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 | const withDescription = (title: string, description: string | undefined) => | 80 | const withDescription = (title: string, description: string | undefined) => |
| ... | @@ -322,29 +322,5 @@ export function resolveDefaultBranchActionDialogCopy(input: { | ... | @@ -322,29 +322,5 @@ export function resolveDefaultBranchActionDialogCopy(input: { |
| 322 | }; | 322 | }; |
| 323 | } | 323 | } |
| 324 | 324 | ||
| 325 | export function resolveAutoFeatureBranchName( | 325 | // Re-export from shared for backwards compatibility in this module's exports |
| 326 | existingBranchNames: readonly string[], | 326 | export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; |
| 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 | } |
apps/web/src/components/GitActionsControl.tsx+26-129| ... | @@ -1,4 +1,4 @@ | ... | @@ -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 | import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 2 | import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { useCallback, useEffect, useMemo, useState } from "react"; | 3 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 4 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; | 4 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; |
| ... | @@ -11,7 +11,6 @@ import { | ... | @@ -11,7 +11,6 @@ import { |
| 11 | type GitQuickAction, | 11 | type GitQuickAction, |
| 12 | type DefaultBranchConfirmableAction, | 12 | type DefaultBranchConfirmableAction, |
| 13 | requiresDefaultBranchConfirmation, | 13 | requiresDefaultBranchConfirmation, |
| 14 | resolveAutoFeatureBranchName, | ||
| 15 | resolveDefaultBranchActionDialogCopy, | 14 | resolveDefaultBranchActionDialogCopy, |
| 16 | resolveQuickAction, | 15 | resolveQuickAction, |
| 17 | summarizeGitResult, | 16 | summarizeGitResult, |
| ... | @@ -34,12 +33,10 @@ import { Textarea } from "~/components/ui/textarea"; | ... | @@ -34,12 +33,10 @@ import { Textarea } from "~/components/ui/textarea"; |
| 34 | import { toastManager } from "~/components/ui/toast"; | 33 | import { toastManager } from "~/components/ui/toast"; |
| 35 | import { | 34 | import { |
| 36 | gitBranchesQueryOptions, | 35 | gitBranchesQueryOptions, |
| 37 | gitCreateBranchAndCheckoutMutationOptions, | ||
| 38 | gitInitMutationOptions, | 36 | gitInitMutationOptions, |
| 39 | gitMutationKeys, | 37 | gitMutationKeys, |
| 40 | gitPullMutationOptions, | 38 | gitPullMutationOptions, |
| 41 | gitRunStackedActionMutationOptions, | 39 | gitRunStackedActionMutationOptions, |
| 42 | gitSuggestCommitAndBranchMutationOptions, | ||
| 43 | gitStatusQueryOptions, | 40 | gitStatusQueryOptions, |
| 44 | invalidateGitQueries, | 41 | invalidateGitQueries, |
| 45 | } from "~/lib/gitReactQuery"; | 42 | } from "~/lib/gitReactQuery"; |
| ... | @@ -60,8 +57,6 @@ interface PendingDefaultBranchAction { | ... | @@ -60,8 +57,6 @@ interface PendingDefaultBranchAction { |
| 60 | onConfirmed?: () => void; | 57 | onConfirmed?: () => void; |
| 61 | } | 58 | } |
| 62 | 59 | ||
| 63 | type GitActionToastId = ReturnType<typeof toastManager.add>; | ||
| 64 | |||
| 65 | function getMenuActionDisabledReason( | 60 | function getMenuActionDisabledReason( |
| 66 | item: GitActionMenuItem, | 61 | item: GitActionMenuItem, |
| 67 | gitStatus: GitStatusResult | null, | 62 | gitStatus: GitStatusResult | null, |
| ... | @@ -173,26 +168,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -173,26 +168,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 173 | const runImmediateGitActionMutation = useMutation( | 168 | const runImmediateGitActionMutation = useMutation( |
| 174 | gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), | 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 | const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); | 171 | const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); |
| 183 | 172 | ||
| 184 | const isRunStackedActionRunning = | 173 | const isRunStackedActionRunning = |
| 185 | useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; | 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 | const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; | 175 | const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; |
| 191 | const isGitActionRunning = | 176 | const isGitActionRunning = isRunStackedActionRunning || isPullRunning; |
| 192 | isRunStackedActionRunning || | ||
| 193 | isSuggestCommitAndBranchRunning || | ||
| 194 | isCreateBranchAndCheckoutRunning || | ||
| 195 | isPullRunning; | ||
| 196 | const isDefaultBranch = useMemo(() => { | 177 | const isDefaultBranch = useMemo(() => { |
| 197 | const branchName = gitStatusForActions?.branch; | 178 | const branchName = gitStatusForActions?.branch; |
| 198 | if (!branchName) return false; | 179 | if (!branchName) return false; |
| ... | @@ -255,26 +236,23 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -255,26 +236,23 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 255 | forcePushOnlyProgress = false, | 236 | forcePushOnlyProgress = false, |
| 256 | onConfirmed, | 237 | onConfirmed, |
| 257 | skipDefaultBranchPrompt = false, | 238 | skipDefaultBranchPrompt = false, |
| 258 | statusOverride, | 239 | featureBranch = false, |
| 259 | isDefaultBranchOverride, | ||
| 260 | progressToastId, | ||
| 261 | }: { | 240 | }: { |
| 262 | action: GitStackedAction; | 241 | action: GitStackedAction; |
| 263 | commitMessage?: string; | 242 | commitMessage?: string; |
| 264 | forcePushOnlyProgress?: boolean; | 243 | forcePushOnlyProgress?: boolean; |
| 265 | onConfirmed?: () => void; | 244 | onConfirmed?: () => void; |
| 266 | skipDefaultBranchPrompt?: boolean; | 245 | skipDefaultBranchPrompt?: boolean; |
| 267 | statusOverride?: GitStatusResult | null; | 246 | featureBranch?: boolean; |
| 268 | isDefaultBranchOverride?: boolean; | ||
| 269 | progressToastId?: GitActionToastId; | ||
| 270 | }) => { | 247 | }) => { |
| 271 | const actionStatus = statusOverride ?? gitStatusForActions; | 248 | const actionStatus = gitStatusForActions; |
| 272 | const actionIsDefaultBranch = isDefaultBranchOverride ?? isDefaultBranch; | 249 | const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch; |
| 273 | const actionBranch = actionStatus?.branch ?? null; | 250 | const actionBranch = actionStatus?.branch ?? null; |
| 274 | const includesCommit = | 251 | const includesCommit = |
| 275 | !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges); | 252 | !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges); |
| 276 | if ( | 253 | if ( |
| 277 | !skipDefaultBranchPrompt && | 254 | !skipDefaultBranchPrompt && |
| 255 | !featureBranch && | ||
| 278 | requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && | 256 | requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && |
| 279 | actionBranch | 257 | actionBranch |
| 280 | ) { | 258 | ) { |
| ... | @@ -293,36 +271,26 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -293,36 +271,26 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 293 | } | 271 | } |
| 294 | onConfirmed?.(); | 272 | onConfirmed?.(); |
| 295 | 273 | ||
| 296 | const pushTarget = actionBranch ? `origin/${actionBranch}` : undefined; | 274 | const pushTarget = !featureBranch && actionBranch ? `origin/${actionBranch}` : undefined; |
| 297 | const progressStages = buildGitActionProgressStages({ | 275 | const progressStages = buildGitActionProgressStages({ |
| 298 | action, | 276 | action, |
| 299 | hasCustomCommitMessage: !!commitMessage?.trim(), | 277 | hasCustomCommitMessage: !!commitMessage?.trim(), |
| 300 | hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, | 278 | hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, |
| 301 | forcePushOnly: forcePushOnlyProgress, | 279 | forcePushOnly: forcePushOnlyProgress, |
| 280 | featureBranch, | ||
| 302 | ...(pushTarget ? { pushTarget } : {}), | 281 | ...(pushTarget ? { pushTarget } : {}), |
| 303 | }); | 282 | }); |
| 304 | const resolvedProgressToastId = | 283 | const progressToastId = toastManager.add({ |
| 305 | progressToastId ?? | 284 | type: "loading", |
| 306 | toastManager.add({ | 285 | title: progressStages[0] ?? "Running git action...", |
| 307 | type: "loading", | 286 | timeout: 0, |
| 308 | title: progressStages[0] ?? "Running git action...", | 287 | data: threadToastData, |
| 309 | timeout: 0, | 288 | }); |
| 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 | } | ||
| 321 | 289 | ||
| 322 | let stageIndex = 0; | 290 | let stageIndex = 0; |
| 323 | const stageInterval = setInterval(() => { | 291 | const stageInterval = setInterval(() => { |
| 324 | stageIndex = Math.min(stageIndex + 1, progressStages.length - 1); | 292 | stageIndex = Math.min(stageIndex + 1, progressStages.length - 1); |
| 325 | toastManager.update(resolvedProgressToastId, { | 293 | toastManager.update(progressToastId, { |
| 326 | title: progressStages[stageIndex] ?? "Running git action...", | 294 | title: progressStages[stageIndex] ?? "Running git action...", |
| 327 | type: "loading", | 295 | type: "loading", |
| 328 | timeout: 0, | 296 | timeout: 0, |
| ... | @@ -337,6 +305,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -337,6 +305,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 337 | const promise = runImmediateGitActionMutation.mutateAsync({ | 305 | const promise = runImmediateGitActionMutation.mutateAsync({ |
| 338 | action, | 306 | action, |
| 339 | ...(commitMessage ? { commitMessage } : {}), | 307 | ...(commitMessage ? { commitMessage } : {}), |
| 308 | ...(featureBranch ? { featureBranch } : {}), | ||
| 340 | }); | 309 | }); |
| 341 | 310 | ||
| 342 | try { | 311 | try { |
| ... | @@ -357,10 +326,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -357,10 +326,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 357 | result.push.status === "pushed" && | 326 | result.push.status === "pushed" && |
| 358 | !actionIsDefaultBranch; | 327 | !actionIsDefaultBranch; |
| 359 | const closeResultToast = () => { | 328 | const closeResultToast = () => { |
| 360 | toastManager.close(resolvedProgressToastId); | 329 | toastManager.close(progressToastId); |
| 361 | }; | 330 | }; |
| 362 | 331 | ||
| 363 | toastManager.update(resolvedProgressToastId, { | 332 | toastManager.update(progressToastId, { |
| 364 | type: "success", | 333 | type: "success", |
| 365 | title: resultToast.title, | 334 | title: resultToast.title, |
| 366 | description: resultToast.description, | 335 | description: resultToast.description, |
| ... | @@ -378,8 +347,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -378,8 +347,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 378 | action: "commit_push", | 347 | action: "commit_push", |
| 379 | forcePushOnlyProgress: true, | 348 | forcePushOnlyProgress: true, |
| 380 | onConfirmed: closeResultToast, | 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,8 +371,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 404 | closeResultToast(); | 371 | closeResultToast(); |
| 405 | void runGitActionWithToast({ | 372 | void runGitActionWithToast({ |
| 406 | action: "commit_push_pr", | 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,7 +379,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 414 | }); | 379 | }); |
| 415 | } catch (err) { | 380 | } catch (err) { |
| 416 | stopProgressUpdates(); | 381 | stopProgressUpdates(); |
| 417 | toastManager.update(resolvedProgressToastId, { | 382 | toastManager.update(progressToastId, { |
| 418 | type: "error", | 383 | type: "error", |
| 419 | title: "Action failed", | 384 | title: "Action failed", |
| 420 | description: err instanceof Error ? err.message : "An error occurred.", | 385 | description: err instanceof Error ? err.message : "An error occurred.", |
| ... | @@ -449,92 +414,24 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -449,92 +414,24 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 449 | (actionParams: { | 414 | (actionParams: { |
| 450 | action: GitStackedAction; | 415 | action: GitStackedAction; |
| 451 | commitMessage?: string; | 416 | commitMessage?: string; |
| 452 | forcePushOnlyProgress?: boolean; | ||
| 453 | onConfirmed?: () => void; | 417 | onConfirmed?: () => void; |
| 454 | }) => { | 418 | }) => { |
| 455 | const existingBranchNames = branchList?.branches.map((branch) => branch.name) ?? []; | 419 | void runGitActionWithToast({ |
| 456 | const providedCommitMessage = actionParams.commitMessage?.trim() || undefined; | 420 | ...actionParams, |
| 457 | const shouldSuggestCommitAndBranch = | 421 | featureBranch: true, |
| 458 | !providedCommitMessage && !!gitStatusForActions?.hasWorkingTreeChanges; | 422 | skipDefaultBranchPrompt: true, |
| 459 | |||
| 460 | const checkoutToastId = toastManager.add({ | ||
| 461 | type: "loading", | ||
| 462 | title: "Preparing feature branch...", | ||
| 463 | timeout: 0, | ||
| 464 | data: threadToastData, | ||
| 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 | [ | 425 | [runGitActionWithToast], |
| 521 | branchList?.branches, | ||
| 522 | createBranchAndCheckoutMutation, | ||
| 523 | gitStatusForActions, | ||
| 524 | runGitActionWithToast, | ||
| 525 | suggestCommitAndBranchMutation, | ||
| 526 | threadToastData, | ||
| 527 | ], | ||
| 528 | ); | 426 | ); |
| 529 | 427 | ||
| 530 | const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { | 428 | const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { |
| 531 | if (!pendingDefaultBranchAction) return; | 429 | if (!pendingDefaultBranchAction) return; |
| 532 | const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction; | 430 | const { action, commitMessage, onConfirmed } = pendingDefaultBranchAction; |
| 533 | setPendingDefaultBranchAction(null); | 431 | setPendingDefaultBranchAction(null); |
| 534 | checkoutNewBranchAndRunAction({ | 432 | checkoutNewBranchAndRunAction({ |
| 535 | action, | 433 | action, |
| 536 | ...(commitMessage ? { commitMessage } : {}), | 434 | ...(commitMessage ? { commitMessage } : {}), |
| 537 | forcePushOnlyProgress, | ||
| 538 | ...(onConfirmed ? { onConfirmed } : {}), | 435 | ...(onConfirmed ? { onConfirmed } : {}), |
| 539 | }); | 436 | }); |
| 540 | }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]); | 437 | }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]); |
apps/web/src/lib/gitReactQuery.test.ts-12| ... | @@ -4,7 +4,6 @@ import { | ... | @@ -4,7 +4,6 @@ import { |
| 4 | gitMutationKeys, | 4 | gitMutationKeys, |
| 5 | gitPullMutationOptions, | 5 | gitPullMutationOptions, |
| 6 | gitRunStackedActionMutationOptions, | 6 | gitRunStackedActionMutationOptions, |
| 7 | gitSuggestCommitAndBranchMutationOptions, | ||
| 8 | } from "./gitReactQuery"; | 7 | } from "./gitReactQuery"; |
| 9 | 8 | ||
| 10 | describe("gitMutationKeys", () => { | 9 | describe("gitMutationKeys", () => { |
| ... | @@ -17,12 +16,6 @@ describe("gitMutationKeys", () => { | ... | @@ -17,12 +16,6 @@ describe("gitMutationKeys", () => { |
| 17 | it("scopes pull keys by cwd", () => { | 16 | it("scopes pull keys by cwd", () => { |
| 18 | expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b")); | 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 | describe("git mutation options", () => { | 21 | describe("git mutation options", () => { |
| ... | @@ -37,9 +30,4 @@ describe("git mutation options", () => { | ... | @@ -37,9 +30,4 @@ describe("git mutation options", () => { |
| 37 | const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); | 30 | const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); |
| 38 | expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); | 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,8 +19,6 @@ export const gitMutationKeys = { |
| 19 | createBranchAndCheckout: (cwd: string | null) => | 19 | createBranchAndCheckout: (cwd: string | null) => |
| 20 | ["git", "mutation", "create-branch-and-checkout", cwd] as const, | 20 | ["git", "mutation", "create-branch-and-checkout", cwd] as const, |
| 21 | runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, | 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 | pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, | 22 | pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, |
| 25 | }; | 23 | }; |
| 26 | 24 | ||
| ... | @@ -118,9 +116,11 @@ export function gitRunStackedActionMutationOptions(input: { | ... | @@ -118,9 +116,11 @@ export function gitRunStackedActionMutationOptions(input: { |
| 118 | mutationFn: async ({ | 116 | mutationFn: async ({ |
| 119 | action, | 117 | action, |
| 120 | commitMessage, | 118 | commitMessage, |
| 119 | featureBranch, | ||
| 121 | }: { | 120 | }: { |
| 122 | action: GitStackedAction; | 121 | action: GitStackedAction; |
| 123 | commitMessage?: string; | 122 | commitMessage?: string; |
| 123 | featureBranch?: boolean; | ||
| 124 | }) => { | 124 | }) => { |
| 125 | const api = ensureNativeApi(); | 125 | const api = ensureNativeApi(); |
| 126 | if (!input.cwd) throw new Error("Git action is unavailable."); | 126 | if (!input.cwd) throw new Error("Git action is unavailable."); |
| ... | @@ -128,6 +128,7 @@ export function gitRunStackedActionMutationOptions(input: { | ... | @@ -128,6 +128,7 @@ export function gitRunStackedActionMutationOptions(input: { |
| 128 | cwd: input.cwd, | 128 | cwd: input.cwd, |
| 129 | action, | 129 | action, |
| 130 | ...(commitMessage ? { commitMessage } : {}), | 130 | ...(commitMessage ? { commitMessage } : {}), |
| 131 | ...(featureBranch ? { featureBranch } : {}), | ||
| 131 | }); | 132 | }); |
| 132 | }, | 133 | }, |
| 133 | onSettled: async () => { | 134 | onSettled: async () => { |
| ... | @@ -136,20 +137,6 @@ export function gitRunStackedActionMutationOptions(input: { | ... | @@ -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 | export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { | 140 | export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { |
| 154 | return mutationOptions({ | 141 | return mutationOptions({ |
| 155 | mutationKey: gitMutationKeys.pull(input.cwd), | 142 | mutationKey: gitMutationKeys.pull(input.cwd), |
apps/web/src/wsNativeApi.test.ts-1| ... | @@ -6,7 +6,6 @@ import { | ... | @@ -6,7 +6,6 @@ import { |
| 6 | ProjectId, | 6 | ProjectId, |
| 7 | ThreadId, | 7 | ThreadId, |
| 8 | WS_CHANNELS, | 8 | WS_CHANNELS, |
| 9 | WS_METHODS, | ||
| 10 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 11 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 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,8 +163,6 @@ export function createWsNativeApi(): NativeApi { |
| 163 | pull: (input) => transport.request(WS_METHODS.gitPull, input), | 163 | pull: (input) => transport.request(WS_METHODS.gitPull, input), |
| 164 | status: (input) => transport.request(WS_METHODS.gitStatus, input), | 164 | status: (input) => transport.request(WS_METHODS.gitStatus, input), |
| 165 | runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), | 165 | runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), |
| 166 | suggestCommitAndBranch: (input) => | ||
| 167 | transport.request(WS_METHODS.gitSuggestCommitAndBranch, input), | ||
| 168 | listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), | 166 | listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), |
| 169 | createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), | 167 | createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), |
| 170 | removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), | 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,6 +13,7 @@ export const GitPushStepStatus = Schema.Literals([ |
| 13 | "skipped_not_requested", | 13 | "skipped_not_requested", |
| 14 | "skipped_up_to_date", | 14 | "skipped_up_to_date", |
| 15 | ]); | 15 | ]); |
| 16 | export const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]); | ||
| 16 | export const GitPrStepStatus = Schema.Literals([ | 17 | export const GitPrStepStatus = Schema.Literals([ |
| 17 | "created", | 18 | "created", |
| 18 | "opened_existing", | 19 | "opened_existing", |
| ... | @@ -53,15 +54,10 @@ export const GitRunStackedActionInput = Schema.Struct({ | ... | @@ -53,15 +54,10 @@ export const GitRunStackedActionInput = Schema.Struct({ |
| 53 | cwd: TrimmedNonEmptyStringSchema, | 54 | cwd: TrimmedNonEmptyStringSchema, |
| 54 | action: GitStackedAction, | 55 | action: GitStackedAction, |
| 55 | commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), | 56 | commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), |
| 57 | featureBranch: Schema.optional(Schema.Boolean), | ||
| 56 | }); | 58 | }); |
| 57 | export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; | 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 | export const GitListBranchesInput = Schema.Struct({ | 61 | export const GitListBranchesInput = Schema.Struct({ |
| 66 | cwd: TrimmedNonEmptyStringSchema, | 62 | cwd: TrimmedNonEmptyStringSchema, |
| 67 | }); | 63 | }); |
| ... | @@ -145,6 +141,10 @@ export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; | ... | @@ -145,6 +141,10 @@ export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; |
| 145 | 141 | ||
| 146 | export const GitRunStackedActionResult = Schema.Struct({ | 142 | export const GitRunStackedActionResult = Schema.Struct({ |
| 147 | action: GitStackedAction, | 143 | action: GitStackedAction, |
| 144 | branch: Schema.Struct({ | ||
| 145 | status: GitBranchStepStatus, | ||
| 146 | name: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 147 | }), | ||
| 148 | commit: Schema.Struct({ | 148 | commit: Schema.Struct({ |
| 149 | status: GitCommitStepStatus, | 149 | status: GitCommitStepStatus, |
| 150 | commitSha: Schema.optional(TrimmedNonEmptyStringSchema), | 150 | commitSha: Schema.optional(TrimmedNonEmptyStringSchema), |
| ... | @@ -167,12 +167,6 @@ export const GitRunStackedActionResult = Schema.Struct({ | ... | @@ -167,12 +167,6 @@ export const GitRunStackedActionResult = Schema.Struct({ |
| 167 | }); | 167 | }); |
| 168 | export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; | 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 | export const GitPullResult = Schema.Struct({ | 170 | export const GitPullResult = Schema.Struct({ |
| 177 | status: Schema.Literals(["pulled", "skipped_up_to_date"]), | 171 | status: Schema.Literals(["pulled", "skipped_up_to_date"]), |
| 178 | branch: TrimmedNonEmptyStringSchema, | 172 | branch: TrimmedNonEmptyStringSchema, |
packages/contracts/src/ipc.ts-5| ... | @@ -11,8 +11,6 @@ import type { | ... | @@ -11,8 +11,6 @@ import type { |
| 11 | GitRemoveWorktreeInput, | 11 | GitRemoveWorktreeInput, |
| 12 | GitRunStackedActionInput, | 12 | GitRunStackedActionInput, |
| 13 | GitRunStackedActionResult, | 13 | GitRunStackedActionResult, |
| 14 | GitSuggestCommitAndBranchInput, | ||
| 15 | GitSuggestCommitAndBranchResult, | ||
| 16 | GitStatusInput, | 14 | GitStatusInput, |
| 17 | GitStatusResult, | 15 | GitStatusResult, |
| 18 | } from "./git"; | 16 | } from "./git"; |
| ... | @@ -90,9 +88,6 @@ export interface NativeApi { | ... | @@ -90,9 +88,6 @@ export interface NativeApi { |
| 90 | pull: (input: GitPullInput) => Promise<GitPullResult>; | 88 | pull: (input: GitPullInput) => Promise<GitPullResult>; |
| 91 | status: (input: GitStatusInput) => Promise<GitStatusResult>; | 89 | status: (input: GitStatusInput) => Promise<GitStatusResult>; |
| 92 | runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>; | 90 | runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>; |
| 93 | suggestCommitAndBranch: ( | ||
| 94 | input: GitSuggestCommitAndBranchInput, | ||
| 95 | ) => Promise<GitSuggestCommitAndBranchResult>; | ||
| 96 | }; | 91 | }; |
| 97 | contextMenu: { | 92 | contextMenu: { |
| 98 | show: <T extends string>( | 93 | show: <T extends string>( |
packages/contracts/src/ws.ts-3| ... | @@ -18,7 +18,6 @@ import { | ... | @@ -18,7 +18,6 @@ import { |
| 18 | GitPullInput, | 18 | GitPullInput, |
| 19 | GitRemoveWorktreeInput, | 19 | GitRemoveWorktreeInput, |
| 20 | GitRunStackedActionInput, | 20 | GitRunStackedActionInput, |
| 21 | GitSuggestCommitAndBranchInput, | ||
| 22 | GitStatusInput, | 21 | GitStatusInput, |
| 23 | } from "./git"; | 22 | } from "./git"; |
| 24 | import { | 23 | import { |
| ... | @@ -49,7 +48,6 @@ export const WS_METHODS = { | ... | @@ -49,7 +48,6 @@ export const WS_METHODS = { |
| 49 | gitPull: "git.pull", | 48 | gitPull: "git.pull", |
| 50 | gitStatus: "git.status", | 49 | gitStatus: "git.status", |
| 51 | gitRunStackedAction: "git.runStackedAction", | 50 | gitRunStackedAction: "git.runStackedAction", |
| 52 | gitSuggestCommitAndBranch: "git.suggestCommitAndBranch", | ||
| 53 | gitListBranches: "git.listBranches", | 51 | gitListBranches: "git.listBranches", |
| 54 | gitCreateWorktree: "git.createWorktree", | 52 | gitCreateWorktree: "git.createWorktree", |
| 55 | gitRemoveWorktree: "git.removeWorktree", | 53 | gitRemoveWorktree: "git.removeWorktree", |
| ... | @@ -111,7 +109,6 @@ export const WebSocketRequestBody = Schema.Union([ | ... | @@ -111,7 +109,6 @@ export const WebSocketRequestBody = Schema.Union([ |
| 111 | tagRequestBody(WS_METHODS.gitPull, GitPullInput), | 109 | tagRequestBody(WS_METHODS.gitPull, GitPullInput), |
| 112 | tagRequestBody(WS_METHODS.gitStatus, GitStatusInput), | 110 | tagRequestBody(WS_METHODS.gitStatus, GitStatusInput), |
| 113 | tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), | 111 | tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), |
| 114 | tagRequestBody(WS_METHODS.gitSuggestCommitAndBranch, GitSuggestCommitAndBranchInput), | ||
| 115 | tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), | 112 | tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), |
| 116 | tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), | 113 | tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), |
| 117 | tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), | 114 | tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), |
packages/shared/src/git.ts+33| ... | @@ -31,3 +31,36 @@ export function sanitizeFeatureBranchName(raw: string): string { | ... | @@ -31,3 +31,36 @@ export function sanitizeFeatureBranchName(raw: string): string { |
| 31 | } | 31 | } |
| 32 | return `feature/${sanitized}`; | 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 | } |