From 33e36475f2d2f2fffa5dabdf36088a680fd9dbcf Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Tue, 1 Sep 2026 16:22:23 -0300 Subject: [PATCH 1/4] Omit generated file bodies from staged diffs - Match lockfiles, `dist`/`out`, snapshots, and minified assets as generated paths. - Return source diffs plus a numstat summary for omitted generated files from `getStagedDiff`. - Cover matching, omitted-path formatting, and staged-diff omission with unit and integration tests. --- src/infra/git/parsers.ts | 33 +++++++++++++++++++++++++ src/infra/git/repo.ts | 27 +++++++++++++++++--- test/infra/git/parsers.test.ts | 28 ++++++++++++++++++++- test/infra/git/repo.integration.test.ts | 31 +++++++++++++++++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/infra/git/parsers.ts b/src/infra/git/parsers.ts index e6a11a3..c338a34 100644 --- a/src/infra/git/parsers.ts +++ b/src/infra/git/parsers.ts @@ -9,6 +9,9 @@ export { splitCommitFields, commandFailureMessage, parseHookInterpreter, + isGeneratedPath, + parseNumstatCounts, + formatOmittedPaths, CREATED_FROM_RE, COMMIT_KEYS, type BaseLookupError @@ -31,6 +34,36 @@ const commandFailureMessage = (failure: CommandFailure, fallbackMsg: string): st const lastPathSegment = (path: string): string => path.split(/[/\\]/).filter(Boolean).at(-1) ?? path; +const GENERATED_PATH_RE = + /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock|go\.sum)$|(^|\/)(dist|out|__snapshots__)\/|\.min\.(js|css)$|\.snap$/; + +const isGeneratedPath = (path: string): boolean => GENERATED_PATH_RE.test(path); + +type DiffCounts = { added: string; deleted: string }; + +const parseNumstatCounts = (stdout: string): ReadonlyMap => { + const map = new Map(); + for (const rec of stdout.split("\0")) { + const [added, deleted, path] = rec.split("\t"); + if (added !== undefined && deleted !== undefined && path) { + map.set(path, { added, deleted }); + } + } + return map; +}; + +const formatOmittedPaths = (paths: readonly string[], counts: ReadonlyMap): string => { + if (paths.length === 0) { + return ""; + } + const lines = paths.map((path) => { + const c = counts.get(path); + const churn = c === undefined || c.added === "-" ? "binary" : `+${c.added} -${c.deleted}`; + return `${path} | ${churn} (generated, body omitted)`; + }); + return `\n# Generated files changed but not shown:\n${lines.join("\n")}\n`; +}; + const parseHookInterpreter = (shebangLine: string): string => { const line = shebangLine.trim(); const env = /^#!\s*\/usr\/bin\/env(?:\s+(\S+))?/.exec(line); diff --git a/src/infra/git/repo.ts b/src/infra/git/repo.ts index 655394f..35fe78d 100644 --- a/src/infra/git/repo.ts +++ b/src/infra/git/repo.ts @@ -43,7 +43,10 @@ import { parseRemoteFromUpstream, splitCommitFields, commandFailureMessage, - parseHookInterpreter + parseHookInterpreter, + isGeneratedPath, + parseNumstatCounts, + formatOmittedPaths } from "@/infra/git/parsers"; type CommitMetadata = { @@ -74,9 +77,27 @@ const splitNulPaths = (stdout: string): readonly string[] => stdout.split("\0"). const checkIsGitRepo = (): Future => execGitChecked(["rev-parse", "--is-inside-work-tree"], "Not a git repository").map(() => {}); +const stagedDiffBody = (root: string, paths: readonly string[]): Future => + paths.length === 0 ? + Future.resolve("") + : execGitChecked(["-C", root, "diff", "--staged", "--", ...paths], "Failed to get staged changes"); + +const omittedPathsSummary = (root: string, paths: readonly string[]): Future => + paths.length === 0 ? + Future.resolve("") + : execGitChecked(["-C", root, "diff", "--staged", "--numstat", "--no-renames", "-z", "--", ...paths], "Failed to get staged changes").map((stdout) => + formatOmittedPaths(paths, parseNumstatCounts(stdout)) + ); + const getStagedDiff = (): Future => - execGitChecked(["diff", "--staged"], "Failed to get staged changes").chain((stdout) => - stdout.trim() ? Future.resolve(stdout) : Future.reject(new Error("No staged changes found")) + Future.concurrently({ root: getWorkTreeRoot(), files: listStagedPaths() }).chain(({ root, files }) => + Future.concurrently({ + body: stagedDiffBody( + root, + files.filter((path) => !isGeneratedPath(path)) + ), + note: omittedPathsSummary(root, files.filter(isGeneratedPath)) + }).map(({ body, note }) => body + note) ); const listStagedPaths = (): Future => diff --git a/test/infra/git/parsers.test.ts b/test/infra/git/parsers.test.ts index b47a436..89acccf 100644 --- a/test/infra/git/parsers.test.ts +++ b/test/infra/git/parsers.test.ts @@ -6,7 +6,10 @@ import { splitCommitFields, parseRemoteFromUpstream, commandFailureMessage, - parseHookInterpreter + parseHookInterpreter, + isGeneratedPath, + parseNumstatCounts, + formatOmittedPaths } from "@/infra/git/parsers"; import { Just, Nothing } from "@/libs/maybe"; import { Success } from "@/libs/result"; @@ -85,3 +88,26 @@ describe("parseHookInterpreter", () => { expect(parseHookInterpreter("#!/usr/bin/python")).toBe("python"); }); }); + +describe("isGeneratedPath", () => { + it("matches lockfiles, build output, and snapshots", () => { + for (const p of ["pnpm-lock.yaml", "web/package-lock.json", "dist/app.js", "src/__snapshots__/a.snap", "a.min.css"]) { + expect(isGeneratedPath(p)).toBe(true); + } + }); + + it("keeps source paths that merely resemble generated ones", () => { + for (const p of ["src/distributed.ts", "src/outbox.ts", "lock.ts", "app/[id]/page.tsx"]) { + expect(isGeneratedPath(p)).toBe(false); + } + }); +}); + +describe("formatOmittedPaths", () => { + it("renders churn per omitted path and empty for none", () => { + const counts = parseNumstatCounts("412\t87\tpnpm-lock.yaml\0-\t-\tdist/logo.png\0"); + expect(formatOmittedPaths(["pnpm-lock.yaml"], counts)).toContain("pnpm-lock.yaml | +412 -87 (generated, body omitted)"); + expect(formatOmittedPaths(["dist/logo.png"], counts)).toContain("dist/logo.png | binary (generated, body omitted)"); + expect(formatOmittedPaths([], counts)).toBe(""); + }); +}); diff --git a/test/infra/git/repo.integration.test.ts b/test/infra/git/repo.integration.test.ts index 4c6425f..4bafedb 100644 --- a/test/infra/git/repo.integration.test.ts +++ b/test/infra/git/repo.integration.test.ts @@ -100,6 +100,37 @@ describe("git repo integration", () => { } }); + it("getStagedDiff omits generated file bodies but names them", async () => { + const { dir, run } = createTempGitRepo({ staged: true }); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lock: 1\nlock: 2\n"); + run("add pnpm-lock.yaml"); + const prev = cwd(); + chdir(dir); + try { + const diff = await runFuture(repo.getStagedDiff()); + expect(diff).toContain("file.txt"); + expect(diff).not.toContain("lock: 2"); + expect(diff).toContain("pnpm-lock.yaml | +2 -0 (generated, body omitted)"); + } finally { + chdir(prev); + } + }); + + it("getStagedDiff resolves with a summary when only generated files are staged", async () => { + const { dir, run } = createTempGitRepo(); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lock: 1\n"); + run("add pnpm-lock.yaml"); + const prev = cwd(); + chdir(dir); + try { + const diff = await runFuture(repo.getStagedDiff()); + expect(diff).toContain("pnpm-lock.yaml"); + expect(diff).not.toContain("diff --git"); + } finally { + chdir(prev); + } + }); + it("performCommit creates commit with message", async () => { const { dir } = createTempGitRepo({ staged: true }); const prev = cwd(); From 3582e674386759e551dc74813ee85e7d88590796 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Tue, 1 Sep 2026 16:22:35 -0300 Subject: [PATCH 2/4] Slim the split-commit prompt to a convention summary - Stop embedding the single-message prompt in `getSplitPrompt`. - Summarize the active convention and include the diff once, without `{diff}` interpolation from custom templates. - Assert the split prompt drops SMALL/MEDIUM/LARGE instructions and interpolates the diff once. --- src/domain/commit/prompts.ts | 34 +++++++++++++++++++++++++----- test/domain/commit/prompts.test.ts | 16 ++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/domain/commit/prompts.ts b/src/domain/commit/prompts.ts index 7a09fea..fcca7b7 100644 --- a/src/domain/commit/prompts.ts +++ b/src/domain/commit/prompts.ts @@ -275,21 +275,45 @@ function promptCustom(gitDiff: string, template: Maybe): string { } } +const IMPERATIVE_SUMMARY = `Each message starts with a capitalized imperative verb ("Add", "Fix", "Refactor"). + No Conventional Commits prefix, no ticket IDs, no author names, no "WIP".`; + +function conventionSummary(convention: CommitConvention, customTemplate: Maybe): string { + switch (convention) { + case "conventional": + return `Each message uses Conventional Commits: "type(optional-scope): description", + type drawn from feat, fix, refactor, chore, docs, style, test, perf, ci, build. + Imperative and lowercase after the prefix, no ticket IDs, no author names, no "WIP".`; + case "imperative": + return IMPERATIVE_SUMMARY; + case "custom": + return customTemplate.maybe(IMPERATIVE_SUMMARY, (t) => `Each message follows this user style template:\n ${t.replace("{diff}", "").trim()}`); + default: + return absurd(convention, "CommitConvention"); + } +} + function getSplitPrompt(diff: string, files: readonly string[], convention: CommitConvention, customTemplate: Maybe = Nothing()): string { - const basePrompt = getPrompt(diff, convention, customTemplate); - const outputInstructionsStart = basePrompt.lastIndexOf(""); - const conventionPrompt = outputInstructionsStart >= 0 ? basePrompt.slice(0, outputInstructionsStart) : basePrompt; return ` Partition staged files into reviewable commits. Do not write one message for the whole diff. A feature that touches unrelated layers is several commits. - ${conventionPrompt} ${files.join("\n")} + + ${diff} + + + + ${conventionSummary(convention, customTemplate)} + A commit covering one small change is a single line. A commit covering several files + may add a blank line then "- " bullets. + + Return ONE JSON object. First character "{", last "}". {"should_split":,"commits":[{"message":"","files":["",...]}]} @@ -301,7 +325,7 @@ function getSplitPrompt(diff: string, files: readonly string[], convention: Comm - should_split=false only when every file is the same concern (implementation + its test, rename + callers). Then exactly 1 commit covering every staged path. - Same feature across unrelated layers (CLI, domain, docs, unrelated tests) is still should_split=true. - When unsure across 2+ areas, should_split=true with 2+ commits. - - Each message follows the active convention (SMALL/MEDIUM/LARGE shape) for that commit only. + - Each message follows the active convention in message_convention, scoped to that commit only. diff --git a/test/domain/commit/prompts.test.ts b/test/domain/commit/prompts.test.ts index 3de1039..afa6030 100644 --- a/test/domain/commit/prompts.test.ts +++ b/test/domain/commit/prompts.test.ts @@ -60,6 +60,22 @@ describe("getSplitPrompt", () => { expect(prompt).toContain("unrelated layers"); expect(prompt).not.toContain("should_split=true only when"); }); + + it("drops single-message instructions from the split prompt", () => { + const prompt = getSplitPrompt(DIFF, ["foo.ts", "bar.ts"], "conventional"); + expect(prompt).toContain(DIFF); + expect(prompt).not.toContain("SMALL"); + expect(prompt).not.toContain("MEDIUM"); + expect(prompt).not.toContain(""); + expect(prompt).toContain("Conventional Commits"); + }); + + it("summarizes a custom template without interpolating the diff twice", () => { + const prompt = getSplitPrompt(DIFF, ["foo.ts"], "custom", Just("Change:\n{diff}")); + expect(prompt).toContain("Change:"); + expect(prompt).not.toContain("{diff}"); + expect(prompt.split(DIFF).length - 1).toBe(1); + }); }); describe("getRefinePrompt", () => { From d8f6d7ea0684c4acc8a024367cf459e22e3f5ab5 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Tue, 1 Sep 2026 17:03:37 -0300 Subject: [PATCH 3/4] Trim model work on the single-commit path - Default commit generation and refine to the provider's low effort when the config sets none, keeping an explicit effort untouched via `withDefaultMinEffort`. - Return the static rules and examples from `getPrompt` as a system instruction and send only the diff as the user prompt, so providers can cache the prefix across runs. - Drop the second copy of the diff from the custom template prompt. - Mark the last Anthropic system block ephemeral on both the API key and setup-token paths. - Cover the effort default, the prompt split, and the Anthropic request shape with unit tests. --- src/domain/commit/prompts.ts | 40 ++++++++------------- src/domain/llm/effort.ts | 4 ++- src/domain/llm/router.ts | 7 ++-- src/infra/llm/anthropic.ts | 19 +++++----- test/domain/commit/prompts.test.ts | 22 ++++++------ test/domain/llm/effort.test.ts | 12 ++++++- test/domain/llm/router.test.ts | 22 ++++++++++++ test/infra/llm/anthropic.test.ts | 57 ++++++++++++++++++++++++++++++ 8 files changed, 134 insertions(+), 49 deletions(-) create mode 100644 test/infra/llm/anthropic.test.ts diff --git a/src/domain/commit/prompts.ts b/src/domain/commit/prompts.ts index fcca7b7..2eafbd0 100644 --- a/src/domain/commit/prompts.ts +++ b/src/domain/commit/prompts.ts @@ -4,7 +4,11 @@ import { CommitConvention } from "@/domain/config/config"; import { Just, Nothing, type Maybe } from "@/libs/maybe"; import { absurd } from "@/libs/types"; -function getPrompt(diff: string, convention: CommitConvention, customTemplate: Maybe = Nothing()): string { +type CommitPrompt = { prompt: string; systemInstruction: string }; + +const diffPrompt = (gitDiff: string): string => `\n${gitDiff}\n`; + +function getPrompt(diff: string, convention: CommitConvention, customTemplate: Maybe = Nothing()): CommitPrompt { switch (convention) { case "conventional": return promptConventional(diff); @@ -17,8 +21,8 @@ function getPrompt(diff: string, convention: CommitConvention, customTemplate: M } } -function promptConventional(gitDiff: string): string { - return ` +function promptConventional(gitDiff: string): CommitPrompt { + const systemInstruction = ` You are an expert software engineer and version control specialist. Your job is to read git diffs and output high-quality commit messages @@ -102,12 +106,6 @@ function promptConventional(gitDiff: string): string { - - - ${gitDiff} - - - 1. First, internally decide if the change is SMALL, MEDIUM, or LARGE. 2. Do NOT output the classification (SMALL/MEDIUM/LARGE) in your response. @@ -122,10 +120,11 @@ function promptConventional(gitDiff: string): string { • Remaining lines: each line is a bullet starting with "- ". `; + return { prompt: diffPrompt(gitDiff), systemInstruction }; } -function promptImperative(gitDiff: string): string { - return ` +function promptImperative(gitDiff: string): CommitPrompt { + const systemInstruction = ` You are an expert software engineer and version control specialist. Your job is to read git diffs and output high-quality commit messages @@ -216,12 +215,6 @@ function promptImperative(gitDiff: string): string { - - - ${gitDiff} - - - 1. First, internally decide if the change is SMALL, MEDIUM, or LARGE according to the rules above. @@ -238,15 +231,15 @@ function promptImperative(gitDiff: string): string { 7. Inline code with single backticks is allowed in the bullet points. `; + return { prompt: diffPrompt(gitDiff), systemInstruction }; } -function promptCustom(gitDiff: string, template: Maybe): string { +function promptCustom(gitDiff: string, template: Maybe): CommitPrompt { switch (true) { case template instanceof Nothing: return promptImperative(gitDiff); case template instanceof Just: { - const processedTemplate = template.value.replace("{diff}", gitDiff); - return ` + const systemInstruction = ` You are an expert software engineer and version control specialist. Your job is to read git diffs and output high-quality commit messages @@ -254,13 +247,9 @@ function promptCustom(gitDiff: string, template: Maybe): string { - ${processedTemplate} + ${template.value.replace("{diff}", "").trim()} - - ${gitDiff} - - 1. Follow the user's template style and format. 2. Analyze the content and create a commit message that matches the template pattern. @@ -268,6 +257,7 @@ function promptCustom(gitDiff: string, template: Maybe): string { 4. Do NOT wrap the commit message in quotes or code fences. `; + return { prompt: diffPrompt(gitDiff), systemInstruction }; } default: template satisfies never; diff --git a/src/domain/llm/effort.ts b/src/domain/llm/effort.ts index 0709764..87403cd 100644 --- a/src/domain/llm/effort.ts +++ b/src/domain/llm/effort.ts @@ -1,4 +1,4 @@ -export { seedProviderConfig, withModel, withMinEffort, selectEffortForProvider }; +export { seedProviderConfig, withModel, withMinEffort, withDefaultMinEffort, selectEffortForProvider }; import { type Future } from "@/libs/future"; import { @@ -59,6 +59,8 @@ const withMinEffort = (config: ProviderConfig): ProviderConfig => { } }; +const withDefaultMinEffort = (config: ProviderConfig): ProviderConfig => (config.effort instanceof Nothing ? withMinEffort(config) : config); + const selectEffortForProvider = (current: ProviderConfig, modelEffort: Maybe = Nothing()): Future => { switch (current.provider) { case "openai": diff --git a/src/domain/llm/router.ts b/src/domain/llm/router.ts index f944b33..431de42 100644 --- a/src/domain/llm/router.ts +++ b/src/domain/llm/router.ts @@ -25,7 +25,7 @@ import { getPrompt, getRefinePrompt, getBranchNamePrompt, getSplitPrompt } from import { parseAndValidateBranchSuggestions, type BranchSuggestion } from "@/domain/branch/suggestions"; import { parseAndValidateSplitPlan, type SplitPlan } from "@/domain/split/plan"; import { withTransientRetry } from "@/domain/llm/retry"; -import { withMinEffort } from "@/domain/llm/effort"; +import { withMinEffort, withDefaultMinEffort } from "@/domain/llm/effort"; import { Maybe, Nothing } from "@/libs/maybe"; type GenerateContentParams = { @@ -118,10 +118,11 @@ const generateCommitMessage = ( diff: string, convention: CommitConvention, customTemplate: Maybe = Nothing() -): Future => withTransientRetry(() => generateContent(config, { prompt: getPrompt(diff, convention, customTemplate) })); +): Future => + withTransientRetry(() => generateContent(withDefaultMinEffort(config), getPrompt(diff, convention, customTemplate))); const refineCommitMessage = (config: ProviderConfig, currentMessage: string, adjustment: string, diff: string): Future => - withTransientRetry(() => generateContent(config, getRefinePrompt({ diff, currentMessage, adjustment }))); + withTransientRetry(() => generateContent(withDefaultMinEffort(config), getRefinePrompt({ diff, currentMessage, adjustment }))); const resultToFuture = (r: Result): Future => r.either( diff --git a/src/infra/llm/anthropic.ts b/src/infra/llm/anthropic.ts index ab1dfb3..96615d6 100644 --- a/src/infra/llm/anthropic.ts +++ b/src/infra/llm/anthropic.ts @@ -12,7 +12,9 @@ import { unsupportedAuth } from "@/domain/llm/auth-error"; import { Just, Nothing, fromOptional, type Maybe } from "@/libs/maybe"; type AnthropicConfig = Extract; -type SystemParam = NonNullable; +type SystemBlocks = Anthropic.TextBlockParam[]; + +const cacheable = (text: string): Anthropic.TextBlockParam => ({ type: "text", text, cache_control: { type: "ephemeral" } }); const extractAnthropicText = (content: Anthropic.ContentBlock[]): string => content @@ -31,7 +33,7 @@ const toTokenUsage = (usage: Anthropic.Usage): TokenUsage => { const buildParams = ( model: string, - system: Maybe, + system: Maybe, effort: Maybe, params: GenerateContentParams ): Anthropic.MessageStreamParams => { @@ -45,11 +47,10 @@ const buildParams = ( return system.maybe(core, (s) => ({ ...core, system: s })); }; -const buildSetupTokenSystem = (instruction: Maybe): SystemParam => - instruction.maybe([{ type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }], (text) => [ - { type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }, - { type: "text", text } - ]); +const buildApiKeySystem = (instruction: Maybe): Maybe => instruction.map((text) => [cacheable(text)]); + +const buildSetupTokenSystem = (instruction: Maybe): SystemBlocks => + instruction.maybe([cacheable(CLAUDE_CODE_SYSTEM_PROMPT)], (text) => [{ type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }, cacheable(text)]); const callAnthropicWithApiKey = ( apiKey: string, @@ -59,7 +60,7 @@ const callAnthropicWithApiKey = ( ): Future => Future.attemptP(async () => { const client = new Anthropic({ apiKey, maxRetries: 3, timeout: 120_000 }); - const stream = client.messages.stream(buildParams(model, fromOptional(params.systemInstruction), effort, params)); + const stream = client.messages.stream(buildParams(model, buildApiKeySystem(fromOptional(params.systemInstruction)), effort, params)); return await stream.finalMessage(); }) .mapRej((error) => new Error(`Failed to create Anthropic message: ${error instanceof Error ? error.message : String(error)}`, { cause: error })) @@ -85,7 +86,7 @@ const callAnthropicWithSetupToken = ( maxRetries: 3, timeout: 120_000 }); - const system = Just(buildSetupTokenSystem(fromOptional(params.systemInstruction))); + const system = Just(buildSetupTokenSystem(fromOptional(params.systemInstruction))); const stream = client.messages.stream(buildParams(model, system, effort, params)); return await stream.finalMessage(); }) diff --git a/test/domain/commit/prompts.test.ts b/test/domain/commit/prompts.test.ts index afa6030..873bba9 100644 --- a/test/domain/commit/prompts.test.ts +++ b/test/domain/commit/prompts.test.ts @@ -6,27 +6,29 @@ const DIFF = "diff --git a/foo.ts b/foo.ts\n+console.log(1)"; describe("getPrompt", () => { it("embeds diff in conventional prompt", () => { - const prompt = getPrompt(DIFF, "conventional"); + const { prompt, systemInstruction } = getPrompt(DIFF, "conventional"); expect(prompt).toContain(DIFF); - expect(prompt).toContain("Conventional Commits"); + expect(systemInstruction).toContain("Conventional Commits"); + expect(systemInstruction).not.toContain(DIFF); }); it("embeds diff in imperative prompt", () => { - const prompt = getPrompt(DIFF, "imperative"); + const { prompt, systemInstruction } = getPrompt(DIFF, "imperative"); expect(prompt).toContain(DIFF); - expect(prompt).toContain("Do NOT use conventional commit prefixes"); + expect(systemInstruction).toContain("Do NOT use conventional commit prefixes"); }); - it("substitutes {diff} in custom template", () => { - const prompt = getPrompt(DIFF, "custom", Just("Change:\n{diff}")); - expect(prompt).toContain("Change:"); + it("keeps the custom template static and sends the diff once", () => { + const { prompt, systemInstruction } = getPrompt(DIFF, "custom", Just("Change:\n{diff}")); + expect(systemInstruction).toContain("Change:"); + expect(systemInstruction).not.toContain("{diff}"); + expect(systemInstruction).not.toContain(DIFF); expect(prompt).toContain(DIFF); - expect(prompt).not.toContain("{diff}"); }); it("falls back to imperative when custom has no template", () => { - const prompt = getPrompt(DIFF, "custom", Nothing()); - expect(prompt).toContain("imperative"); + const { systemInstruction } = getPrompt(DIFF, "custom", Nothing()); + expect(systemInstruction).toContain("imperative"); }); }); diff --git a/test/domain/llm/effort.test.ts b/test/domain/llm/effort.test.ts index 25e35cf..1ae672b 100644 --- a/test/domain/llm/effort.test.ts +++ b/test/domain/llm/effort.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withMinEffort } from "@/domain/llm/effort"; +import { withMinEffort, withDefaultMinEffort } from "@/domain/llm/effort"; import { GEMINI_EFFORTS, type ProviderConfig } from "@/domain/config/config"; import { Just, Nothing } from "@/libs/maybe"; @@ -25,3 +25,13 @@ describe("withMinEffort", () => { expect(withMinEffort(base("xai")).effort).toEqual(Just("low")); }); }); + +describe("withDefaultMinEffort", () => { + it("applies the minimum when effort is unset", () => { + expect(withDefaultMinEffort(base("anthropic")).effort).toEqual(Just("low")); + }); + it("keeps an explicit effort", () => { + const config = { ...base("anthropic"), effort: Just("high") } as ProviderConfig; + expect(withDefaultMinEffort(config)).toBe(config); + }); +}); diff --git a/test/domain/llm/router.test.ts b/test/domain/llm/router.test.ts index eb8a466..0596cd9 100644 --- a/test/domain/llm/router.test.ts +++ b/test/domain/llm/router.test.ts @@ -35,6 +35,28 @@ describe("generateCommitMessage", () => { expect(result.metadata.model.provider).toBe(provider); expect(result.metadata.durationMs).toBeGreaterThanOrEqual(0); }); + + it("defaults to low effort when the config has none", async () => { + const { generateContentWithAnthropic } = await import("@/infra/llm/anthropic"); + await runFuture(generateCommitMessage(mockProvider("anthropic"), "diff", "conventional", Nothing())); + expect(vi.mocked(generateContentWithAnthropic).mock.calls[0]?.[0].effort).toEqual(Just("low")); + }); + + it("keeps an explicit effort", async () => { + const { generateContentWithAnthropic } = await import("@/infra/llm/anthropic"); + const config = { ...mockProvider("anthropic"), effort: Just("high") } as ProviderConfig; + await runFuture(generateCommitMessage(config, "diff", "conventional", Nothing())); + expect(vi.mocked(generateContentWithAnthropic).mock.calls[0]?.[0].effort).toEqual(Just("high")); + }); + + it("sends the static prompt as the system instruction and the diff as the prompt", async () => { + const { generateContentWithAnthropic } = await import("@/infra/llm/anthropic"); + await runFuture(generateCommitMessage(mockProvider("anthropic"), "diff body", "conventional", Nothing())); + const params = vi.mocked(generateContentWithAnthropic).mock.calls[0]?.[1]; + expect(params?.systemInstruction).toContain("Conventional Commits"); + expect(params?.systemInstruction).not.toContain("diff body"); + expect(params?.prompt).toContain("diff body"); + }); }); describe("refineCommitMessage", () => { diff --git a/test/infra/llm/anthropic.test.ts b/test/infra/llm/anthropic.test.ts new file mode 100644 index 0000000..8aeba78 --- /dev/null +++ b/test/infra/llm/anthropic.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { type Config } from "@/domain/config/config"; +import { generateContentWithAnthropic } from "@/infra/llm/anthropic"; +import { Just, Nothing } from "@/libs/maybe"; +import { runFuture } from "@test/helpers/run-future"; + +const stream = vi.hoisted(() => vi.fn()); + +vi.mock("@anthropic-ai/sdk", () => { + class MockAnthropic { + readonly messages = { stream }; + } + return { default: MockAnthropic }; +}); + +type AnthropicConfig = Extract; + +const apiKeyConfig: AnthropicConfig = { + provider: "anthropic", + model: "claude-sonnet-5", + effort: Just("low"), + auth_method: { type: "api_key", content: "sk-ant-test" } +}; + +const setupTokenConfig: AnthropicConfig = { ...apiKeyConfig, effort: Nothing(), auth_method: { type: "anthropic_setup_token", content: "sk-ant-oat" } }; + +const message = { content: [{ type: "text", text: "feat: test" }], usage: { input_tokens: 1, output_tokens: 2 } }; + +describe("generateContentWithAnthropic", () => { + beforeEach(() => { + stream.mockReset(); + stream.mockReturnValue({ finalMessage: vi.fn().mockResolvedValue(message) }); + }); + + it("sends the system instruction as a cacheable block and the diff as the user message", async () => { + await runFuture(generateContentWithAnthropic(apiKeyConfig, { prompt: "diff", systemInstruction: "rules" })); + expect(stream.mock.calls[0]?.[0]).toMatchObject({ + system: [{ type: "text", text: "rules", cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: "diff" }], + output_config: { effort: "low" } + }); + }); + + it("omits system when there is no instruction", async () => { + await runFuture(generateContentWithAnthropic(apiKeyConfig, { prompt: "diff" })); + expect(stream.mock.calls[0]?.[0]).not.toHaveProperty("system"); + }); + + it("marks the instruction block after the Claude Code preamble on the setup-token path", async () => { + await runFuture(generateContentWithAnthropic(setupTokenConfig, { prompt: "diff", systemInstruction: "rules" })); + const system = stream.mock.calls[0]?.[0].system; + expect(system).toHaveLength(2); + expect(system[0]).not.toHaveProperty("cache_control"); + expect(system[1]).toMatchObject({ text: "rules", cache_control: { type: "ephemeral" } }); + }); +}); From 71e89a36ce6d60f9efcc45d39f91249b83d24b00 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Tue, 1 Sep 2026 17:03:45 -0300 Subject: [PATCH 4/4] Skip the model when only generated files are staged - Add `generatedOnlyMessage` to build a commit title, and a bullet body for several files, from the staged paths and the active convention. - Short-circuit `Commit.route` to that local message when every staged path matches `isGeneratedPath`, and say so in the log. - Thread a `Proposal` with optional request metadata through `interact` and the commit handlers so the local path renders no model lines. - Cover the local message and the skipped model call with unit tests. --- src/cli/commit.ts | 44 ++++++++++++++--------- src/domain/commit/generated-only.ts | 13 +++++++ test/cli/commit.test.ts | 16 ++++++++- test/domain/commit/generated-only.test.ts | 15 ++++++++ 4 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 src/domain/commit/generated-only.ts create mode 100644 test/domain/commit/generated-only.test.ts diff --git a/src/cli/commit.ts b/src/cli/commit.ts index 8904049..8bb6c4f 100644 --- a/src/cli/commit.ts +++ b/src/cli/commit.ts @@ -3,6 +3,8 @@ export { Commit, routeAnalysis, type AnalysisRoute }; import * as p from "@clack/prompts"; import * as pr from "@/infra/github/pr"; import * as repo from "@/infra/git/repo"; +import { isGeneratedPath } from "@/infra/git/parsers"; +import { generatedOnlyMessage } from "@/domain/commit/generated-only"; import { Future } from "@/libs/future"; import { loadConfig } from "@/infra/storage/config"; @@ -32,6 +34,10 @@ type UserAction = (typeof USER_ACTIONS)[number]; type AnalysisRoute = { tag: "split"; plan: SplitPlan } | { tag: "single"; message: string }; +type Proposal = { readonly text: string; readonly metadata: Maybe }; + +const fromGenerated = (generated: GeneratedContent): Proposal => ({ text: generated.text, metadata: Just(generated.metadata) }); + const routeAnalysis = (plan: SplitPlan): Result => plan.shouldSplit && plan.commits.length >= 2 ? Success({ tag: "split", plan }) @@ -69,13 +75,17 @@ class Commit { } private route(diff: string, files: readonly string[]): Future { + if (files.every(isGeneratedPath)) { + p.log.info("Only generated files are staged. Skipped the model."); + return this.interact(diff, { text: generatedOnlyMessage(files, this.config.commit_convention), metadata: Nothing() }); + } return this.config.split_commits && files.length >= 2 ? loading( "Analyzing staged changes...", "Ready!", generateSplitPlan(this.providerConfig, diff, files, this.config.commit_convention, this.config.custom_template) ).chain((content) => this.followAnalysis(diff, files, content)) - : this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((message) => this.interact(diff, message)); + : this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((message) => this.interact(diff, fromGenerated(message))); } private followAnalysis(diff: string, files: readonly string[], content: SplitPlanContent): Future { @@ -87,7 +97,7 @@ class Commit { case "split": return Split.fromResolved(this.config, this.providerConfig).runPlan(diff, files, route.plan, metadata); case "single": - return this.interact(diff, { text: route.message, metadata }); + return this.interact(diff, { text: route.message, metadata: Just(metadata) }); default: return absurd(route, "AnalysisRoute"); } @@ -142,17 +152,17 @@ class Commit { ); } - interact(diff: string, generated: GeneratedContent): Future { - return this.promptAction(generated.text).chain((action) => { + interact(diff: string, proposal: Proposal): Future { + return this.promptAction(proposal.text).chain((action) => { switch (action) { case "commit": - return this.handleCommit(generated); + return this.handleCommit(proposal); case "commit_push": - return this.handleCommitAndPush(generated); + return this.handleCommitAndPush(proposal); case "regenerate": - return this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((msg) => this.interact(diff, msg)); + return this.generate(diff, this.config.commit_convention, this.config.custom_template).chain((msg) => this.interact(diff, fromGenerated(msg))); case "adjust": - return this.handleAdjust(diff, generated); + return this.handleAdjust(diff, proposal); case "cancel": return Future.resolve(undefined); } @@ -188,21 +198,21 @@ class Commit { }); } - private handleCommit(generated: GeneratedContent): Future { - return this.commit(generated.text).chain((stats) => + private handleCommit(proposal: Proposal): Future { + return this.commit(proposal.text).chain((stats) => repo.findCommitMetadata().map((commit) => { process.stdout.write(stats); - renderCommitNote({ commit, request: Just(generated.metadata) }); + renderCommitNote({ commit, request: proposal.metadata }); p.outro(color.green("Committed successfully!")); }) ); } - private handleCommitAndPush(generated: GeneratedContent): Future { - return this.commit(generated.text) + private handleCommitAndPush(proposal: Proposal): Future { + return this.commit(proposal.text) .chain((stats) => { process.stdout.write(stats); - return this.pushAfterCommit(Just(generated.metadata)); + return this.pushAfterCommit(proposal.metadata); }) .map(() => { p.outro(color.green("Done!")); @@ -239,11 +249,11 @@ class Commit { }).chain((shouldForce) => (shouldForce ? this.push(request, undefined, false, true) : Future.resolve(undefined))); } - private handleAdjust(diff: string, generated: GeneratedContent): Future { + private handleAdjust(diff: string, proposal: Proposal): Future { return this.promptAdjustment().chain((maybeAdj) => maybeAdj instanceof Nothing ? - this.interact(diff, generated) - : this.refine(generated.text, maybeAdj.value, diff).chain((refined) => this.interact(diff, refined)) + this.interact(diff, proposal) + : this.refine(proposal.text, maybeAdj.value, diff).chain((refined) => this.interact(diff, fromGenerated(refined))) ); } diff --git a/src/domain/commit/generated-only.ts b/src/domain/commit/generated-only.ts new file mode 100644 index 0000000..f87fd34 --- /dev/null +++ b/src/domain/commit/generated-only.ts @@ -0,0 +1,13 @@ +export { generatedOnlyMessage }; + +import { type CommitConvention } from "@/domain/config/config"; + +const basename = (path: string): string => path.split("/").at(-1) ?? path; + +const generatedOnlyMessage = (files: readonly string[], convention: CommitConvention): string => { + const verb = convention === "conventional" ? "chore: update" : "Update"; + const [only] = files; + return only !== undefined && files.length === 1 ? + `${verb} ${basename(only)}` + : `${verb} generated files\n\n${files.map((path) => `- ${path}.`).join("\n")}`; +}; diff --git a/test/cli/commit.test.ts b/test/cli/commit.test.ts index aa92170..2a3145c 100644 --- a/test/cli/commit.test.ts +++ b/test/cli/commit.test.ts @@ -52,7 +52,7 @@ vi.mock("@clack/prompts", () => ({ confirm: vi.fn(), isCancel: vi.fn(() => false), outro: vi.fn(), - log: { warn: vi.fn(), error: vi.fn() } + log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() } })); vi.mock("@/infra/ui/push-note", () => ({ renderCommitNote: vi.fn(), @@ -99,6 +99,20 @@ describe("Commit.run", () => { expect(repo.performCommit).toHaveBeenCalledWith("feat: generated"); }); + it("commits a local message without calling the model when only generated files are staged", async () => { + const repo = await import("@/infra/git/repo"); + vi.mocked(repo.listStagedPaths).mockReturnValue(Future.resolve(["pnpm-lock.yaml"])); + + await runFuture(Commit.create().chain((c) => c.run())); + + const router = await import("@/domain/llm/router"); + expect(router.generateCommitMessage).not.toHaveBeenCalled(); + expect(router.generateSplitPlan).not.toHaveBeenCalled(); + expect(repo.performCommit).toHaveBeenCalledWith("chore: update pnpm-lock.yaml"); + const note = await import("@/infra/ui/push-note"); + expect(vi.mocked(note.renderCommitNote).mock.calls[0]?.[0].request).toEqual(Nothing()); + }); + it("does not analyze when split is disabled and two files are staged", async () => { const repo = await import("@/infra/git/repo"); vi.mocked(repo.listStagedPaths).mockReturnValue(Future.resolve(["a.ts", "b.ts"])); diff --git a/test/domain/commit/generated-only.test.ts b/test/domain/commit/generated-only.test.ts new file mode 100644 index 0000000..f8710fb --- /dev/null +++ b/test/domain/commit/generated-only.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { generatedOnlyMessage } from "@/domain/commit/generated-only"; + +describe("generatedOnlyMessage", () => { + it("names a single file with a chore prefix under conventional", () => { + expect(generatedOnlyMessage(["web/pnpm-lock.yaml"], "conventional")).toBe("chore: update pnpm-lock.yaml"); + }); + it("uses an imperative title otherwise", () => { + expect(generatedOnlyMessage(["pnpm-lock.yaml"], "imperative")).toBe("Update pnpm-lock.yaml"); + expect(generatedOnlyMessage(["pnpm-lock.yaml"], "custom")).toBe("Update pnpm-lock.yaml"); + }); + it("lists several files as bullets", () => { + expect(generatedOnlyMessage(["pnpm-lock.yaml", "dist/app.js"], "imperative")).toBe("Update generated files\n\n- pnpm-lock.yaml.\n- dist/app.js."); + }); +});