| author | |
| committer | |
| log | c34a4c69e5d58f48f68c53ebed22183905a9db15 |
| tree | 3ec387f6d591412175eb056901f5e8c84bdc813a |
| parent | 2f6cac0cf0c7424236e3aaf04c2485e72fe1fe5c |
Replace date-based auto feature branch names (feature/stacked-YYYYMMDD)
with semantically meaningful names derived from AI-generated commit
messages. The text generation prompt now returns a branch field alongside
subject and body, and a new suggestCommitAndBranch endpoint exposes this
to the web client for the default-branch checkout flow.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>18 files changed, 388 insertions(+), 49 deletions(-)
apps/server/src/git/Layers/CodexTextGeneration.test.ts+2| ... | ... | @@ -196,6 +196,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 196 | 196 | subject: |
| 197 | 197 | " Add important change to the system with too much detail and a trailing period.\nsecondary line", |
| 198 | 198 | body: "\n- added migration\n- updated tests\n", |
| 199 | branch: "fix/important-system-change", | |
| 199 | 200 | }), |
| 200 | 201 | }, |
| 201 | 202 | Effect.gen(function* () { |
| ... | ... | @@ -211,6 +212,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 211 | 212 | expect(generated.subject.length).toBeLessThanOrEqual(72); |
| 212 | 213 | expect(generated.subject.endsWith(".")).toBe(false); |
| 213 | 214 | expect(generated.body).toBe("- added migration\n- updated tests"); |
| 215 | expect(generated.branch).toBe("feature/fix/important-system-change"); | |
| 214 | 216 | }), |
| 215 | 217 | ), |
| 216 | 218 | ); |
apps/server/src/git/Layers/CodexTextGeneration.ts+12-1| ... | ... | @@ -111,6 +111,14 @@ function sanitizeBranchName(raw: string): string { |
| 111 | 111 | return branchFragment.length > 0 ? branchFragment : "update"; |
| 112 | 112 | } |
| 113 | 113 | |
| 114 | function sanitizeFeatureBranchName(raw: string): string { | |
| 115 | const sanitized = sanitizeBranchName(raw); | |
| 116 | if (sanitized.includes("/")) { | |
| 117 | return sanitized.startsWith("feature/") ? sanitized : `feature/${sanitized}`; | |
| 118 | } | |
| 119 | return `feature/${sanitized}`; | |
| 120 | } | |
| 121 | ||
| 114 | 122 | const makeCodexTextGeneration = Effect.gen(function* () { |
| 115 | 123 | const fileSystem = yield* FileSystem.FileSystem; |
| 116 | 124 | const path = yield* Path.Path; |
| ... | ... | @@ -330,10 +338,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 330 | 338 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { |
| 331 | 339 | const prompt = [ |
| 332 | 340 | "You write concise git commit messages.", |
| 333 | "Return a JSON object with keys: subject, body.", | |
| 341 | "Return a JSON object with keys: subject, body, branch.", | |
| 334 | 342 | "Rules:", |
| 335 | 343 | "- subject must be imperative, <= 72 chars, and no trailing period", |
| 336 | 344 | "- body can be empty string or short bullet points", |
| 345 | "- branch must be a short semantic git branch fragment for this change", | |
| 337 | 346 | "- capture the primary user-visible or developer-visible change", |
| 338 | 347 | "", |
| 339 | 348 | `Branch: ${input.branch ?? "(detached)"}`, |
| ... | ... | @@ -352,6 +361,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 352 | 361 | outputSchemaJson: Schema.Struct({ |
| 353 | 362 | subject: Schema.String, |
| 354 | 363 | body: Schema.String, |
| 364 | branch: Schema.String, | |
| 355 | 365 | }), |
| 356 | 366 | }).pipe( |
| 357 | 367 | Effect.map( |
| ... | ... | @@ -359,6 +369,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 359 | 369 | ({ |
| 360 | 370 | subject: sanitizeCommitSubject(generated.subject), |
| 361 | 371 | body: generated.body.trim(), |
| 372 | branch: sanitizeFeatureBranchName(generated.branch), | |
| 362 | 373 | }) satisfies CommitMessageGenerationResult, |
| 363 | 374 | ), |
| 364 | 375 | ); |
apps/server/src/git/Layers/GitManager.test.ts+81-1| ... | ... | @@ -32,7 +32,7 @@ interface FakeGitTextGeneration { |
| 32 | 32 | branch: string | null; |
| 33 | 33 | stagedSummary: string; |
| 34 | 34 | stagedPatch: string; |
| 35 | }) => Effect.Effect<{ subject: string; body: string }, TextGenerationError>; | |
| 35 | }) => Effect.Effect<{ subject: string; body: string; branch: string }, TextGenerationError>; | |
| 36 | 36 | generatePrContent: (input: { |
| 37 | 37 | cwd: string; |
| 38 | 38 | baseBranch: string; |
| ... | ... | @@ -108,6 +108,7 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T |
| 108 | 108 | Effect.succeed({ |
| 109 | 109 | subject: "Implement stacked git actions", |
| 110 | 110 | body: "", |
| 111 | branch: "feature/implement-stacked-git-actions", | |
| 111 | 112 | }), |
| 112 | 113 | generatePrContent: () => |
| 113 | 114 | Effect.succeed({ |
| ... | ... | @@ -288,6 +289,16 @@ function runStackedAction( |
| 288 | 289 | return manager.runStackedAction(input); |
| 289 | 290 | } |
| 290 | 291 | |
| 292 | function suggestCommitAndBranch( | |
| 293 | manager: GitManagerShape, | |
| 294 | input: { | |
| 295 | cwd: string; | |
| 296 | commitMessage?: string; | |
| 297 | }, | |
| 298 | ) { | |
| 299 | return manager.suggestCommitAndBranch(input); | |
| 300 | } | |
| 301 | ||
| 291 | 302 | function makeManager(input?: { |
| 292 | 303 | ghScenario?: FakeGhScenario; |
| 293 | 304 | textGeneration?: Partial<FakeGitTextGeneration>; |
| ... | ... | @@ -501,6 +512,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 501 | 512 | return { |
| 502 | 513 | subject: "this should not be used", |
| 503 | 514 | body: "", |
| 515 | branch: "feature/unused", | |
| 504 | 516 | }; |
| 505 | 517 | }), |
| 506 | 518 | }, |
| ... | ... | @@ -527,6 +539,57 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 527 | 539 | }), |
| 528 | 540 | ); |
| 529 | 541 | |
| 542 | it.effect("suggests commit message and semantic branch for dirty worktree", () => | |
| 543 | Effect.gen(function* () { | |
| 544 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 545 | yield* initRepo(repoDir); | |
| 546 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nsuggest\n"); | |
| 547 | ||
| 548 | const { manager } = yield* makeManager(); | |
| 549 | const suggestion = yield* suggestCommitAndBranch(manager, { | |
| 550 | cwd: repoDir, | |
| 551 | }); | |
| 552 | ||
| 553 | expect(suggestion).toEqual({ | |
| 554 | commitMessage: "Implement stacked git actions", | |
| 555 | branch: "feature/implement-stacked-git-actions", | |
| 556 | }); | |
| 557 | }), | |
| 558 | ); | |
| 559 | ||
| 560 | it.effect("suggests branch from custom commit message without AI generation", () => | |
| 561 | Effect.gen(function* () { | |
| 562 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 563 | yield* initRepo(repoDir); | |
| 564 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-suggest\n"); | |
| 565 | let generatedCount = 0; | |
| 566 | ||
| 567 | const { manager } = yield* makeManager({ | |
| 568 | textGeneration: { | |
| 569 | generateCommitMessage: () => | |
| 570 | Effect.sync(() => { | |
| 571 | generatedCount += 1; | |
| 572 | return { | |
| 573 | subject: "unused", | |
| 574 | body: "", | |
| 575 | branch: "feature/unused", | |
| 576 | }; | |
| 577 | }), | |
| 578 | }, | |
| 579 | }); | |
| 580 | const suggestion = yield* suggestCommitAndBranch(manager, { | |
| 581 | cwd: repoDir, | |
| 582 | commitMessage: "feat: custom summary line\n\n- details from user", | |
| 583 | }); | |
| 584 | ||
| 585 | expect(suggestion).toEqual({ | |
| 586 | commitMessage: "feat: custom summary line\n\n- details from user", | |
| 587 | branch: "feature/feat-custom-summary-line", | |
| 588 | }); | |
| 589 | expect(generatedCount).toBe(0); | |
| 590 | }), | |
| 591 | ); | |
| 592 | ||
| 530 | 593 | it.effect("skips commit when there are no uncommitted changes", () => |
| 531 | 594 | Effect.gen(function* () { |
| 532 | 595 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| ... | ... | @@ -544,6 +607,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 544 | 607 | }), |
| 545 | 608 | ); |
| 546 | 609 | |
| 610 | it.effect("returns a clear error when suggesting from clean worktree", () => | |
| 611 | Effect.gen(function* () { | |
| 612 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 613 | yield* initRepo(repoDir); | |
| 614 | ||
| 615 | const { manager } = yield* makeManager(); | |
| 616 | const errorMessage = yield* suggestCommitAndBranch(manager, { | |
| 617 | cwd: repoDir, | |
| 618 | }).pipe( | |
| 619 | Effect.flip, | |
| 620 | Effect.map((error) => error.message), | |
| 621 | ); | |
| 622 | ||
| 623 | expect(errorMessage).toContain("no changes to commit"); | |
| 624 | }), | |
| 625 | ); | |
| 626 | ||
| 547 | 627 | it.effect("commits and pushes with upstream auto-setup when needed", () => |
| 548 | 628 | Effect.gen(function* () { |
| 549 | 629 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
apps/server/src/git/Layers/GitManager.ts+107-16| ... | ... | @@ -85,9 +85,10 @@ function limitContext(value: string, maxChars: number): string { |
| 85 | 85 | return `${value.slice(0, maxChars)}\n\n[truncated]`; |
| 86 | 86 | } |
| 87 | 87 | |
| 88 | function sanitizeCommitMessage(generated: { subject: string; body: string }): { | |
| 88 | function sanitizeCommitMessage(generated: { subject: string; body: string; branch: string }): { | |
| 89 | 89 | subject: string; |
| 90 | 90 | body: string; |
| 91 | branch: string; | |
| 91 | 92 | } { |
| 92 | 93 | const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; |
| 93 | 94 | const subject = rawSubject.replace(/[.]+$/g, "").trim(); |
| ... | ... | @@ -95,9 +96,44 @@ function sanitizeCommitMessage(generated: { subject: string; body: string }): { |
| 95 | 96 | return { |
| 96 | 97 | subject: safeSubject, |
| 97 | 98 | body: generated.body.trim(), |
| 99 | branch: generated.branch, | |
| 98 | 100 | }; |
| 99 | 101 | } |
| 100 | 102 | |
| 103 | function sanitizeBranchFragment(raw: string): string { | |
| 104 | const normalized = raw | |
| 105 | .trim() | |
| 106 | .toLowerCase() | |
| 107 | .replace(/['"`]/g, "") | |
| 108 | .replace(/^[./\s_-]+|[./\s_-]+$/g, ""); | |
| 109 | ||
| 110 | const branchFragment = normalized | |
| 111 | .replace(/[^a-z0-9/_-]+/g, "-") | |
| 112 | .replace(/\/+/g, "/") | |
| 113 | .replace(/-+/g, "-") | |
| 114 | .replace(/^[./_-]+|[./_-]+$/g, "") | |
| 115 | .slice(0, 64) | |
| 116 | .replace(/[./_-]+$/g, ""); | |
| 117 | ||
| 118 | return branchFragment.length > 0 ? branchFragment : "update"; | |
| 119 | } | |
| 120 | ||
| 121 | function sanitizeFeatureBranchName(raw: string): string { | |
| 122 | const sanitized = sanitizeBranchFragment(raw); | |
| 123 | if (sanitized.includes("/")) { | |
| 124 | return sanitized.startsWith("feature/") ? sanitized : `feature/${sanitized}`; | |
| 125 | } | |
| 126 | return `feature/${sanitized}`; | |
| 127 | } | |
| 128 | ||
| 129 | function formatCommitMessage(subject: string, body: string): string { | |
| 130 | const trimmedBody = body.trim(); | |
| 131 | if (trimmedBody.length === 0) { | |
| 132 | return subject; | |
| 133 | } | |
| 134 | return `${subject}\n\n${trimmedBody}`; | |
| 135 | } | |
| 136 | ||
| 101 | 137 | function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { |
| 102 | 138 | const normalized = raw.replace(/\r\n/g, "\n").trim(); |
| 103 | 139 | if (normalized.length === 0) { |
| ... | ... | @@ -253,30 +289,62 @@ export const makeGitManager = Effect.gen(function* () { |
| 253 | 289 | return "main"; |
| 254 | 290 | }); |
| 255 | 291 | |
| 256 | const runCommitStep = (cwd: string, branch: string | null, commitMessage?: string) => | |
| 292 | const resolveCommitAndBranchSuggestion = (input: { | |
| 293 | cwd: string; | |
| 294 | branch: string | null; | |
| 295 | commitMessage?: string; | |
| 296 | }) => | |
| 257 | 297 | Effect.gen(function* () { |
| 258 | const context = yield* gitCore.prepareCommitContext(cwd); | |
| 298 | const context = yield* gitCore.prepareCommitContext(input.cwd); | |
| 259 | 299 | if (!context) { |
| 260 | return { status: "skipped_no_changes" as const }; | |
| 300 | return null; | |
| 261 | 301 | } |
| 262 | 302 | |
| 263 | let generated = parseCustomCommitMessage(commitMessage ?? ""); | |
| 264 | if (!generated) { | |
| 265 | generated = yield* textGeneration | |
| 266 | .generateCommitMessage({ | |
| 267 | cwd, | |
| 268 | branch, | |
| 269 | stagedSummary: limitContext(context.stagedSummary, 8_000), | |
| 270 | stagedPatch: limitContext(context.stagedPatch, 50_000), | |
| 271 | }) | |
| 272 | .pipe(Effect.map((result) => sanitizeCommitMessage(result))); | |
| 303 | const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); | |
| 304 | if (customCommit) { | |
| 305 | const branch = sanitizeFeatureBranchName(customCommit.subject); | |
| 306 | return { | |
| 307 | subject: customCommit.subject, | |
| 308 | body: customCommit.body, | |
| 309 | branch, | |
| 310 | commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), | |
| 311 | }; | |
| 312 | } | |
| 313 | ||
| 314 | const generated = yield* textGeneration | |
| 315 | .generateCommitMessage({ | |
| 316 | cwd: input.cwd, | |
| 317 | branch: input.branch, | |
| 318 | stagedSummary: limitContext(context.stagedSummary, 8_000), | |
| 319 | stagedPatch: limitContext(context.stagedPatch, 50_000), | |
| 320 | }) | |
| 321 | .pipe(Effect.map((result) => sanitizeCommitMessage(result))); | |
| 322 | ||
| 323 | const branch = sanitizeFeatureBranchName(generated.branch); | |
| 324 | return { | |
| 325 | subject: generated.subject, | |
| 326 | body: generated.body, | |
| 327 | branch, | |
| 328 | commitMessage: formatCommitMessage(generated.subject, generated.body), | |
| 329 | }; | |
| 330 | }); | |
| 331 | ||
| 332 | const runCommitStep = (cwd: string, branch: string | null, commitMessage?: string) => | |
| 333 | Effect.gen(function* () { | |
| 334 | const suggestion = yield* resolveCommitAndBranchSuggestion({ | |
| 335 | cwd, | |
| 336 | branch, | |
| 337 | ...(commitMessage ? { commitMessage } : {}), | |
| 338 | }); | |
| 339 | if (!suggestion) { | |
| 340 | return { status: "skipped_no_changes" as const }; | |
| 273 | 341 | } |
| 274 | 342 | |
| 275 | const { commitSha } = yield* gitCore.commit(cwd, generated.subject, generated.body); | |
| 343 | const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body); | |
| 276 | 344 | return { |
| 277 | 345 | status: "created" as const, |
| 278 | 346 | commitSha, |
| 279 | subject: generated.subject, | |
| 347 | subject: suggestion.subject, | |
| 280 | 348 | }; |
| 281 | 349 | }); |
| 282 | 350 | |
| ... | ... | @@ -381,6 +449,28 @@ export const makeGitManager = Effect.gen(function* () { |
| 381 | 449 | }; |
| 382 | 450 | }); |
| 383 | 451 | |
| 452 | const suggestCommitAndBranch: GitManagerShape["suggestCommitAndBranch"] = Effect.fnUntraced( | |
| 453 | function* (input) { | |
| 454 | const details = yield* gitCore.statusDetails(input.cwd); | |
| 455 | const suggestion = yield* resolveCommitAndBranchSuggestion({ | |
| 456 | cwd: input.cwd, | |
| 457 | branch: details.branch, | |
| 458 | ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), | |
| 459 | }); | |
| 460 | if (!suggestion) { | |
| 461 | return yield* gitManagerError( | |
| 462 | "suggestCommitAndBranch", | |
| 463 | "Cannot suggest a commit and branch because there are no changes to commit.", | |
| 464 | ); | |
| 465 | } | |
| 466 | ||
| 467 | return { | |
| 468 | commitMessage: suggestion.commitMessage, | |
| 469 | branch: suggestion.branch, | |
| 470 | }; | |
| 471 | }, | |
| 472 | ); | |
| 473 | ||
| 384 | 474 | const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced( |
| 385 | 475 | function* (input) { |
| 386 | 476 | const wantsPush = input.action !== "commit"; |
| ... | ... | @@ -419,6 +509,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 419 | 509 | return { |
| 420 | 510 | status, |
| 421 | 511 | runStackedAction, |
| 512 | suggestCommitAndBranch, | |
| 422 | 513 | } satisfies GitManagerShape; |
| 423 | 514 | }); |
| 424 | 515 |
apps/server/src/git/Services/GitManager.ts+9| ... | ... | @@ -9,6 +9,8 @@ |
| 9 | 9 | import { |
| 10 | 10 | GitRunStackedActionInput, |
| 11 | 11 | GitRunStackedActionResult, |
| 12 | GitSuggestCommitAndBranchInput, | |
| 13 | GitSuggestCommitAndBranchResult, | |
| 12 | 14 | GitStatusInput, |
| 13 | 15 | GitStatusResult, |
| 14 | 16 | } from "@t3tools/contracts"; |
| ... | ... | @@ -33,6 +35,13 @@ export interface GitManagerShape { |
| 33 | 35 | readonly runStackedAction: ( |
| 34 | 36 | input: GitRunStackedActionInput, |
| 35 | 37 | ) => 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>; | |
| 36 | 45 | } |
| 37 | 46 | |
| 38 | 47 | /** |
apps/server/src/git/Services/TextGeneration.ts+1| ... | ... | @@ -22,6 +22,7 @@ export interface CommitMessageGenerationInput { |
| 22 | 22 | export interface CommitMessageGenerationResult { |
| 23 | 23 | subject: string; |
| 24 | 24 | body: string; |
| 25 | branch: string; | |
| 25 | 26 | } |
| 26 | 27 | |
| 27 | 28 | export interface PrContentGenerationInput { |
apps/server/src/wsServer.test.ts+31-1| ... | ... | @@ -1555,7 +1555,8 @@ 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 gitManager: GitManagerShape = { status, runStackedAction }; | |
| 1558 | const suggestCommitAndBranch = vi.fn(() => Effect.void as any); | |
| 1559 | const gitManager: GitManagerShape = { status, runStackedAction, suggestCommitAndBranch }; | |
| 1559 | 1560 | |
| 1560 | 1561 | server = await createTestServer({ cwd: "/test", gitManager }); |
| 1561 | 1562 | const addr = server.address(); |
| ... | ... | @@ -1585,6 +1586,7 @@ describe("WebSocket Server", () => { |
| 1585 | 1586 | const gitManager: GitManagerShape = { |
| 1586 | 1587 | status: vi.fn(() => Effect.void as any), |
| 1587 | 1588 | runStackedAction, |
| 1589 | suggestCommitAndBranch: vi.fn(() => Effect.void as any), | |
| 1588 | 1590 | }; |
| 1589 | 1591 | |
| 1590 | 1592 | server = await createTestServer({ cwd: "/test", gitManager }); |
| ... | ... | @@ -1607,6 +1609,34 @@ describe("WebSocket Server", () => { |
| 1607 | 1609 | }); |
| 1608 | 1610 | }); |
| 1609 | 1611 | |
| 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 | ||
| 1610 | 1640 | it("rejects websocket connections without a valid auth token", async () => { |
| 1611 | 1641 | server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); |
| 1612 | 1642 | const addr = server.address(); |
apps/server/src/wsServer.ts+5| ... | ... | @@ -668,6 +668,11 @@ 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 | ||
| 671 | 676 | case WS_METHODS.gitListBranches: { |
| 672 | 677 | const body = stripRequestTag(request.body); |
| 673 | 678 | return yield* git.listBranches(body); |
apps/web/src/components/GitActionsControl.logic.test.ts+16-8| ... | ... | @@ -910,18 +910,26 @@ describe("summarizeGitResult", () => { |
| 910 | 910 | }); |
| 911 | 911 | |
| 912 | 912 | describe("resolveAutoFeatureBranchName", () => { |
| 913 | const day = new Date("2026-03-01T12:00:00.000Z"); | |
| 913 | it("uses semantic preferred branch names when available", () => { | |
| 914 | const branch = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); | |
| 915 | assert.equal(branch, "feature/fix-toast-copy"); | |
| 916 | }); | |
| 914 | 917 | |
| 915 | it("returns the base auto feature branch name when available", () => { | |
| 916 | const branch = resolveAutoFeatureBranchName(["main", "feature/other"], day); | |
| 917 | assert.equal(branch, "feature/stacked-20260301"); | |
| 918 | it("normalizes preferred names that already include a branch namespace", () => { | |
| 919 | const branch = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions"); | |
| 920 | assert.equal(branch, "feature/refine-toolbar-actions"); | |
| 918 | 921 | }); |
| 919 | 922 | |
| 920 | it("increments suffix when the base name already exists", () => { | |
| 923 | it("increments suffix when the preferred branch name already exists", () => { | |
| 921 | 924 | const branch = resolveAutoFeatureBranchName( |
| 922 | ["main", "feature/stacked-20260301", "feature/stacked-20260301-2"], | |
| 923 | day, | |
| 925 | ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"], | |
| 926 | "fix toast copy", | |
| 924 | 927 | ); |
| 925 | assert.equal(branch, "feature/stacked-20260301-3"); | |
| 928 | assert.equal(branch, "feature/fix-toast-copy-3"); | |
| 929 | }); | |
| 930 | ||
| 931 | it("falls back to feature/update when no preferred name is provided", () => { | |
| 932 | const branch = resolveAutoFeatureBranchName(["main"]); | |
| 933 | assert.equal(branch, "feature/update"); | |
| 926 | 934 | }); |
| 927 | 935 | }); |
apps/web/src/components/GitActionsControl.logic.ts+31-13| ... | ... | @@ -35,7 +35,7 @@ export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr"; |
| 35 | 35 | |
| 36 | 36 | const SHORT_SHA_LENGTH = 7; |
| 37 | 37 | const TOAST_DESCRIPTION_MAX = 72; |
| 38 | const AUTO_FEATURE_BRANCH_PREFIX = "feature/stacked"; | |
| 38 | const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; | |
| 39 | 39 | |
| 40 | 40 | function shortenSha(sha: string | undefined): string | null { |
| 41 | 41 | if (!sha) return null; |
| ... | ... | @@ -321,29 +321,47 @@ export function resolveDefaultBranchActionDialogCopy(input: { |
| 321 | 321 | }; |
| 322 | 322 | } |
| 323 | 323 | |
| 324 | function formatDateForBranch(now: Date): string { | |
| 325 | const year = now.getUTCFullYear(); | |
| 326 | const month = String(now.getUTCMonth() + 1).padStart(2, "0"); | |
| 327 | const day = String(now.getUTCDate()).padStart(2, "0"); | |
| 328 | return `${year}${month}${day}`; | |
| 324 | function sanitizeBranchFragment(raw: string): string { | |
| 325 | const normalized = raw | |
| 326 | .trim() | |
| 327 | .toLowerCase() | |
| 328 | .replace(/['"`]/g, "") | |
| 329 | .replace(/^[./\s_-]+|[./\s_-]+$/g, ""); | |
| 330 | ||
| 331 | const branchFragment = normalized | |
| 332 | .replace(/[^a-z0-9/_-]+/g, "-") | |
| 333 | .replace(/\/+/g, "/") | |
| 334 | .replace(/-+/g, "-") | |
| 335 | .replace(/^[./_-]+|[./_-]+$/g, "") | |
| 336 | .slice(0, 64) | |
| 337 | .replace(/[./_-]+$/g, ""); | |
| 338 | ||
| 339 | return branchFragment.length > 0 ? branchFragment : "update"; | |
| 329 | 340 | } |
| 330 | 341 | |
| 331 | 342 | export function resolveAutoFeatureBranchName( |
| 332 | 343 | existingBranchNames: readonly string[], |
| 333 | now = new Date(), | |
| 344 | preferredBranch?: string, | |
| 334 | 345 | ): string { |
| 335 | const daySlug = formatDateForBranch(now); | |
| 336 | const base = `${AUTO_FEATURE_BRANCH_PREFIX}-${daySlug}`; | |
| 346 | const preferred = preferredBranch?.trim(); | |
| 347 | const normalized = sanitizeBranchFragment( | |
| 348 | preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK, | |
| 349 | ); | |
| 350 | const resolvedBase = normalized.includes("/") | |
| 351 | ? normalized.startsWith("feature/") | |
| 352 | ? normalized | |
| 353 | : `feature/${normalized}` | |
| 354 | : `feature/${normalized}`; | |
| 337 | 355 | const existingNames = new Set(existingBranchNames); |
| 338 | 356 | |
| 339 | if (!existingNames.has(base)) { | |
| 340 | return base; | |
| 357 | if (!existingNames.has(resolvedBase)) { | |
| 358 | return resolvedBase; | |
| 341 | 359 | } |
| 342 | 360 | |
| 343 | 361 | let suffix = 2; |
| 344 | while (existingNames.has(`${base}-${suffix}`)) { | |
| 362 | while (existingNames.has(`${resolvedBase}-${suffix}`)) { | |
| 345 | 363 | suffix += 1; |
| 346 | 364 | } |
| 347 | 365 | |
| 348 | return `${base}-${suffix}`; | |
| 366 | return `${resolvedBase}-${suffix}`; | |
| 349 | 367 | } |
apps/web/src/components/GitActionsControl.tsx+42-8| ... | ... | @@ -39,6 +39,7 @@ import { |
| 39 | 39 | gitMutationKeys, |
| 40 | 40 | gitPullMutationOptions, |
| 41 | 41 | gitRunStackedActionMutationOptions, |
| 42 | gitSuggestCommitAndBranchMutationOptions, | |
| 42 | 43 | gitStatusQueryOptions, |
| 43 | 44 | invalidateGitQueries, |
| 44 | 45 | } from "~/lib/gitReactQuery"; |
| ... | ... | @@ -172,6 +173,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 172 | 173 | const runImmediateGitActionMutation = useMutation( |
| 173 | 174 | gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), |
| 174 | 175 | ); |
| 176 | const suggestCommitAndBranchMutation = useMutation( | |
| 177 | gitSuggestCommitAndBranchMutationOptions({ cwd: gitCwd }), | |
| 178 | ); | |
| 175 | 179 | const createBranchAndCheckoutMutation = useMutation( |
| 176 | 180 | gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }), |
| 177 | 181 | ); |
| ... | ... | @@ -179,11 +183,16 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 179 | 183 | |
| 180 | 184 | const isRunStackedActionRunning = |
| 181 | 185 | useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; |
| 186 | const isSuggestCommitAndBranchRunning = | |
| 187 | useIsMutating({ mutationKey: gitMutationKeys.suggestCommitAndBranch(gitCwd) }) > 0; | |
| 182 | 188 | const isCreateBranchAndCheckoutRunning = |
| 183 | 189 | useIsMutating({ mutationKey: gitMutationKeys.createBranchAndCheckout(gitCwd) }) > 0; |
| 184 | 190 | const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; |
| 185 | 191 | const isGitActionRunning = |
| 186 | isRunStackedActionRunning || isCreateBranchAndCheckoutRunning || isPullRunning; | |
| 192 | isRunStackedActionRunning || | |
| 193 | isSuggestCommitAndBranchRunning || | |
| 194 | isCreateBranchAndCheckoutRunning || | |
| 195 | isPullRunning; | |
| 187 | 196 | const isDefaultBranch = useMemo(() => { |
| 188 | 197 | const branchName = gitStatusForActions?.branch; |
| 189 | 198 | if (!branchName) return false; |
| ... | ... | @@ -443,20 +452,43 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 443 | 452 | forcePushOnlyProgress?: boolean; |
| 444 | 453 | onConfirmed?: () => void; |
| 445 | 454 | }) => { |
| 446 | const branchName = resolveAutoFeatureBranchName( | |
| 447 | branchList?.branches.map((branch) => branch.name) ?? [], | |
| 448 | ); | |
| 455 | const existingBranchNames = branchList?.branches.map((branch) => branch.name) ?? []; | |
| 456 | const providedCommitMessage = actionParams.commitMessage?.trim() || undefined; | |
| 457 | const shouldSuggestCommitAndBranch = | |
| 458 | !providedCommitMessage && !!gitStatusForActions?.hasWorkingTreeChanges; | |
| 449 | 459 | |
| 450 | const checkoutPromise = createBranchAndCheckoutMutation.mutateAsync(branchName); | |
| 451 | 460 | const checkoutToastId = toastManager.add({ |
| 452 | 461 | type: "loading", |
| 453 | title: `Creating ${branchName}...`, | |
| 462 | title: "Preparing feature branch...", | |
| 454 | 463 | timeout: 0, |
| 455 | 464 | data: threadToastData, |
| 456 | 465 | }); |
| 457 | 466 | |
| 458 | void checkoutPromise | |
| 459 | .then(() => { | |
| 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 }) => { | |
| 460 | 492 | const statusOverride = gitStatusForActions |
| 461 | 493 | ? { |
| 462 | 494 | ...gitStatusForActions, |
| ... | ... | @@ -469,6 +501,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 469 | 501 | : null; |
| 470 | 502 | return runGitActionWithToast({ |
| 471 | 503 | ...actionParams, |
| 504 | ...(resolvedCommitMessage ? { commitMessage: resolvedCommitMessage } : {}), | |
| 472 | 505 | skipDefaultBranchPrompt: true, |
| 473 | 506 | statusOverride, |
| 474 | 507 | isDefaultBranchOverride: false, |
| ... | ... | @@ -489,6 +522,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 489 | 522 | createBranchAndCheckoutMutation, |
| 490 | 523 | gitStatusForActions, |
| 491 | 524 | runGitActionWithToast, |
| 525 | suggestCommitAndBranchMutation, | |
| 492 | 526 | threadToastData, |
| 493 | 527 | ], |
| 494 | 528 | ); |
apps/web/src/lib/gitReactQuery.test.ts+12| ... | ... | @@ -4,6 +4,7 @@ import { |
| 4 | 4 | gitMutationKeys, |
| 5 | 5 | gitPullMutationOptions, |
| 6 | 6 | gitRunStackedActionMutationOptions, |
| 7 | gitSuggestCommitAndBranchMutationOptions, | |
| 7 | 8 | } from "./gitReactQuery"; |
| 8 | 9 | |
| 9 | 10 | describe("gitMutationKeys", () => { |
| ... | ... | @@ -16,6 +17,12 @@ describe("gitMutationKeys", () => { |
| 16 | 17 | it("scopes pull keys by cwd", () => { |
| 17 | 18 | expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b")); |
| 18 | 19 | }); |
| 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 | }); | |
| 19 | 26 | }); |
| 20 | 27 | |
| 21 | 28 | describe("git mutation options", () => { |
| ... | ... | @@ -30,4 +37,9 @@ describe("git mutation options", () => { |
| 30 | 37 | const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); |
| 31 | 38 | expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); |
| 32 | 39 | }); |
| 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 | }); | |
| 33 | 45 | }); |
apps/web/src/lib/gitReactQuery.ts+16| ... | ... | @@ -19,6 +19,8 @@ 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, | |
| 22 | 24 | pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, |
| 23 | 25 | }; |
| 24 | 26 | |
| ... | ... | @@ -134,6 +136,20 @@ export function gitRunStackedActionMutationOptions(input: { |
| 134 | 136 | }); |
| 135 | 137 | } |
| 136 | 138 | |
| 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 | ||
| 137 | 153 | export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { |
| 138 | 154 | return mutationOptions({ |
| 139 | 155 | mutationKey: gitMutationKeys.pull(input.cwd), |
apps/web/src/wsNativeApi.test.ts+1-1| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | ProjectId, |
| 7 | 7 | ThreadId, |
| 8 | 8 | WS_CHANNELS, |
| 9 | WS_METHODS, | |
| 9 | 10 | } from "@t3tools/contracts"; |
| 10 | 11 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 11 | 12 | |
| ... | ... | @@ -376,5 +377,4 @@ describe("wsNativeApi", () => { |
| 376 | 377 | { x: 20, y: 30 }, |
| 377 | 378 | ); |
| 378 | 379 | }); |
| 379 | ||
| 380 | 380 | }); |
apps/web/src/wsNativeApi.ts+2| ... | ... | @@ -163,6 +163,8 @@ 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), | |
| 166 | 168 | listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), |
| 167 | 169 | createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), |
| 168 | 170 | removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), |
packages/contracts/src/git.ts+12| ... | ... | @@ -56,6 +56,12 @@ export const GitRunStackedActionInput = Schema.Struct({ |
| 56 | 56 | }); |
| 57 | 57 | export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; |
| 58 | 58 | |
| 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 | ||
| 59 | 65 | export const GitListBranchesInput = Schema.Struct({ |
| 60 | 66 | cwd: TrimmedNonEmptyStringSchema, |
| 61 | 67 | }); |
| ... | ... | @@ -161,6 +167,12 @@ export const GitRunStackedActionResult = Schema.Struct({ |
| 161 | 167 | }); |
| 162 | 168 | export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; |
| 163 | 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 | ||
| 164 | 176 | export const GitPullResult = Schema.Struct({ |
| 165 | 177 | status: Schema.Literals(["pulled", "skipped_up_to_date"]), |
| 166 | 178 | branch: TrimmedNonEmptyStringSchema, |
packages/contracts/src/ipc.ts+5| ... | ... | @@ -11,6 +11,8 @@ import type { |
| 11 | 11 | GitRemoveWorktreeInput, |
| 12 | 12 | GitRunStackedActionInput, |
| 13 | 13 | GitRunStackedActionResult, |
| 14 | GitSuggestCommitAndBranchInput, | |
| 15 | GitSuggestCommitAndBranchResult, | |
| 14 | 16 | GitStatusInput, |
| 15 | 17 | GitStatusResult, |
| 16 | 18 | } from "./git"; |
| ... | ... | @@ -88,6 +90,9 @@ export interface NativeApi { |
| 88 | 90 | pull: (input: GitPullInput) => Promise<GitPullResult>; |
| 89 | 91 | status: (input: GitStatusInput) => Promise<GitStatusResult>; |
| 90 | 92 | runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>; |
| 93 | suggestCommitAndBranch: ( | |
| 94 | input: GitSuggestCommitAndBranchInput, | |
| 95 | ) => Promise<GitSuggestCommitAndBranchResult>; | |
| 91 | 96 | }; |
| 92 | 97 | contextMenu: { |
| 93 | 98 | show: <T extends string>( |
packages/contracts/src/ws.ts+3| ... | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | GitPullInput, |
| 19 | 19 | GitRemoveWorktreeInput, |
| 20 | 20 | GitRunStackedActionInput, |
| 21 | GitSuggestCommitAndBranchInput, | |
| 21 | 22 | GitStatusInput, |
| 22 | 23 | } from "./git"; |
| 23 | 24 | import { |
| ... | ... | @@ -48,6 +49,7 @@ export const WS_METHODS = { |
| 48 | 49 | gitPull: "git.pull", |
| 49 | 50 | gitStatus: "git.status", |
| 50 | 51 | gitRunStackedAction: "git.runStackedAction", |
| 52 | gitSuggestCommitAndBranch: "git.suggestCommitAndBranch", | |
| 51 | 53 | gitListBranches: "git.listBranches", |
| 52 | 54 | gitCreateWorktree: "git.createWorktree", |
| 53 | 55 | gitRemoveWorktree: "git.removeWorktree", |
| ... | ... | @@ -109,6 +111,7 @@ export const WebSocketRequestBody = Schema.Union([ |
| 109 | 111 | tagRequestBody(WS_METHODS.gitPull, GitPullInput), |
| 110 | 112 | tagRequestBody(WS_METHODS.gitStatus, GitStatusInput), |
| 111 | 113 | tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), |
| 114 | tagRequestBody(WS_METHODS.gitSuggestCommitAndBranch, GitSuggestCommitAndBranchInput), | |
| 112 | 115 | tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), |
| 113 | 116 | tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), |
| 114 | 117 | tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), |