diff --git a/.gitignore b/.gitignore index 3e8d287755b..2c756b66c34 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,4 @@ release/ apps/web/.playwright apps/web/playwright-report apps/web/src/components/__screenshots__ -.vitest-* \ No newline at end of file +.vitest-* diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 1cf2d0e0922..a1c633dd7b9 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -4,12 +4,15 @@ import { Effect, FileSystem, Layer, Path } from "effect"; import { expect } from "vitest"; import { ServerConfig } from "../../config.ts"; -import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { TextGenerationError } from "../Errors.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; +import { GitCoreLive } from "./GitCore.ts"; +import { GitServiceLive } from "./GitService.ts"; const makeCodexTextGenerationTestLayer = (stateDir: string) => CodexTextGenerationLive.pipe( + Layer.provide(GitCoreLive.pipe(Layer.provideMerge(GitServiceLive))), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), Layer.provideMerge(NodeServices.layer), ); @@ -197,7 +200,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { " Add important change to the system with too much detail and a trailing period.\nsecondary line", body: "\n- added migration\n- updated tests\n", }), - stdinMustNotContain: "branch must be a short semantic git branch fragment", + stdinMustNotContain: "branch must be a short semantic git branch fragment for this change", }, Effect.gen(function* () { const textGeneration = yield* TextGeneration; @@ -225,7 +228,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { body: "", branch: "fix/important-system-change", }), - stdinMustContain: "branch must be a short semantic git branch fragment", + stdinMustContain: "branch must be a short semantic git branch fragment for this change", }, Effect.gen(function* () { const textGeneration = yield* TextGeneration; @@ -481,7 +484,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { it.effect("returns typed TextGenerationError when codex exits non-zero", () => withFakeCodexEnv( { - output: JSON.stringify({ subject: "ignored", body: "" }), + output: JSON.stringify({ + subject: "fix crash on startup", + body: "", + }), exitCode: 1, stderr: "codex execution failed", }, @@ -491,9 +497,9 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { const result = yield* textGeneration .generateCommitMessage({ cwd: process.cwd(), - branch: "feature/codex-error", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", + branch: "fix/crash", + stagedSummary: "M src/index.ts", + stagedPatch: "+process.on('uncaughtException', logger.error)", }) .pipe( Effect.match({ @@ -510,4 +516,134 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { }), ), ); + + it.effect("includes repository pattern analysis only in auto mode", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add feature", + body: "", + }), + stdinMustContain: "Repository analysis:", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/test", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md", + commitMessageMode: "auto", + }); + + expect(generated.subject).toBe("Add feature"); + }), + ), + ); + + it.effect("uses the standard base commit prompt by default", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add feature", + body: "", + }), + stdinMustContain: + "Rules:\n- subject must be imperative, <= 72 chars, and no trailing period\n- body can be empty string or short bullet points", + stdinMustNotContain: "Repository analysis:", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/test", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md", + }); + + expect(generated.subject).toBe("Add feature"); + }), + ), + ); + + it.effect("includes gitmoji formatting instructions when gitmoji mode is selected", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "✨ feat: add feature", + body: "", + }), + stdinMustContain: "- Use an appropriate gitmoji in the commit message.", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/test", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md", + commitMessageMode: "gitmoji", + }); + + expect(generated.subject).toBe("✨ feat: add feature"); + }), + ), + ); + + it.effect( + "passes custom message directly to the AI without parsing", + () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add feature", + body: "", + }), + stdinMustContain: + "Custom commit instructions:\ntemplate: Standard commit format (Conventional Commits): '(): '\n\n- Generate a commit message following the custom instructions above.", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/test", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md", + commitMessageMode: "custom", + message: + "template: Standard commit format (Conventional Commits): '(): '", + }); + + expect(generated.subject).toBe("Add feature"); + }), + ), + ); + + it.effect("includes imperative mood instructions in prompt", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add feature", + body: "", + }), + stdinMustContain: "- subject must be imperative, <= 72 chars, and no trailing period", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/test", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md", + }); + + expect(generated.subject).toBe("Add feature"); + }), + ), + ); }); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index a444627c3fe..b14a688393c 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -8,7 +8,9 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { analyzeCommitPatterns as analyzeCommitPatternsUtil } from "../analyzeCommitPatterns.ts"; import { TextGenerationError } from "../Errors.ts"; +import { GitCore } from "../Services/GitCore.ts"; import { type BranchNameGenerationInput, type BranchNameGenerationResult, @@ -100,6 +102,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); + const gitCore = yield* GitCore; + + const analyzeCommitPatterns = analyzeCommitPatternsUtil({ + getRecentCommitMessages: (cwd, count) => gitCore.getRecentCommitMessages(cwd, count), + }); type MaterializedImageAttachments = { readonly imagePaths: ReadonlyArray; @@ -284,7 +291,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { Option.match({ onNone: () => Effect.fail( - new TextGenerationError({ operation, detail: "Codex CLI request timed out." }), + new TextGenerationError({ + operation, + detail: "Codex CLI request timed out.", + }), ), onSome: () => Effect.void, }), @@ -315,59 +325,105 @@ const makeCodexTextGeneration = Effect.gen(function* () { }); const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { - const wantsBranch = input.includeBranch === true; + return Effect.gen(function* () { + const wantsBranch = input.includeBranch === true; + const mode = input.commitMessageMode ?? "standard"; + const promptSections = [ + "You write concise git commit messages.", + wantsBranch + ? "Return a JSON object with keys: subject, body, branch." + : "Return a JSON object with keys: subject, body.", + "Rules:", + "- subject must be imperative, <= 72 chars, and no trailing period", + "- body can be empty string or short bullet points", + ...(wantsBranch + ? ["- branch must be a short semantic git branch fragment for this change"] + : []), + "- capture the primary user-visible or developer-visible change", + ]; - const prompt = [ - "You write concise git commit messages.", - wantsBranch - ? "Return a JSON object with keys: subject, body, branch." - : "Return a JSON object with keys: subject, body.", - "Rules:", - "- subject must be imperative, <= 72 chars, and no trailing period", - "- body can be empty string or short bullet points", - ...(wantsBranch - ? ["- branch must be a short semantic git branch fragment for this change"] - : []), - "- capture the primary user-visible or developer-visible change", - "", - `Branch: ${input.branch ?? "(detached)"}`, - "", - "Staged files:", - limitSection(input.stagedSummary, 6_000), - "", - "Staged patch:", - limitSection(input.stagedPatch, 40_000), - ].join("\n"); + switch (mode) { + case "auto": { + const patterns = yield* analyzeCommitPatterns(input.cwd); + const bestExample = patterns.examples[0]?.trim(); + promptSections.push( + "", + "Repository analysis:", + patterns.analysis.trim(), + ...(bestExample + ? ["", "Best recent commit example from this repository:", bestExample] + : []), + "", + "- Follow the repository style shown in the analysis and example above.", + ); + break; + } + case "gitmoji": + promptSections.push("- Use an appropriate gitmoji in the commit message."); + break; + case "custom": + if (input.message?.trim()) { + promptSections.push( + input.message.trim(), + "", + "- Generate a commit message strictly following the provided template.", + "- Do not change the template structure or format under any condition.", + "- Replace all placeholders (, , , , <#ref>) with meaningful values based on the staged changes.", + "- Ensure the commit message is clear, descriptive, and complete.", + "- If the user includes any GitHub or Jira reference (e.g., #123, PROJ-123, or a URL), extract it and include it in the appropriate section of the template.", + "- Do not leave any placeholder unresolved.", + "- Follow the template exactly — no extra text, no missing sections." + ); + } + break; + case "standard": + default: + break; + } - const outputSchemaJson = wantsBranch - ? Schema.Struct({ - subject: Schema.String, - body: Schema.String, - branch: Schema.String, - }) - : Schema.Struct({ - subject: Schema.String, - body: Schema.String, - }); + promptSections.push( + "", + `Branch: ${input.branch ?? "(detached)"}`, + "", + "Staged files:", + limitSection(input.stagedSummary, 6_000), + "", + "Staged patch:", + limitSection(input.stagedPatch, 40_000), + ); - return runCodexJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson, - ...(input.model ? { model: input.model } : {}), - }).pipe( - Effect.map( - (generated) => - ({ - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }) satisfies CommitMessageGenerationResult, - ), - ); + const prompt = promptSections.join("\n"); + + const outputSchemaJson = wantsBranch + ? Schema.Struct({ + subject: Schema.String, + body: Schema.String, + branch: Schema.String, + }) + : Schema.Struct({ + subject: Schema.String, + body: Schema.String, + }); + + return yield* runCodexJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson, + ...(input.model ? { model: input.model } : {}), + }).pipe( + Effect.map( + (generated) => + ({ + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }) satisfies CommitMessageGenerationResult, + ), + ); + }); }; const generatePrContent: TextGenerationShape["generatePrContent"] = (input) => { diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 6c98229e8a7..b9a79d2fb44 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -103,6 +103,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd), readRangeContext: (cwd, baseBranch) => core.readRangeContext(cwd, baseBranch), readConfigValue: (cwd, key) => core.readConfigValue(cwd, key), + getRecentCommitMessages: (cwd, count?) => core.getRecentCommitMessages(cwd, count), listBranches: (input) => core.listBranches(input), createWorktree: (input) => core.createWorktree(input), fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input), diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index f5b9168abbb..c480d51baa2 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -994,6 +994,21 @@ const makeGitCore = Effect.gen(function* () { Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); + const getRecentCommitMessages: GitCoreShape["getRecentCommitMessages"] = (cwd, count = 5) => + runGitStdout("GitCore.getRecentCommitMessages", cwd, [ + "log", + `-${count}`, + "--pretty=%B%x00", + "--no-merges", + ]).pipe( + Effect.map((stdout) => { + return stdout + .split("\x00") + .map((commit) => commit.trim()) + .filter((commit) => commit.length > 0); + }), + ); + const listBranches: GitCoreShape["listBranches"] = (input) => Effect.gen(function* () { const branchRecencyPromise = readBranchRecency(input.cwd).pipe( @@ -1410,6 +1425,7 @@ const makeGitCore = Effect.gen(function* () { pullCurrentBranch, readRangeContext, readConfigValue, + getRecentCommitMessages, listBranches, createWorktree, fetchPullRequestBranch, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 8c72941cd0e..39dd8393489 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -46,7 +46,10 @@ interface FakeGitTextGeneration { branch: string | null; stagedSummary: string; stagedPatch: string; + message?: string; includeBranch?: boolean; + model?: string; + commitMessageMode?: "auto" | "gitmoji" | "standard" | "custom"; }) => Effect.Effect< { subject: string; body: string; branch?: string | undefined }, TextGenerationError @@ -450,6 +453,7 @@ function runStackedAction( cwd: string; action: "commit" | "commit_push" | "commit_push_pr"; commitMessage?: string; + commitMessageMode?: "auto" | "gitmoji" | "standard" | "custom"; featureBranch?: boolean; filePaths?: readonly string[]; }, @@ -886,6 +890,90 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("custom mode uses commitMessage as generation input instead of a literal commit", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-mode\n"); + let generatedCount = 0; + let capturedMessage: string | undefined; + + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + generatedCount += 1; + capturedMessage = input.message; + return { + subject: "Implement custom commit format", + body: "", + ...(input.includeBranch + ? { branch: "feature/implement-custom-commit-format" } + : {}), + }; + }), + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + commitMessageMode: "custom", + commitMessage: "PROJ-123 : ", + }); + + expect(result.commit.status).toBe("created"); + expect(result.commit.subject).toBe("Implement custom commit format"); + expect(generatedCount).toBe(1); + expect(capturedMessage).toBe("PROJ-123 : "); + expect( + yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( + Effect.map((gitResult) => gitResult.stdout.trim()), + ), + ).toBe("Implement custom commit format"); + }), + ); + + it.effect("featureBranch custom mode uses commitMessage as generation input", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature-mode\n"); + let generatedCount = 0; + let capturedMessage: string | undefined; + + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + generatedCount += 1; + capturedMessage = input.message; + return { + subject: "feat(auth): add login validation", + body: "", + ...(input.includeBranch ? { branch: "feature/add-login-validation" } : {}), + }; + }), + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + featureBranch: true, + commitMessageMode: "custom", + commitMessage: "PROJ-123 : ", + }); + + expect(result.branch.status).toBe("created"); + expect(result.branch.name).toBe("feature/add-login-validation"); + expect(result.commit.status).toBe("created"); + expect(result.commit.subject).toBe("feat(auth): add login validation"); + expect(generatedCount).toBe(1); + expect(capturedMessage).toBe("PROJ-123 : "); + }), + ); + it.effect("skips commit when there are no uncommitted changes", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 1a3cf2bb35b..63ca07f9b8e 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -640,6 +640,7 @@ export const makeGitManager = Effect.gen(function* () { includeBranch?: boolean; filePaths?: readonly string[]; model?: string; + commitMessageMode?: "auto" | "gitmoji" | "standard" | "custom"; }) => Effect.gen(function* () { const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); @@ -647,7 +648,10 @@ export const makeGitManager = Effect.gen(function* () { return null; } - const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); + const customCommit = + input.commitMessageMode === "custom" + ? null + : parseCustomCommitMessage(input.commitMessage ?? ""); if (customCommit) { return { subject: customCommit.subject, @@ -665,8 +669,12 @@ export const makeGitManager = Effect.gen(function* () { branch: input.branch, stagedSummary: limitContext(context.stagedSummary, 8_000), stagedPatch: limitContext(context.stagedPatch, 50_000), + ...(input.commitMessageMode === "custom" && input.commitMessage + ? { message: input.commitMessage } + : {}), ...(input.includeBranch ? { includeBranch: true } : {}), ...(input.model ? { model: input.model } : {}), + ...(input.commitMessageMode ? { commitMessageMode: input.commitMessageMode } : {}), }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); @@ -685,6 +693,7 @@ export const makeGitManager = Effect.gen(function* () { preResolvedSuggestion?: CommitAndBranchSuggestion, filePaths?: readonly string[], model?: string, + commitMessageMode?: "auto" | "gitmoji" | "standard" | "custom", ) => Effect.gen(function* () { const suggestion = @@ -695,6 +704,7 @@ export const makeGitManager = Effect.gen(function* () { ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), ...(model ? { model } : {}), + ...(commitMessageMode ? { commitMessageMode } : {}), })); if (!suggestion) { return { status: "skipped_no_changes" as const }; @@ -978,6 +988,7 @@ export const makeGitManager = Effect.gen(function* () { commitMessage?: string, filePaths?: readonly string[], model?: string, + commitMessageMode?: "auto" | "gitmoji" | "standard" | "custom", ) => Effect.gen(function* () { const suggestion = yield* resolveCommitAndBranchSuggestion({ @@ -987,6 +998,7 @@ export const makeGitManager = Effect.gen(function* () { ...(filePaths ? { filePaths } : {}), includeBranch: true, ...(model ? { model } : {}), + ...(commitMessageMode ? { commitMessageMode } : {}), }); if (!suggestion) { return yield* gitManagerError( @@ -1036,6 +1048,7 @@ export const makeGitManager = Effect.gen(function* () { input.commitMessage, input.filePaths, input.textGenerationModel, + input.commitMessageMode, ); branchStep = result.branchStep; commitMessageForStep = result.resolvedCommitMessage; @@ -1053,6 +1066,7 @@ export const makeGitManager = Effect.gen(function* () { preResolvedCommitSuggestion, input.filePaths, input.textGenerationModel, + input.commitMessageMode, ); const push = wantsPush diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 879927934ef..699cf7b765f 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -137,6 +137,14 @@ export interface GitCoreShape { key: string, ) => Effect.Effect; + /** + * Get recent commit messages from the repository. + */ + readonly getRecentCommitMessages: ( + cwd: string, + count?: number, + ) => Effect.Effect; + /** * List local + remote branches and branch metadata. */ diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index b4650ed5707..de7f8231c62 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -17,10 +17,14 @@ export interface CommitMessageGenerationInput { branch: string | null; stagedSummary: string; stagedPatch: string; + /** Free-form user message for custom commit generation. */ + message?: string; /** When true, the model also returns a semantic branch name for the change. */ includeBranch?: boolean; /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */ model?: string; + /** Commit message generation mode. Defaults to 'standard'. */ + commitMessageMode?: "auto" | "gitmoji" | "standard" | "custom"; } export interface CommitMessageGenerationResult { diff --git a/apps/server/src/git/analyzeCommitPatterns.test.ts b/apps/server/src/git/analyzeCommitPatterns.test.ts new file mode 100644 index 00000000000..ba9115e3b8f --- /dev/null +++ b/apps/server/src/git/analyzeCommitPatterns.test.ts @@ -0,0 +1,673 @@ +import { describe, it, expect } from "vitest"; +import { Effect } from "effect"; + +import { analyzeCommitPatterns } from "./analyzeCommitPatterns"; +import { GitCommandError } from "./Errors.ts"; + +describe("analyzeCommitPatterns", () => { + const mockDependencies = (commits: readonly string[] | Error) => ({ + getRecentCommitMessages: (_cwd: string, _count?: number) => + commits instanceof Error + ? Effect.fail( + new GitCommandError({ + operation: "getRecentCommitMessages", + command: "git log", + cwd: "/fake/cwd", + detail: commits.message, + cause: commits, + }), + ) + : Effect.succeed(commits), + }); + + const runAnalysis = (commits: readonly string[] | Error) => { + const result = Effect.runSync(analyzeCommitPatterns(mockDependencies(commits))("/fake/cwd")); + return result; + }; + + it("detects emoji + scope + type pattern", () => { + const commits = [ + `✨ feat(git): improve commit pattern detection logic + Enhance commit message analysis to detect emojis, scopes and types more accurately. + Improves pattern matching logic for better AI prompt generation. + - Improve regex for emoji detection + - Add validation for commit scope pattern + - Improve detection accuracy for conventional types + - Update tests to cover mixed commit styles + This update ensures the system can correctly detect structured commit formats used in modern repositories.`, + + `🐛 fix: handle empty commit history safely + Fix issue where commit analysis failed when repository had no commit history. + Adds graceful fallback behaviour. + - Handle empty git log response + - Prevent runtime errors when parsing commits + - Add fallback analysis message + This ensures the commit analysis feature works correctly even in newly initialised repositories.`, + + `feat(commit): support emoji detection in commit messages + Add functionality to detect emoji prefixes in commit subjects. + Uses Unicode emoji pattern matching for better accuracy. + - Add emoji regex detection + - Update commit pattern interface + - Add unit tests for emoji commits + This change allows commit analysis to detect gitmoji-style workflows.`, + ]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(true); + expect(result.hasScopes).toBe(true); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + expect(result.examples[0]).toContain("✨ feat(git): improve commit pattern detection logic"); + expect(result.analysis).toContain("Uses emoji prefixes"); + expect(result.analysis).toContain("Uses scopes"); + expect(result.analysis).toContain("Uses conventional commit types"); + }); + + it("detects emoji + type pattern (no scope)", () => { + const commits = [ + `🐛 fix: handle empty commit history safely + Fix issue where commit analysis failed when repository had no commit history. + Adds graceful fallback behaviour. + - Handle empty git log response + - Prevent runtime errors when parsing commits + - Add fallback analysis message + This ensures the commit analysis feature works correctly even in newly initialised repositories.`, + + `⚡ perf: optimise commit pattern detection performance + Improve efficiency of commit message analysis for large repositories. + Reduces unnecessary regex evaluations. + - Cache compiled regex patterns + - Reduce redundant message parsing + - Add performance benchmarks + This optimisation significantly reduces analysis time for large commit histories.`, + + `📚 update documentation for commit pattern analysis + Improve documentation explaining how commit analysis works internally. + Adds examples and better explanations. + - Add usage examples + - Document regex pattern logic + - Clarify commit analysis workflow + This documentation update helps contributors understand commit analysis behaviour.`, + ]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(true); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + expect(result.examples[0]).toMatch(/🐛|⚡|📚/); + expect(result.analysis).toContain("Uses emoji prefixes"); + expect(result.analysis).toContain("Uses conventional commit types"); + }); + + it("detects scope + type pattern (no emoji)", () => { + const commits = [ + `feat(commit): support emoji detection in commit messages + Add functionality to detect emoji prefixes in commit subjects. + Uses Unicode emoji pattern matching for better accuracy. + - Add emoji regex detection + - Update commit pattern interface + - Add unit tests for emoji commits + This change allows commit analysis to detect gitmoji-style workflows.`, + + `fix(parser): correctly extract commit subject lines + Fix issue where commit subject parsing failed for multiline messages. + Ensures only the first line is used for pattern detection. + - Improve message splitting logic + - Handle edge cases for newline characters + - Add regression tests + This change improves accuracy of commit pattern detection.`, + + `refactor: simplify commit analysis pipeline + Refactor commit analysis implementation to improve readability and maintainability. + Removes duplicated logic and clarifies intent. + - Extract pattern detection helpers + - Simplify analysis builder logic + - Improve naming for internal variables + This refactor makes the analysis module easier to maintain and extend.`, + ]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(false); + expect(result.hasScopes).toBe(true); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + expect(result.analysis).toContain("Uses scopes"); + expect(result.analysis).toContain("Uses conventional commit types"); + }); + + it("detects type only pattern", () => { + const commits = [ + `refactor: simplify commit analysis pipeline + Refactor commit analysis implementation to improve readability and maintainability. + Removes duplicated logic and clarifies intent. + - Extract pattern detection helpers + - Simplify analysis builder logic + - Improve naming for internal variables + This refactor makes the analysis module easier to maintain and extend.`, + + `test: add coverage for commit pattern detection + Introduce additional tests to verify detection of emojis, scopes and types. + Improves reliability of commit analysis logic. + - Add emoji detection tests + - Add scope detection tests + - Add conventional commit detection tests + These tests ensure the commit analysis behaves correctly across multiple commit styles.`, + + `chore: update dependencies to latest versions + Update project dependencies to their latest stable versions. + Ensures security patches and bug fixes are included. + - Update package.json + - Run npm audit fix + - Update lockfile + This update keeps the project secure and up-to-date.`, + ]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(false); + expect(result.hasScopes).toBe(false); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + expect(result.analysis).toContain("Uses conventional commit types"); + }); + + it("detects emoji only pattern", () => { + const commits = [ + `📚 update documentation for commit pattern analysis + Improve documentation explaining how commit analysis works internally. + Adds examples and better explanations. + - Add usage examples + - Document regex pattern logic + - Clarify commit analysis workflow + This documentation update helps contributors understand commit analysis behaviour.`, + + `🎨 improve UI styling for commit viewer + Enhance visual appearance of commit message viewer component. + Uses modern design patterns for better UX. + - Update color scheme + - Improve spacing and typography + - Add hover effects + This visual update makes the commit viewer more pleasant to use.`, + + `✅ add CI pipeline for automated testing + Set up continuous integration pipeline for automated testing. + Ensures code quality and prevents regressions. + - Configure GitHub Actions + - Add automated test runs + - Set up deployment checks + This CI setup improves development workflow and code quality.`, + ]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(true); + expect(result.hasScopes).toBe(false); + expect(result.hasTypes).toBe(false); + expect(result.examples).toHaveLength(1); + expect(result.analysis).toContain("Uses emoji prefixes"); + }); + + it("selects best example when mixed patterns exist", () => { + const commits = [ + `📚 update documentation + Simple doc update with no type or scope.`, + + `✨ feat(git): improve commit pattern detection logic + Enhance commit message analysis to detect emojis, scopes and types more accurately. + Improves pattern matching logic for better AI prompt generation. + - Improve regex for emoji detection + - Add validation for commit scope pattern + - Improve detection accuracy for conventional types + - Update tests to cover mixed commit styles + This update ensures the system can correctly detect structured commit formats used in modern repositories.`, + + `refactor(api): simplify code + Refactor with no emoji but has type and scope.`, + ]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(true); + expect(result.hasScopes).toBe(true); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + expect(result.examples[0]).toContain("✨ feat(git): improve commit pattern detection logic"); + }); + + it("handles empty repository (no commits)", () => { + const result = runAnalysis([]); + + expect(result.hasEmojis).toBe(false); + expect(result.hasScopes).toBe(false); + expect(result.hasTypes).toBe(false); + expect(result.examples).toEqual([]); + expect(result.analysis).toContain("No previous commits found"); + }); + + it("handles git log failure gracefully", () => { + const result = runAnalysis(new Error("Git command failed")); + + expect(result.hasEmojis).toBe(false); + expect(result.hasScopes).toBe(false); + expect(result.hasTypes).toBe(false); + expect(result.examples).toEqual([]); + expect(result.analysis).toContain("No previous commits found"); + }); + + it("handles commits with only subject lines (no body)", () => { + const commits = [ + "✨ feat: initial commit", + "🐛 fix: bug", + "♻️ refactor: code", + "📝 docs: readme", + "🚀 chore: release", + ]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(true); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + expect(result.examples[0]).toBe("✨ feat: initial commit"); + }); + + it("detects no pattern when commits are unstructured", () => { + const commits = [ + `Initial commit + Set up the project structure.`, + `Update dependencies + Ran npm update.`, + `Fix bug + Fixed the login issue.`, + `Add feature + Added user profile.`, + `Refactor code + Cleaned up the codebase.`, + ]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(false); + expect(result.hasScopes).toBe(false); + expect(result.hasTypes).toBe(false); + expect(result.analysis).toContain("No specific pattern detected"); + }); + + it("correctly parses the test commit message from requirements", () => { + const commits = [ + `feat(git): add getRecentCommitMessages function + Add method to retrieve recent commit messages from git repository. + Supports optional count parameter with default of 5 messages. + Excludes merge commits and returns clean message list. + - Add interface definition in GitCoreShape + - Implement git log command execution with proper error handling + - Add comprehensive unit tests for functionality + - Follows existing Effect patterns in codebase + This commit adds new git functionality that can be used for features like gitmoji suggestions or commit history analysis.`, + ]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(false); + expect(result.hasScopes).toBe(true); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + expect(result.examples[0]).toContain("feat(git): add getRecentCommitMessages function"); + expect(result.analysis).toContain("Uses scopes"); + expect(result.analysis).toContain("Uses conventional commit types"); + }); + + it("handles one-liner commits with emoji + type + scope", () => { + const commits = [ + "✨ feat(git): add feature", + "🐛 fix(api): resolve bug", + "🚀 chore(deps): update packages", + ]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(true); + expect(result.hasScopes).toBe(true); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + expect(result.examples[0]).toBe("✨ feat(git): add feature"); + }); + + it("handles one-liner commits with emoji + type (no scope)", () => { + const commits = ["✨ feat: add feature", "🐛 fix: resolve bug", "🚀 chore: update packages"]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(true); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + }); + + it("handles one-liner commits with emoji only (no type)", () => { + const commits = ["✨ add feature", "🐛 resolve bug", "🚀 update packages"]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(true); + expect(result.hasScopes).toBe(false); + expect(result.hasTypes).toBe(false); + expect(result.examples).toHaveLength(1); + }); + + it("handles one-liner commits with type only (no emoji)", () => { + const commits = ["feat: add feature", "fix: resolve bug", "chore: update packages"]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(false); + expect(result.hasScopes).toBe(false); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + }); + + it("handles one-liner commits with type + scope (no emoji)", () => { + const commits = [ + "feat(git): add feature", + "fix(api): resolve bug", + "chore(deps): update packages", + ]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(false); + expect(result.hasScopes).toBe(true); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + }); + + it("handles one-liner commits with no pattern", () => { + const commits = ["add feature", "resolve bug", "update packages"]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(false); + expect(result.hasScopes).toBe(false); + expect(result.hasTypes).toBe(false); + expect(result.analysis).toContain("No specific pattern detected"); + }); + + it("handles commits with multiple emojis", () => { + const commits = ["✨🚀 feat: add feature", "🐛🔧 fix: resolve bug", "📝🎨 docs: update readme"]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(true); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + }); + + it("handles commits with special characters and URLs", () => { + const commits = [ + `feat(api): add OAuth2 authentication +Integrate with GitHub OAuth2 API. +https://github.com/oauth/docs +- Add OAuth2 flow +- Handle tokens +- Fix edge case: API rate-limit (403 errors) +Supports scopes: read:user, repo, admin:repo_hook`, + + `fix(config): resolve CORS issues +Fixed Access-Control-Allow-Origin errors. +See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS +- Configure allowed origins +- Set proper headers +- Handle preflight requests`, + + `docs(readme): update installation guide +Updated URLs for new package registry. +https://npmjs.com/package/v2 +- Update install command +- Fix broken links +- Add troubleshooting section`, + ]; + + const result = runAnalysis(commits); + + expect(result.hasScopes).toBe(true); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + }); + + it("handles commits with mixed case types", () => { + const commits = ["Feat: add feature", "FIX: resolve bug", "Chore: update packages"]; + + const result = runAnalysis(commits); + + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + }); + + it("handles commits with colons in subject", () => { + const commits = [ + `feat: add feature: with colon in description +Implement new feature that handles colons.`, + `fix(api): resolve: rate limiting issue +Fixed the API rate:limit problem.`, + ]; + + const result = runAnalysis(commits); + + expect(result.hasTypes).toBe(true); + }); + + it("handles commits with trailing whitespace", () => { + const commits = ["feat: add feature ", "fix: resolve bug\t", "chore: update packages \n"]; + + const result = runAnalysis(commits); + + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + expect(result.examples[0]?.trim()).toBe("feat: add feature"); + }); + + it("handles commits with only body (no proper subject line)", () => { + const commits = [ + `This is a commit with no subject line +Just the body of the commit message. +Describing what was done.`, + ]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(false); + expect(result.hasScopes).toBe(false); + expect(result.hasTypes).toBe(false); + }); + + it("handles very long commit messages", () => { + const commits = [ + `feat(git): add comprehensive commit message analysis + This is a very long commit message with lots of details. + - Add support for emoji detection + - Add support for scope detection + - Add support for conventional commit types + - Add comprehensive test coverage + - Handle edge cases like one-liners, empty bodies, special characters + - Support mixed commit styles + - Add intelligent example selection + - Ensure proper error handling + - Follow existing code patterns + - Add detailed documentation + - Performance optimizations for large repositories + - Cache compiled regex patterns + - Reduce redundant parsing operations + - Add benchmarks for performance validation + This commit represents a substantial feature addition with comprehensive testing and documentation.`, + ]; + + const result = runAnalysis(commits); + + expect(result.hasScopes).toBe(true); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + }); + + it("handles commits with code blocks and technical content", () => { + const commits = [ + `feat(parser): add commit message parser + Add function to parse commit messages efficiently. + + \`\`\`typescript + function parseCommit(message: string): Commit { + return { + type: extractType(message), + scope: extractScope(message), + subject: extractSubject(message), + }; + } + \`\`\` + + Usage: + \`\`\` + const commit = parseCommit("feat(api): add endpoint"); + // { type: "feat", scope: "api", subject: "add endpoint" } + \`\`\` + + - Support emoji prefixes + - Handle edge cases + - Add unit tests`, + + `fix(types): resolve type inference issue + Fixed issue with generic type constraints. + + Before: + \`\`\`typescript + function parse(input: T): T + \`\`\` + + After: + \`\`\`typescript + function parse(input: T): Commit + \`\`\` + + - Add proper type constraints + - Update all call sites + - Add regression tests`, + ]; + + const result = runAnalysis(commits); + + expect(result.hasScopes).toBe(true); + expect(result.hasTypes).toBe(true); + }); + + it("handles mixed one-liner and multi-line commits", () => { + const commits = [ + "✨ feat: add feature", + `🐛 fix(api): resolve bug + Fixed null pointer exception in API handler. + - Add null checks + - Add error handling + This prevents crashes on invalid input.`, + "🚀 chore: update packages", + `feat(ui): improve button styling + Better visual feedback for user interactions. + - Add hover states + - Add active states + - Improve accessibility`, + "📝 docs: update readme", + ]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(true); + expect(result.hasScopes).toBe(true); + expect(result.hasTypes).toBe(true); + }); + + it("handles commits with only bullet points in body", () => { + const commits = [ + `feat(git): add commit analysis + - Support emoji detection + - Support scope detection + - Support type detection + - Add comprehensive tests + - Add documentation`, + + `fix(api): resolve rate limiting + - Add rate limiter + - Configure limits + - Add monitoring + - Add alerts`, + ]; + + const result = runAnalysis(commits); + + expect(result.hasScopes).toBe(true); + expect(result.hasTypes).toBe(true); + }); + + it("handles commits with various line endings", () => { + const commits = [ + "feat: add feature\r\n\r\nWith CRLF line endings", + "fix: resolve bug\n\nWith LF line endings", + "chore: update\rWith mixed line endings", + ]; + + const result = runAnalysis(commits); + + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + }); + + it("handles commits with numbers in types", () => { + const commits = ["feat: add feature", "fix2: resolve bug", "v1: initial release"]; + + const result = runAnalysis(commits); + + expect(result.hasTypes).toBe(false); + expect(result.examples).toHaveLength(1); + expect(result.examples[0]).toBe("feat: add feature"); + }); + + it("handles empty strings and whitespace-only commits", () => { + const commits = ["", " ", "\n\n", "feat: actual commit", " \t "]; + + const result = runAnalysis(commits); + + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + expect(result.examples[0]).toBe("feat: actual commit"); + }); + + it("handles real-world mixed commit styles", () => { + const commits = [ + "✨ feat: add user authentication", + "fix(api): resolve rate limiting", + `feat(ui): improve dashboard + Redesign dashboard with new charts. + - Add pie charts + - Add bar charts + - Add line charts + This improves data visualization.`, + "🐛 fix: memory leak in event handlers", + "refactor(core): optimize data structures", + "📚 docs: update API documentation", + "chore: update dependencies", + `feat(auth): add OAuth2 support + Integrate with GitHub OAuth2. + - Add OAuth2 flow + - Handle tokens + - Store sessions`, + "test: add unit tests for auth module", + "⚡ perf: cache compiled regex patterns", + ]; + + const result = runAnalysis(commits); + + expect(result.hasEmojis).toBe(true); + expect(result.hasScopes).toBe(true); + expect(result.hasTypes).toBe(true); + expect(result.examples).toHaveLength(1); + expect(result.examples[0]).toMatch(/✨ feat:|feat\(auth\):|feat\(ui\):|feat\(git\):/); + }); +}); diff --git a/apps/server/src/git/analyzeCommitPatterns.ts b/apps/server/src/git/analyzeCommitPatterns.ts new file mode 100644 index 00000000000..1bdfaae75e8 --- /dev/null +++ b/apps/server/src/git/analyzeCommitPatterns.ts @@ -0,0 +1,148 @@ +import { Effect } from "effect"; + +import { GitCommandError } from "./Errors.ts"; + +export interface CommitPatternAnalysis { + readonly hasEmojis: boolean; + readonly hasScopes: boolean; + readonly hasTypes: boolean; + readonly examples: readonly string[]; + readonly analysis: string; +} + +/** + * Conventional commit types used across most projects. + * This includes the official specification and common extensions. + */ +const COMMIT_TYPES = [ + "feat", + "fix", + "docs", + "style", + "refactor", + "perf", + "test", + "build", + "ci", + "chore", + "revert", + "security", + "deps", + "config", + "infra", + "adhoc", +] as const; + +/** + * Regex to detect conventional commit messages. + * + * Examples supported: + * feat: add feature + * fix(auth): resolve login issue + * ✨ feat: add feature (with optional emoji prefix) + * ✨🚀 feat: add feature (with multiple emoji prefix) + * Feat: add feature (case-insensitive) + */ +export const TYPE_PATTERN = new RegExp( + `^(?:[\\p{Extended_Pictographic}\\p{Emoji_Presentation}]\\s*)*(${COMMIT_TYPES.join("|")})(\\([^)]+\\))?:`, + "iu", +); + +const SCOPE_PATTERN = /\([^)]+\)/; + +const EMOJI_PATTERN = /^[\p{Extended_Pictographic}\p{Emoji_Presentation}]/u; + +export interface AnalyzeCommitPatternsDependencies { + readonly getRecentCommitMessages: ( + cwd: string, + count?: number, + ) => Effect.Effect; +} + +/** + * Analyzes recent git commit messages to detect patterns and provide examples. + * Returns analysis of emoji usage, scopes, conventional types, and real examples. + * + * Note: Commit messages include full body (subject + body) for better pattern detection + * and more useful examples for AI prompt generation. + */ +export const analyzeCommitPatterns = + (dependencies: AnalyzeCommitPatternsDependencies) => + (cwd: string): Effect.Effect => + Effect.gen(function* () { + const recentMessages = yield* dependencies + .getRecentCommitMessages(cwd, 5) + .pipe(Effect.catch(() => Effect.succeed([]))); + + const validMessages = recentMessages.filter((msg) => msg.trim().length > 0); + + if (validMessages.length === 0) { + return { + hasEmojis: false, + hasScopes: false, + hasTypes: false, + examples: [], + analysis: "No previous commits found. Use conventional commits.", + }; + } + + const subjects = validMessages.map((msg) => { + const firstLine = msg.split(/\r?\n/)[0] ?? ""; + return firstLine.trim(); + }); + + const emojiCount = subjects.filter((subject) => EMOJI_PATTERN.test(subject)).length; + const hasEmojis = emojiCount >= subjects.length * 0.4; + + const scopeCount = subjects.filter((subject) => SCOPE_PATTERN.test(subject)).length; + const hasScopes = scopeCount >= subjects.length * 0.4; + + const typeCount = subjects.filter((subject) => TYPE_PATTERN.test(subject)).length; + const hasTypes = typeCount >= subjects.length * 0.4; + + const bestExample = + validMessages.find((msg) => { + const subject = msg.split(/\r?\n/)[0] ?? ""; + return ( + EMOJI_PATTERN.test(subject) && TYPE_PATTERN.test(subject) && SCOPE_PATTERN.test(subject) + ); + }) ?? + validMessages.find((msg) => { + const subject = msg.split(/\r?\n/)[0] ?? ""; + return ( + (EMOJI_PATTERN.test(subject) && TYPE_PATTERN.test(subject)) || + (TYPE_PATTERN.test(subject) && SCOPE_PATTERN.test(subject)) + ); + }) ?? + validMessages.find((msg) => { + const subject = msg.split(/\r?\n/)[0] ?? ""; + return ( + EMOJI_PATTERN.test(subject) || TYPE_PATTERN.test(subject) || SCOPE_PATTERN.test(subject) + ); + }) ?? + validMessages[0]!; + + const examples: readonly string[] = [bestExample]; + + let analysis = "Detected commit patterns:\n"; + if (hasEmojis) { + analysis += "- Uses emoji prefixes (gitmoji-style)\n"; + } + if (hasScopes) { + analysis += "- Uses scopes like feat(scope): or fix(scope):\n"; + } + if (hasTypes) { + analysis += "- Uses conventional commit types (feat, fix, docs, etc.)\n"; + } + if (!hasEmojis && !hasScopes && !hasTypes) { + analysis += "- No specific pattern detected\n"; + } + + return { + hasEmojis, + hasScopes, + hasTypes, + examples, + analysis, + }; + }); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 4486920daeb..029d6aa9041 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -75,7 +75,7 @@ export function makeServerProviderLayer(): Layer.Layer< export function makeServerRuntimeServicesLayer() { const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); - const textGenerationLayer = CodexTextGenerationLive; + const textGenerationLayer = CodexTextGenerationLive.pipe(Layer.provide(gitCoreLayer)); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), diff --git a/apps/web/src/components/CommitModeSelector.tsx b/apps/web/src/components/CommitModeSelector.tsx new file mode 100644 index 00000000000..6d61b29688b --- /dev/null +++ b/apps/web/src/components/CommitModeSelector.tsx @@ -0,0 +1,229 @@ +import type { GitCommitMessageMode } from "@t3tools/contracts"; +import { COMMIT_MODES, CUSTOM_COMMIT_TEMPLATES } from "@t3tools/contracts"; +import { + CheckIcon, + ChevronRightIcon, + GitBranchIcon, + InfoIcon, + Settings2Icon, + SparklesIcon, + SmileIcon, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "~/components/ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { cn } from "~/lib/utils"; + +const COMMIT_MODE_ICONS: ReadonlyArray<{ + value: GitCommitMessageMode; + icon: React.ComponentType<{ className?: string }>; +}> = [ + { + value: "standard", + icon: GitBranchIcon, + }, + { + value: "auto", + icon: SparklesIcon, + }, + { + value: "gitmoji", + icon: SmileIcon, + }, + { + value: "custom", + icon: Settings2Icon, + }, +] as const; + +interface CommitModeSelectorProps { + value: GitCommitMessageMode; + onChange: (value: GitCommitMessageMode) => void; + commitMessage: string; + onCommitMessageChange: (value: string) => void; + disabled?: boolean; +} + +export function CommitModeSelector({ + value, + onChange, + commitMessage, + onCommitMessageChange, + disabled = false, +}: CommitModeSelectorProps) { + const [popoverOpen, setPopoverOpen] = useState(false); + const [templatePopoverOpen, setTemplatePopoverOpen] = useState(false); + const [selectedTemplateId, setSelectedTemplateId] = useState(null); + const lastAppliedTemplateRef = useRef<(typeof CUSTOM_COMMIT_TEMPLATES)[number] | null>(null); + const previousValueRef = useRef(value); + const commitMessageRef = useRef(commitMessage); + + useEffect(() => { + commitMessageRef.current = commitMessage; + }, [commitMessage]); + + useEffect(() => { + if (previousValueRef.current !== value && lastAppliedTemplateRef.current) { + const updatedMessage = commitMessageRef.current + .replace(lastAppliedTemplateRef.current.prompt, "") + .replace(/\n\n+/g, "\n\n") + .trim(); + onCommitMessageChange(updatedMessage); + } + setSelectedTemplateId(null); + lastAppliedTemplateRef.current = null; + previousValueRef.current = value; + }, [value, onCommitMessageChange]); + + const selectedModeConfig = COMMIT_MODES.find((mode) => mode.value === value); + const SelectedModeIcon = COMMIT_MODE_ICONS.find((icon) => icon.value === value)?.icon; + + const handleTemplateSelect = (template: (typeof CUSTOM_COMMIT_TEMPLATES)[number]) => { + let newMessage = commitMessage; + + if (lastAppliedTemplateRef.current) { + newMessage = newMessage.replace(lastAppliedTemplateRef.current.prompt, "").trim(); + } + + newMessage = newMessage.trim() ? `${newMessage}\n\n${template.prompt}` : template.prompt; + + onCommitMessageChange(newMessage); + setSelectedTemplateId(template.id); + lastAppliedTemplateRef.current = template; + setTemplatePopoverOpen(false); + }; + + const isTemplateSelected = (templateId: string) => selectedTemplateId === templateId; + + return ( +
+
+ Commit style + + + + + } + /> + +
+

How commit messages are generated

+
    + {COMMIT_MODES.map((mode) => { + const Icon = COMMIT_MODE_ICONS.find((icon) => icon.value === mode.value)?.icon; + if (!Icon) return null; + return ( +
  • +
    + + {mode.label}: + {mode.summary} +
    +

    {mode.description}

    +
  • + ); + })} +
+
+
+
+ + {selectedModeConfig && SelectedModeIcon && ( +
+ ( + + {selectedModeConfig.summary} + ) +
+ )} +
+
+ {COMMIT_MODES.map((mode) => { + const Icon = COMMIT_MODE_ICONS.find((icon) => icon.value === mode.value)?.icon; + if (!Icon) return null; + return ( + + ); + })} +
+ + {value === "custom" && ( +
+

Select a template or enter custom instructions

+ + + + {selectedTemplateId + ? `✓ ${CUSTOM_COMMIT_TEMPLATES.find((t) => t.id === selectedTemplateId)?.label || "Template applied"}` + : "Choose a template..."} + + + + } + /> + +
+

+ PREDEFINED TEMPLATES +

+ {CUSTOM_COMMIT_TEMPLATES.map((template) => ( + + ))} +
+
+
+ {commitMessage && selectedTemplateId && ( +

+ 💡 Template applied to commit message below. Edit to customize. +

+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 07718751356..10d3ee82833 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,7 +1,13 @@ -import type { GitStackedAction, GitStatusResult, ThreadId } from "@t3tools/contracts"; +import type { + GitCommitMessageMode, + GitStackedAction, + GitStatusResult, + ThreadId, +} from "@t3tools/contracts"; import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; +import { CommitModeSelector } from "./CommitModeSelector"; import { GitHubIcon } from "./Icons"; import { buildGitActionProgressStages, @@ -62,6 +68,7 @@ interface PendingDefaultBranchAction { } type GitActionToastId = ReturnType; +const DEFAULT_COMMIT_MESSAGE_MODE: GitCommitMessageMode = "standard"; function getMenuActionDisabledReason({ item, @@ -163,6 +170,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); + const [commitMessageMode, setCommitMessageMode] = useState( + DEFAULT_COMMIT_MESSAGE_MODE, + ); const [excludedFiles, setExcludedFiles] = useState>(new Set()); const [isEditingFiles, setIsEditingFiles] = useState(false); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = @@ -265,6 +275,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions async ({ action, commitMessage, + commitMessageMode: mode = DEFAULT_COMMIT_MESSAGE_MODE, forcePushOnlyProgress = false, onConfirmed, skipDefaultBranchPrompt = false, @@ -276,6 +287,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }: { action: GitStackedAction; commitMessage?: string; + commitMessageMode?: GitCommitMessageMode; forcePushOnlyProgress?: boolean; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; @@ -355,6 +367,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const promise = runImmediateGitActionMutation.mutateAsync({ action, ...(commitMessage ? { commitMessage } : {}), + ...(mode ? { commitMessageMode: mode } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), }); @@ -464,12 +477,13 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), + commitMessageMode, forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), skipDefaultBranchPrompt: true, }); - }, [pendingDefaultBranchAction, runGitActionWithToast]); + }, [pendingDefaultBranchAction, runGitActionWithToast, commitMessageMode]); const checkoutNewBranchAndRunAction = useCallback( (actionParams: { @@ -481,11 +495,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }) => { void runGitActionWithToast({ ...actionParams, + commitMessageMode, featureBranch: true, skipDefaultBranchPrompt: true, }); }, - [runGitActionWithToast], + [runGitActionWithToast, commitMessageMode], ); const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { @@ -508,6 +523,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions setIsCommitDialogOpen(false); setDialogCommitMessage(""); + setCommitMessageMode(DEFAULT_COMMIT_MESSAGE_MODE); setExcludedFiles(new Set()); setIsEditingFiles(false); @@ -591,11 +607,13 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const commitMessage = dialogCommitMessage.trim(); setIsCommitDialogOpen(false); setDialogCommitMessage(""); + setCommitMessageMode(DEFAULT_COMMIT_MESSAGE_MODE); setExcludedFiles(new Set()); setIsEditingFiles(false); void runGitActionWithToast({ action: "commit", ...(commitMessage ? { commitMessage } : {}), + commitMessageMode, ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), }); }, [ @@ -604,6 +622,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions isCommitDialogOpen, runGitActionWithToast, selectedFiles, + commitMessageMode, setDialogCommitMessage, setIsCommitDialogOpen, ]); @@ -768,6 +787,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions if (!open) { setIsCommitDialogOpen(false); setDialogCommitMessage(""); + setCommitMessageMode(DEFAULT_COMMIT_MESSAGE_MODE); setExcludedFiles(new Set()); setIsEditingFiles(false); } @@ -891,6 +911,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions )} +

Commit message (optional)