Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 27 additions & 17 deletions src/cli/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<LlmRequestMetadata> };

const fromGenerated = (generated: GeneratedContent): Proposal => ({ text: generated.text, metadata: Just(generated.metadata) });

const routeAnalysis = (plan: SplitPlan): Result<Error, AnalysisRoute> =>
plan.shouldSplit && plan.commits.length >= 2 ?
Success({ tag: "split", plan })
Expand Down Expand Up @@ -69,13 +75,17 @@ class Commit {
}

private route(diff: string, files: readonly string[]): Future<Error, void> {
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<Error, void> {
Expand All @@ -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");
}
Expand Down Expand Up @@ -142,17 +152,17 @@ class Commit {
);
}

interact(diff: string, generated: GeneratedContent): Future<Error, void> {
return this.promptAction(generated.text).chain((action) => {
interact(diff: string, proposal: Proposal): Future<Error, void> {
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);
}
Expand Down Expand Up @@ -188,21 +198,21 @@ class Commit {
});
}

private handleCommit(generated: GeneratedContent): Future<Error, void> {
return this.commit(generated.text).chain((stats) =>
private handleCommit(proposal: Proposal): Future<Error, void> {
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<Error, void> {
return this.commit(generated.text)
private handleCommitAndPush(proposal: Proposal): Future<Error, void> {
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!"));
Expand Down Expand Up @@ -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<Error, void> {
private handleAdjust(diff: string, proposal: Proposal): Future<Error, void> {
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)))
);
}

Expand Down
13 changes: 13 additions & 0 deletions src/domain/commit/generated-only.ts
Original file line number Diff line number Diff line change
@@ -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")}`;
};
74 changes: 44 additions & 30 deletions src/domain/commit/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = Nothing()): string {
type CommitPrompt = { prompt: string; systemInstruction: string };

const diffPrompt = (gitDiff: string): string => `<git_diff>\n${gitDiff}\n</git_diff>`;

function getPrompt(diff: string, convention: CommitConvention, customTemplate: Maybe<string> = Nothing()): CommitPrompt {
switch (convention) {
case "conventional":
return promptConventional(diff);
Expand All @@ -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 = `
<system>
You are an expert software engineer and version control specialist.
Your job is to read git diffs and output high-quality commit messages
Expand Down Expand Up @@ -102,12 +106,6 @@ function promptConventional(gitDiff: string): string {
</example>
</examples>

<input>
<git_diff>
${gitDiff}
</git_diff>
</input>

<output_instructions>
1. First, internally decide if the change is SMALL, MEDIUM, or LARGE.
2. Do NOT output the classification (SMALL/MEDIUM/LARGE) in your response.
Expand All @@ -122,10 +120,11 @@ function promptConventional(gitDiff: string): string {
• Remaining lines: each line is a bullet starting with "- ".
</output_instructions>
`;
return { prompt: diffPrompt(gitDiff), systemInstruction };
}

function promptImperative(gitDiff: string): string {
return `
function promptImperative(gitDiff: string): CommitPrompt {
const systemInstruction = `
<system>
You are an expert software engineer and version control specialist.
Your job is to read git diffs and output high-quality commit messages
Expand Down Expand Up @@ -216,12 +215,6 @@ function promptImperative(gitDiff: string): string {

</examples>

<input>
<git_diff>
${gitDiff}
</git_diff>
</input>

<output_instructions>
1. First, internally decide if the change is SMALL, MEDIUM, or LARGE
according to the rules above.
Expand All @@ -238,58 +231,79 @@ function promptImperative(gitDiff: string): string {
7. Inline code with single backticks is allowed in the bullet points.
</output_instructions>
`;
return { prompt: diffPrompt(gitDiff), systemInstruction };
}

function promptCustom(gitDiff: string, template: Maybe<string>): string {
function promptCustom(gitDiff: string, template: Maybe<string>): CommitPrompt {
switch (true) {
case template instanceof Nothing:
return promptImperative(gitDiff);
case template instanceof Just: {
const processedTemplate = template.value.replace("{diff}", gitDiff);
return `
const systemInstruction = `
<system>
You are an expert software engineer and version control specialist.
Your job is to read git diffs and output high-quality commit messages
following the user's custom template.
</system>

<user_template>
${processedTemplate}
${template.value.replace("{diff}", "").trim()}
</user_template>

<git_diff>
${gitDiff}
</git_diff>

<output_instructions>
1. Follow the user's template style and format.
2. Analyze the content and create a commit message that matches the template pattern.
3. Output ONLY the final commit message text, with no explanation.
4. Do NOT wrap the commit message in quotes or code fences.
</output_instructions>
`;
return { prompt: diffPrompt(gitDiff), systemInstruction };
}
default:
template satisfies never;
return promptImperative(gitDiff);
}
}

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>): 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<string> = Nothing()): string {
const basePrompt = getPrompt(diff, convention, customTemplate);
const outputInstructionsStart = basePrompt.lastIndexOf("<output_instructions>");
const conventionPrompt = outputInstructionsStart >= 0 ? basePrompt.slice(0, outputInstructionsStart) : basePrompt;
return `
<task>
Partition staged files into reviewable commits. Do not write one message for the whole diff.
A feature that touches unrelated layers is several commits.
</task>
${conventionPrompt}

<staged_files>
${files.join("\n")}
</staged_files>

<git_diff>
${diff}
</git_diff>

<message_convention>
${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.
</message_convention>

<output_shape>
Return ONE JSON object. First character "{", last "}".
{"should_split":<true|false>,"commits":[{"message":"<commit message>","files":["<exact path>",...]}]}
Expand All @@ -301,7 +315,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.
</partition_rules>
<examples>
<example>
Expand Down
4 changes: 3 additions & 1 deletion src/domain/llm/effort.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { seedProviderConfig, withModel, withMinEffort, selectEffortForProvider };
export { seedProviderConfig, withModel, withMinEffort, withDefaultMinEffort, selectEffortForProvider };

import { type Future } from "@/libs/future";
import {
Expand Down Expand Up @@ -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<OpenAIModelEffort> = Nothing()): Future<Error, ProviderConfig> => {
switch (current.provider) {
case "openai":
Expand Down
7 changes: 4 additions & 3 deletions src/domain/llm/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -118,10 +118,11 @@ const generateCommitMessage = (
diff: string,
convention: CommitConvention,
customTemplate: Maybe<string> = Nothing()
): Future<Error, GeneratedContent> => withTransientRetry(() => generateContent(config, { prompt: getPrompt(diff, convention, customTemplate) }));
): Future<Error, GeneratedContent> =>
withTransientRetry(() => generateContent(withDefaultMinEffort(config), getPrompt(diff, convention, customTemplate)));

const refineCommitMessage = (config: ProviderConfig, currentMessage: string, adjustment: string, diff: string): Future<Error, GeneratedContent> =>
withTransientRetry(() => generateContent(config, getRefinePrompt({ diff, currentMessage, adjustment })));
withTransientRetry(() => generateContent(withDefaultMinEffort(config), getRefinePrompt({ diff, currentMessage, adjustment })));

const resultToFuture = <T>(r: Result<Error, T>): Future<Error, T> =>
r.either(
Expand Down
33 changes: 33 additions & 0 deletions src/infra/git/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export {
splitCommitFields,
commandFailureMessage,
parseHookInterpreter,
isGeneratedPath,
parseNumstatCounts,
formatOmittedPaths,
CREATED_FROM_RE,
COMMIT_KEYS,
type BaseLookupError
Expand All @@ -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<string, DiffCounts> => {
const map = new Map<string, DiffCounts>();
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, DiffCounts>): 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);
Expand Down
Loading