| 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,6 +196,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 196 | subject: | 196 | subject: |
| 197 | " Add important change to the system with too much detail and a trailing period.\nsecondary line", | 197 | " Add important change to the system with too much detail and a trailing period.\nsecondary line", |
| 198 | body: "\n- added migration\n- updated tests\n", | 198 | body: "\n- added migration\n- updated tests\n", |
| 199 | branch: "fix/important-system-change", | ||
| 199 | }), | 200 | }), |
| 200 | }, | 201 | }, |
| 201 | Effect.gen(function* () { | 202 | Effect.gen(function* () { |
| ... | @@ -211,6 +212,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -211,6 +212,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 211 | expect(generated.subject.length).toBeLessThanOrEqual(72); | 212 | expect(generated.subject.length).toBeLessThanOrEqual(72); |
| 212 | expect(generated.subject.endsWith(".")).toBe(false); | 213 | expect(generated.subject.endsWith(".")).toBe(false); |
| 213 | expect(generated.body).toBe("- added migration\n- updated tests"); | 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,6 +111,14 @@ function sanitizeBranchName(raw: string): string { |
| 111 | return branchFragment.length > 0 ? branchFragment : "update"; | 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 | const makeCodexTextGeneration = Effect.gen(function* () { | 122 | const makeCodexTextGeneration = Effect.gen(function* () { |
| 115 | const fileSystem = yield* FileSystem.FileSystem; | 123 | const fileSystem = yield* FileSystem.FileSystem; |
| 116 | const path = yield* Path.Path; | 124 | const path = yield* Path.Path; |
| ... | @@ -330,10 +338,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -330,10 +338,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 330 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { | 338 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { |
| 331 | const prompt = [ | 339 | const prompt = [ |
| 332 | "You write concise git commit messages.", | 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 | "Rules:", | 342 | "Rules:", |
| 335 | "- subject must be imperative, <= 72 chars, and no trailing period", | 343 | "- subject must be imperative, <= 72 chars, and no trailing period", |
| 336 | "- body can be empty string or short bullet points", | 344 | "- body can be empty string or short bullet points", |
| 345 | "- branch must be a short semantic git branch fragment for this change", | ||
| 337 | "- capture the primary user-visible or developer-visible change", | 346 | "- capture the primary user-visible or developer-visible change", |
| 338 | "", | 347 | "", |
| 339 | `Branch: ${input.branch ?? "(detached)"}`, | 348 | `Branch: ${input.branch ?? "(detached)"}`, |
| ... | @@ -352,6 +361,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -352,6 +361,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 352 | outputSchemaJson: Schema.Struct({ | 361 | outputSchemaJson: Schema.Struct({ |
| 353 | subject: Schema.String, | 362 | subject: Schema.String, |
| 354 | body: Schema.String, | 363 | body: Schema.String, |
| 364 | branch: Schema.String, | ||
| 355 | }), | 365 | }), |
| 356 | }).pipe( | 366 | }).pipe( |
| 357 | Effect.map( | 367 | Effect.map( |
| ... | @@ -359,6 +369,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -359,6 +369,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 359 | ({ | 369 | ({ |
| 360 | subject: sanitizeCommitSubject(generated.subject), | 370 | subject: sanitizeCommitSubject(generated.subject), |
| 361 | body: generated.body.trim(), | 371 | body: generated.body.trim(), |
| 372 | branch: sanitizeFeatureBranchName(generated.branch), | ||
| 362 | }) satisfies CommitMessageGenerationResult, | 373 | }) satisfies CommitMessageGenerationResult, |
| 363 | ), | 374 | ), |
| 364 | ); | 375 | ); |
apps/server/src/git/Layers/GitManager.test.ts+81-1| ... | @@ -32,7 +32,7 @@ interface FakeGitTextGeneration { | ... | @@ -32,7 +32,7 @@ interface FakeGitTextGeneration { |
| 32 | branch: string | null; | 32 | branch: string | null; |
| 33 | stagedSummary: string; | 33 | stagedSummary: string; |
| 34 | stagedPatch: string; | 34 | stagedPatch: string; |
| 35 | }) => Effect.Effect<{ subject: string; body: string }, TextGenerationError>; | 35 | }) => Effect.Effect<{ subject: string; body: string; branch: string }, TextGenerationError>; |
| 36 | generatePrContent: (input: { | 36 | generatePrContent: (input: { |
| 37 | cwd: string; | 37 | cwd: string; |
| 38 | baseBranch: string; | 38 | baseBranch: string; |
| ... | @@ -108,6 +108,7 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T | ... | @@ -108,6 +108,7 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T |
| 108 | Effect.succeed({ | 108 | Effect.succeed({ |
| 109 | subject: "Implement stacked git actions", | 109 | subject: "Implement stacked git actions", |
| 110 | body: "", | 110 | body: "", |
| 111 | branch: "feature/implement-stacked-git-actions", | ||
| 111 | }), | 112 | }), |
| 112 | generatePrContent: () => | 113 | generatePrContent: () => |
| 113 | Effect.succeed({ | 114 | Effect.succeed({ |
| ... | @@ -288,6 +289,16 @@ function runStackedAction( | ... | @@ -288,6 +289,16 @@ function runStackedAction( |
| 288 | return manager.runStackedAction(input); | 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 | function makeManager(input?: { | 302 | function makeManager(input?: { |
| 292 | ghScenario?: FakeGhScenario; | 303 | ghScenario?: FakeGhScenario; |
| 293 | textGeneration?: Partial<FakeGitTextGeneration>; | 304 | textGeneration?: Partial<FakeGitTextGeneration>; |
| ... | @@ -501,6 +512,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -501,6 +512,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 501 | return { | 512 | return { |
| 502 | subject: "this should not be used", | 513 | subject: "this should not be used", |
| 503 | body: "", | 514 | body: "", |
| 515 | branch: "feature/unused", | ||
| 504 | }; | 516 | }; |
| 505 | }), | 517 | }), |
| 506 | }, | 518 | }, |
| ... | @@ -527,6 +539,57 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -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 | it.effect("skips commit when there are no uncommitted changes", () => | 593 | it.effect("skips commit when there are no uncommitted changes", () => |
| 531 | Effect.gen(function* () { | 594 | Effect.gen(function* () { |
| 532 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | 595 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| ... | @@ -544,6 +607,23 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { | ... | @@ -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 | it.effect("commits and pushes with upstream auto-setup when needed", () => | 627 | it.effect("commits and pushes with upstream auto-setup when needed", () => |
| 548 | Effect.gen(function* () { | 628 | Effect.gen(function* () { |
| 549 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | 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,9 +85,10 @@ function limitContext(value: string, maxChars: number): string { |
| 85 | return `${value.slice(0, maxChars)}\n\n[truncated]`; | 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 | subject: string; | 89 | subject: string; |
| 90 | body: string; | 90 | body: string; |
| 91 | branch: string; | ||
| 91 | } { | 92 | } { |
| 92 | const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | 93 | const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; |
| 93 | const subject = rawSubject.replace(/[.]+$/g, "").trim(); | 94 | const subject = rawSubject.replace(/[.]+$/g, "").trim(); |
| ... | @@ -95,9 +96,44 @@ function sanitizeCommitMessage(generated: { subject: string; body: string }): { | ... | @@ -95,9 +96,44 @@ function sanitizeCommitMessage(generated: { subject: string; body: string }): { |
| 95 | return { | 96 | return { |
| 96 | subject: safeSubject, | 97 | subject: safeSubject, |
| 97 | body: generated.body.trim(), | 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 | function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { | 137 | function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { |
| 102 | const normalized = raw.replace(/\r\n/g, "\n").trim(); | 138 | const normalized = raw.replace(/\r\n/g, "\n").trim(); |
| 103 | if (normalized.length === 0) { | 139 | if (normalized.length === 0) { |
| ... | @@ -253,30 +289,62 @@ export const makeGitManager = Effect.gen(function* () { | ... | @@ -253,30 +289,62 @@ export const makeGitManager = Effect.gen(function* () { |
| 253 | return "main"; | 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 | Effect.gen(function* () { | 297 | Effect.gen(function* () { |
| 258 | const context = yield* gitCore.prepareCommitContext(cwd); | 298 | const context = yield* gitCore.prepareCommitContext(input.cwd); |
| 259 | if (!context) { | 299 | if (!context) { |
| 260 | return { status: "skipped_no_changes" as const }; | 300 | return null; |
| 261 | } | 301 | } |
| 262 | 302 | ||
| 263 | let generated = parseCustomCommitMessage(commitMessage ?? ""); | 303 | const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); |
| 264 | if (!generated) { | 304 | if (customCommit) { |
| 265 | generated = yield* textGeneration | 305 | const branch = sanitizeFeatureBranchName(customCommit.subject); |
| 266 | .generateCommitMessage({ | 306 | return { |
| 267 | cwd, | 307 | subject: customCommit.subject, |
| 268 | branch, | 308 | body: customCommit.body, |
| 269 | stagedSummary: limitContext(context.stagedSummary, 8_000), | 309 | branch, |
| 270 | stagedPatch: limitContext(context.stagedPatch, 50_000), | 310 | commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), |
| 271 | }) | 311 | }; |
| 272 | .pipe(Effect.map((result) => sanitizeCommitMessage(result))); | 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 | return { | 344 | return { |
| 277 | status: "created" as const, | 345 | status: "created" as const, |
| 278 | commitSha, | 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,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 | const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced( | 474 | const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fnUntraced( |
| 385 | function* (input) { | 475 | function* (input) { |
| 386 | const wantsPush = input.action !== "commit"; | 476 | const wantsPush = input.action !== "commit"; |
| ... | @@ -419,6 +509,7 @@ export const makeGitManager = Effect.gen(function* () { | ... | @@ -419,6 +509,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 419 | return { | 509 | return { |
| 420 | status, | 510 | status, |
| 421 | runStackedAction, | 511 | runStackedAction, |
| 512 | suggestCommitAndBranch, | ||
| 422 | } satisfies GitManagerShape; | 513 | } satisfies GitManagerShape; |
| 423 | }); | 514 | }); |
| 424 | 515 |
apps/server/src/git/Services/GitManager.ts+9| ... | @@ -9,6 +9,8 @@ | ... | @@ -9,6 +9,8 @@ |
| 9 | import { | 9 | import { |
| 10 | GitRunStackedActionInput, | 10 | GitRunStackedActionInput, |
| 11 | GitRunStackedActionResult, | 11 | GitRunStackedActionResult, |
| 12 | GitSuggestCommitAndBranchInput, | ||
| 13 | GitSuggestCommitAndBranchResult, | ||
| 12 | GitStatusInput, | 14 | GitStatusInput, |
| 13 | GitStatusResult, | 15 | GitStatusResult, |
| 14 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| ... | @@ -33,6 +35,13 @@ export interface GitManagerShape { | ... | @@ -33,6 +35,13 @@ export interface GitManagerShape { |
| 33 | readonly runStackedAction: ( | 35 | readonly runStackedAction: ( |
| 34 | input: GitRunStackedActionInput, | 36 | input: GitRunStackedActionInput, |
| 35 | ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>; | 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,6 +22,7 @@ export interface CommitMessageGenerationInput { |
| 22 | export interface CommitMessageGenerationResult { | 22 | export interface CommitMessageGenerationResult { |
| 23 | subject: string; | 23 | subject: string; |
| 24 | body: string; | 24 | body: string; |
| 25 | branch: string; | ||
| 25 | } | 26 | } |
| 26 | 27 | ||
| 27 | export interface PrContentGenerationInput { | 28 | export interface PrContentGenerationInput { |
apps/server/src/wsServer.test.ts+31-1| ... | @@ -1555,7 +1555,8 @@ describe("WebSocket Server", () => { | ... | @@ -1555,7 +1555,8 @@ 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 gitManager: GitManagerShape = { status, runStackedAction }; | 1558 | const suggestCommitAndBranch = vi.fn(() => Effect.void as any); |
| 1559 | const gitManager: GitManagerShape = { status, runStackedAction, suggestCommitAndBranch }; | ||
| 1559 | 1560 | ||
| 1560 | server = await createTestServer({ cwd: "/test", gitManager }); | 1561 | server = await createTestServer({ cwd: "/test", gitManager }); |
| 1561 | const addr = server.address(); | 1562 | const addr = server.address(); |
| ... | @@ -1585,6 +1586,7 @@ describe("WebSocket Server", () => { | ... | @@ -1585,6 +1586,7 @@ describe("WebSocket Server", () => { |
| 1585 | const gitManager: GitManagerShape = { | 1586 | const gitManager: GitManagerShape = { |
| 1586 | status: vi.fn(() => Effect.void as any), | 1587 | status: vi.fn(() => Effect.void as any), |
| 1587 | runStackedAction, | 1588 | runStackedAction, |
| 1589 | suggestCommitAndBranch: vi.fn(() => Effect.void as any), | ||
| 1588 | }; | 1590 | }; |
| 1589 | 1591 | ||
| 1590 | server = await createTestServer({ cwd: "/test", gitManager }); | 1592 | server = await createTestServer({ cwd: "/test", gitManager }); |
| ... | @@ -1607,6 +1609,34 @@ describe("WebSocket Server", () => { | ... | @@ -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 | it("rejects websocket connections without a valid auth token", async () => { | 1640 | it("rejects websocket connections without a valid auth token", async () => { |
| 1611 | server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); | 1641 | server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); |
| 1612 | const addr = server.address(); | 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,6 +668,11 @@ 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 | |||
| 671 | case WS_METHODS.gitListBranches: { | 676 | case WS_METHODS.gitListBranches: { |
| 672 | const body = stripRequestTag(request.body); | 677 | const body = stripRequestTag(request.body); |
| 673 | return yield* git.listBranches(body); | 678 | return yield* git.listBranches(body); |
apps/web/src/components/GitActionsControl.logic.test.ts+16-8| ... | @@ -910,18 +910,26 @@ describe("summarizeGitResult", () => { | ... | @@ -910,18 +910,26 @@ describe("summarizeGitResult", () => { |
| 910 | }); | 910 | }); |
| 911 | 911 | ||
| 912 | describe("resolveAutoFeatureBranchName", () => { | 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", () => { | 918 | it("normalizes preferred names that already include a branch namespace", () => { |
| 916 | const branch = resolveAutoFeatureBranchName(["main", "feature/other"], day); | 919 | const branch = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions"); |
| 917 | assert.equal(branch, "feature/stacked-20260301"); | 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 | const branch = resolveAutoFeatureBranchName( | 924 | const branch = resolveAutoFeatureBranchName( |
| 922 | ["main", "feature/stacked-20260301", "feature/stacked-20260301-2"], | 925 | ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"], |
| 923 | day, | 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,7 +35,7 @@ export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr"; |
| 35 | 35 | ||
| 36 | const SHORT_SHA_LENGTH = 7; | 36 | const SHORT_SHA_LENGTH = 7; |
| 37 | const TOAST_DESCRIPTION_MAX = 72; | 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 | function shortenSha(sha: string | undefined): string | null { | 40 | function shortenSha(sha: string | undefined): string | null { |
| 41 | if (!sha) return null; | 41 | if (!sha) return null; |
| ... | @@ -321,29 +321,47 @@ export function resolveDefaultBranchActionDialogCopy(input: { | ... | @@ -321,29 +321,47 @@ export function resolveDefaultBranchActionDialogCopy(input: { |
| 321 | }; | 321 | }; |
| 322 | } | 322 | } |
| 323 | 323 | ||
| 324 | function formatDateForBranch(now: Date): string { | 324 | function sanitizeBranchFragment(raw: string): string { |
| 325 | const year = now.getUTCFullYear(); | 325 | const normalized = raw |
| 326 | const month = String(now.getUTCMonth() + 1).padStart(2, "0"); | 326 | .trim() |
| 327 | const day = String(now.getUTCDate()).padStart(2, "0"); | 327 | .toLowerCase() |
| 328 | return `${year}${month}${day}`; | 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 | export function resolveAutoFeatureBranchName( | 342 | export function resolveAutoFeatureBranchName( |
| 332 | existingBranchNames: readonly string[], | 343 | existingBranchNames: readonly string[], |
| 333 | now = new Date(), | 344 | preferredBranch?: string, |
| 334 | ): string { | 345 | ): string { |
| 335 | const daySlug = formatDateForBranch(now); | 346 | const preferred = preferredBranch?.trim(); |
| 336 | const base = `${AUTO_FEATURE_BRANCH_PREFIX}-${daySlug}`; | 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 | const existingNames = new Set(existingBranchNames); | 355 | const existingNames = new Set(existingBranchNames); |
| 338 | 356 | ||
| 339 | if (!existingNames.has(base)) { | 357 | if (!existingNames.has(resolvedBase)) { |
| 340 | return base; | 358 | return resolvedBase; |
| 341 | } | 359 | } |
| 342 | 360 | ||
| 343 | let suffix = 2; | 361 | let suffix = 2; |
| 344 | while (existingNames.has(`${base}-${suffix}`)) { | 362 | while (existingNames.has(`${resolvedBase}-${suffix}`)) { |
| 345 | suffix += 1; | 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,6 +39,7 @@ import { |
| 39 | gitMutationKeys, | 39 | gitMutationKeys, |
| 40 | gitPullMutationOptions, | 40 | gitPullMutationOptions, |
| 41 | gitRunStackedActionMutationOptions, | 41 | gitRunStackedActionMutationOptions, |
| 42 | gitSuggestCommitAndBranchMutationOptions, | ||
| 42 | gitStatusQueryOptions, | 43 | gitStatusQueryOptions, |
| 43 | invalidateGitQueries, | 44 | invalidateGitQueries, |
| 44 | } from "~/lib/gitReactQuery"; | 45 | } from "~/lib/gitReactQuery"; |
| ... | @@ -172,6 +173,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -172,6 +173,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 172 | const runImmediateGitActionMutation = useMutation( | 173 | const runImmediateGitActionMutation = useMutation( |
| 173 | gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), | 174 | gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), |
| 174 | ); | 175 | ); |
| 176 | const suggestCommitAndBranchMutation = useMutation( | ||
| 177 | gitSuggestCommitAndBranchMutationOptions({ cwd: gitCwd }), | ||
| 178 | ); | ||
| 175 | const createBranchAndCheckoutMutation = useMutation( | 179 | const createBranchAndCheckoutMutation = useMutation( |
| 176 | gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }), | 180 | gitCreateBranchAndCheckoutMutationOptions({ cwd: gitCwd, queryClient }), |
| 177 | ); | 181 | ); |
| ... | @@ -179,11 +183,16 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -179,11 +183,16 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 179 | 183 | ||
| 180 | const isRunStackedActionRunning = | 184 | const isRunStackedActionRunning = |
| 181 | useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; | 185 | useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; |
| 186 | const isSuggestCommitAndBranchRunning = | ||
| 187 | useIsMutating({ mutationKey: gitMutationKeys.suggestCommitAndBranch(gitCwd) }) > 0; | ||
| 182 | const isCreateBranchAndCheckoutRunning = | 188 | const isCreateBranchAndCheckoutRunning = |
| 183 | useIsMutating({ mutationKey: gitMutationKeys.createBranchAndCheckout(gitCwd) }) > 0; | 189 | useIsMutating({ mutationKey: gitMutationKeys.createBranchAndCheckout(gitCwd) }) > 0; |
| 184 | const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; | 190 | const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; |
| 185 | const isGitActionRunning = | 191 | const isGitActionRunning = |
| 186 | isRunStackedActionRunning || isCreateBranchAndCheckoutRunning || isPullRunning; | 192 | isRunStackedActionRunning || |
| 193 | isSuggestCommitAndBranchRunning || | ||
| 194 | isCreateBranchAndCheckoutRunning || | ||
| 195 | isPullRunning; | ||
| 187 | const isDefaultBranch = useMemo(() => { | 196 | const isDefaultBranch = useMemo(() => { |
| 188 | const branchName = gitStatusForActions?.branch; | 197 | const branchName = gitStatusForActions?.branch; |
| 189 | if (!branchName) return false; | 198 | if (!branchName) return false; |
| ... | @@ -443,20 +452,43 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -443,20 +452,43 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 443 | forcePushOnlyProgress?: boolean; | 452 | forcePushOnlyProgress?: boolean; |
| 444 | onConfirmed?: () => void; | 453 | onConfirmed?: () => void; |
| 445 | }) => { | 454 | }) => { |
| 446 | const branchName = resolveAutoFeatureBranchName( | 455 | const existingBranchNames = branchList?.branches.map((branch) => branch.name) ?? []; |
| 447 | branchList?.branches.map((branch) => branch.name) ?? [], | 456 | const providedCommitMessage = actionParams.commitMessage?.trim() || undefined; |
| 448 | ); | 457 | const shouldSuggestCommitAndBranch = |
| 458 | !providedCommitMessage && !!gitStatusForActions?.hasWorkingTreeChanges; | ||
| 449 | 459 | ||
| 450 | const checkoutPromise = createBranchAndCheckoutMutation.mutateAsync(branchName); | ||
| 451 | const checkoutToastId = toastManager.add({ | 460 | const checkoutToastId = toastManager.add({ |
| 452 | type: "loading", | 461 | type: "loading", |
| 453 | title: `Creating ${branchName}...`, | 462 | title: "Preparing feature branch...", |
| 454 | timeout: 0, | 463 | timeout: 0, |
| 455 | data: threadToastData, | 464 | data: threadToastData, |
| 456 | }); | 465 | }); |
| 457 | 466 | ||
| 458 | void checkoutPromise | 467 | const suggestionPromise = shouldSuggestCommitAndBranch |
| 459 | .then(() => { | 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 | const statusOverride = gitStatusForActions | 492 | const statusOverride = gitStatusForActions |
| 461 | ? { | 493 | ? { |
| 462 | ...gitStatusForActions, | 494 | ...gitStatusForActions, |
| ... | @@ -469,6 +501,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -469,6 +501,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 469 | : null; | 501 | : null; |
| 470 | return runGitActionWithToast({ | 502 | return runGitActionWithToast({ |
| 471 | ...actionParams, | 503 | ...actionParams, |
| 504 | ...(resolvedCommitMessage ? { commitMessage: resolvedCommitMessage } : {}), | ||
| 472 | skipDefaultBranchPrompt: true, | 505 | skipDefaultBranchPrompt: true, |
| 473 | statusOverride, | 506 | statusOverride, |
| 474 | isDefaultBranchOverride: false, | 507 | isDefaultBranchOverride: false, |
| ... | @@ -489,6 +522,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -489,6 +522,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 489 | createBranchAndCheckoutMutation, | 522 | createBranchAndCheckoutMutation, |
| 490 | gitStatusForActions, | 523 | gitStatusForActions, |
| 491 | runGitActionWithToast, | 524 | runGitActionWithToast, |
| 525 | suggestCommitAndBranchMutation, | ||
| 492 | threadToastData, | 526 | threadToastData, |
| 493 | ], | 527 | ], |
| 494 | ); | 528 | ); |
apps/web/src/lib/gitReactQuery.test.ts+12| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | gitMutationKeys, | 4 | gitMutationKeys, |
| 5 | gitPullMutationOptions, | 5 | gitPullMutationOptions, |
| 6 | gitRunStackedActionMutationOptions, | 6 | gitRunStackedActionMutationOptions, |
| 7 | gitSuggestCommitAndBranchMutationOptions, | ||
| 7 | } from "./gitReactQuery"; | 8 | } from "./gitReactQuery"; |
| 8 | 9 | ||
| 9 | describe("gitMutationKeys", () => { | 10 | describe("gitMutationKeys", () => { |
| ... | @@ -16,6 +17,12 @@ describe("gitMutationKeys", () => { | ... | @@ -16,6 +17,12 @@ describe("gitMutationKeys", () => { |
| 16 | it("scopes pull keys by cwd", () => { | 17 | it("scopes pull keys by cwd", () => { |
| 17 | expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b")); | 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 | describe("git mutation options", () => { | 28 | describe("git mutation options", () => { |
| ... | @@ -30,4 +37,9 @@ describe("git mutation options", () => { | ... | @@ -30,4 +37,9 @@ describe("git mutation options", () => { |
| 30 | const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); | 37 | const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); |
| 31 | expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); | 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,6 +19,8 @@ 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, | ||
| 22 | pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, | 24 | pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, |
| 23 | }; | 25 | }; |
| 24 | 26 | ||
| ... | @@ -134,6 +136,20 @@ export function gitRunStackedActionMutationOptions(input: { | ... | @@ -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 | export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { | 153 | export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { |
| 138 | return mutationOptions({ | 154 | return mutationOptions({ |
| 139 | mutationKey: gitMutationKeys.pull(input.cwd), | 155 | mutationKey: gitMutationKeys.pull(input.cwd), |
apps/web/src/wsNativeApi.test.ts+1-1| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | ProjectId, | 6 | ProjectId, |
| 7 | ThreadId, | 7 | ThreadId, |
| 8 | WS_CHANNELS, | 8 | WS_CHANNELS, |
| 9 | WS_METHODS, | ||
| 9 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 10 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 11 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 11 | 12 | ||
| ... | @@ -376,5 +377,4 @@ describe("wsNativeApi", () => { | ... | @@ -376,5 +377,4 @@ describe("wsNativeApi", () => { |
| 376 | { x: 20, y: 30 }, | 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,6 +163,8 @@ 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), | ||
| 166 | listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), | 168 | listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), |
| 167 | createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), | 169 | createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), |
| 168 | removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), | 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,6 +56,12 @@ export const GitRunStackedActionInput = Schema.Struct({ |
| 56 | }); | 56 | }); |
| 57 | export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; | 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 | export const GitListBranchesInput = Schema.Struct({ | 65 | export const GitListBranchesInput = Schema.Struct({ |
| 60 | cwd: TrimmedNonEmptyStringSchema, | 66 | cwd: TrimmedNonEmptyStringSchema, |
| 61 | }); | 67 | }); |
| ... | @@ -161,6 +167,12 @@ export const GitRunStackedActionResult = Schema.Struct({ | ... | @@ -161,6 +167,12 @@ export const GitRunStackedActionResult = Schema.Struct({ |
| 161 | }); | 167 | }); |
| 162 | export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; | 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 | export const GitPullResult = Schema.Struct({ | 176 | export const GitPullResult = Schema.Struct({ |
| 165 | status: Schema.Literals(["pulled", "skipped_up_to_date"]), | 177 | status: Schema.Literals(["pulled", "skipped_up_to_date"]), |
| 166 | branch: TrimmedNonEmptyStringSchema, | 178 | branch: TrimmedNonEmptyStringSchema, |
packages/contracts/src/ipc.ts+5| ... | @@ -11,6 +11,8 @@ import type { | ... | @@ -11,6 +11,8 @@ import type { |
| 11 | GitRemoveWorktreeInput, | 11 | GitRemoveWorktreeInput, |
| 12 | GitRunStackedActionInput, | 12 | GitRunStackedActionInput, |
| 13 | GitRunStackedActionResult, | 13 | GitRunStackedActionResult, |
| 14 | GitSuggestCommitAndBranchInput, | ||
| 15 | GitSuggestCommitAndBranchResult, | ||
| 14 | GitStatusInput, | 16 | GitStatusInput, |
| 15 | GitStatusResult, | 17 | GitStatusResult, |
| 16 | } from "./git"; | 18 | } from "./git"; |
| ... | @@ -88,6 +90,9 @@ export interface NativeApi { | ... | @@ -88,6 +90,9 @@ export interface NativeApi { |
| 88 | pull: (input: GitPullInput) => Promise<GitPullResult>; | 90 | pull: (input: GitPullInput) => Promise<GitPullResult>; |
| 89 | status: (input: GitStatusInput) => Promise<GitStatusResult>; | 91 | status: (input: GitStatusInput) => Promise<GitStatusResult>; |
| 90 | runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>; | 92 | runStackedAction: (input: GitRunStackedActionInput) => Promise<GitRunStackedActionResult>; |
| 93 | suggestCommitAndBranch: ( | ||
| 94 | input: GitSuggestCommitAndBranchInput, | ||
| 95 | ) => Promise<GitSuggestCommitAndBranchResult>; | ||
| 91 | }; | 96 | }; |
| 92 | contextMenu: { | 97 | contextMenu: { |
| 93 | show: <T extends string>( | 98 | show: <T extends string>( |
packages/contracts/src/ws.ts+3| ... | @@ -18,6 +18,7 @@ import { | ... | @@ -18,6 +18,7 @@ import { |
| 18 | GitPullInput, | 18 | GitPullInput, |
| 19 | GitRemoveWorktreeInput, | 19 | GitRemoveWorktreeInput, |
| 20 | GitRunStackedActionInput, | 20 | GitRunStackedActionInput, |
| 21 | GitSuggestCommitAndBranchInput, | ||
| 21 | GitStatusInput, | 22 | GitStatusInput, |
| 22 | } from "./git"; | 23 | } from "./git"; |
| 23 | import { | 24 | import { |
| ... | @@ -48,6 +49,7 @@ export const WS_METHODS = { | ... | @@ -48,6 +49,7 @@ export const WS_METHODS = { |
| 48 | gitPull: "git.pull", | 49 | gitPull: "git.pull", |
| 49 | gitStatus: "git.status", | 50 | gitStatus: "git.status", |
| 50 | gitRunStackedAction: "git.runStackedAction", | 51 | gitRunStackedAction: "git.runStackedAction", |
| 52 | gitSuggestCommitAndBranch: "git.suggestCommitAndBranch", | ||
| 51 | gitListBranches: "git.listBranches", | 53 | gitListBranches: "git.listBranches", |
| 52 | gitCreateWorktree: "git.createWorktree", | 54 | gitCreateWorktree: "git.createWorktree", |
| 53 | gitRemoveWorktree: "git.removeWorktree", | 55 | gitRemoveWorktree: "git.removeWorktree", |
| ... | @@ -109,6 +111,7 @@ export const WebSocketRequestBody = Schema.Union([ | ... | @@ -109,6 +111,7 @@ export const WebSocketRequestBody = Schema.Union([ |
| 109 | tagRequestBody(WS_METHODS.gitPull, GitPullInput), | 111 | tagRequestBody(WS_METHODS.gitPull, GitPullInput), |
| 110 | tagRequestBody(WS_METHODS.gitStatus, GitStatusInput), | 112 | tagRequestBody(WS_METHODS.gitStatus, GitStatusInput), |
| 111 | tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), | 113 | tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), |
| 114 | tagRequestBody(WS_METHODS.gitSuggestCommitAndBranch, GitSuggestCommitAndBranchInput), | ||
| 112 | tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), | 115 | tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), |
| 113 | tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), | 116 | tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), |
| 114 | tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), | 117 | tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), |