feat(commit): added omp commit command for AI-powered conventional commits

- Added `omp commit` command for generating conventional commits with AI-powered analysis of staged changes.
- Implemented conventional commit analysis pipeline with scope detection, changelog categorization, and summary generation.
- Added map-reduce analysis pattern for efficiently processing large diffs using smaller models for file-level observations.
- Implemented automatic changelog generation and updates to CHANGELOG.md files based on commit analysis.
- Added git operations wrapper (ControlledGit) for managing staged files, diffs, commits, and push operations.
- Implemented comprehensive validation for conventional commit format, scope naming, and changelog entries.
This commit is contained in:
can1357
2026-01-23 13:46:06 +01:00
parent fb68e72d78
commit 8ebc89c9d2
34 changed files with 2454 additions and 0 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added `omp commit` command to generate conventional commits with changelog updates
## [8.0.0] - 2026-01-23
### Added
@@ -0,0 +1,165 @@
import type { Api, AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
import { Type } from "@sinclair/typebox";
import analysisSystemPrompt from "$c/commit/prompts/analysis-system.md" with { type: "text" };
import analysisUserPrompt from "$c/commit/prompts/analysis-user.md" with { type: "text" };
import type { ChangelogCategory, ConventionalAnalysis, ConventionalDetail } from "$c/commit/types";
import { renderPromptTemplate } from "$c/config/prompt-templates";
const ConventionalAnalysisTool = {
name: "create_conventional_analysis",
description: "Analyze a diff and return conventional commit classification.",
parameters: Type.Object({
type: Type.Union([
Type.Literal("feat"),
Type.Literal("fix"),
Type.Literal("refactor"),
Type.Literal("docs"),
Type.Literal("test"),
Type.Literal("chore"),
Type.Literal("style"),
Type.Literal("perf"),
Type.Literal("build"),
Type.Literal("ci"),
Type.Literal("revert"),
]),
scope: Type.Union([Type.String(), Type.Null()]),
details: Type.Array(
Type.Object({
text: Type.String(),
changelog_category: Type.Optional(
Type.Union([
Type.Literal("Added"),
Type.Literal("Changed"),
Type.Literal("Fixed"),
Type.Literal("Deprecated"),
Type.Literal("Removed"),
Type.Literal("Security"),
Type.Literal("Breaking Changes"),
]),
),
user_visible: Type.Optional(Type.Boolean()),
}),
),
issue_refs: Type.Array(Type.String()),
}),
};
export interface ConventionalAnalysisInput {
model: Model<Api>;
apiKey: string;
contextFiles?: Array<{ path: string; content: string }>;
userContext?: string;
typesDescription?: string;
recentCommits?: string[];
scopeCandidates: string;
stat: string;
diff: string;
}
/**
* Generate conventional analysis data from a diff and metadata.
*/
export async function generateConventionalAnalysis({
model,
apiKey,
contextFiles,
userContext,
typesDescription,
recentCommits,
scopeCandidates,
stat,
diff,
}: ConventionalAnalysisInput): Promise<ConventionalAnalysis> {
const prompt = renderPromptTemplate(analysisUserPrompt, {
context_files: contextFiles && contextFiles.length > 0 ? contextFiles : undefined,
user_context: userContext,
types_description: typesDescription,
recent_commits: recentCommits?.join("\n"),
scope_candidates: scopeCandidates,
stat,
diff,
});
const response = await completeSimple(
model,
{
systemPrompt: renderPromptTemplate(analysisSystemPrompt),
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
tools: [ConventionalAnalysisTool],
},
{ apiKey, maxTokens: 2400 },
);
return parseAnalysisFromResponse(response);
}
function parseAnalysisFromResponse(message: AssistantMessage): ConventionalAnalysis {
const toolCall = extractToolCall(message, "create_conventional_analysis");
if (toolCall) {
const parsed = validateToolCall([ConventionalAnalysisTool], toolCall) as {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
};
return normalizeAnalysis(parsed);
}
const text = extractTextContent(message);
const parsed = parseJsonPayload(text) as {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
};
return normalizeAnalysis(parsed);
}
function normalizeAnalysis(parsed: {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
}): ConventionalAnalysis {
const details: ConventionalDetail[] = parsed.details.map((detail) => ({
text: detail.text.trim(),
changelogCategory: detail.user_visible ? detail.changelog_category : undefined,
userVisible: detail.user_visible ?? false,
}));
return {
type: parsed.type,
scope: parsed.scope?.trim() || null,
details,
issueRefs: parsed.issue_refs ?? [],
};
}
function extractToolCall(message: AssistantMessage, name: string): ToolCall | undefined {
for (const content of message.content) {
if (content.type === "toolCall" && content.name === name) {
return content;
}
}
return undefined;
}
function extractTextContent(message: AssistantMessage): string {
return message.content
.filter((content) => content.type === "text")
.map((content) => content.text)
.join("")
.trim();
}
function parseJsonPayload(text: string): unknown {
const trimmed = text.trim();
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
return JSON.parse(trimmed) as unknown;
}
const match = trimmed.match(/\{[\s\S]*\}/);
if (!match) {
throw new Error("No JSON payload found in analysis response");
}
return JSON.parse(match[0]) as unknown;
}
@@ -0,0 +1,4 @@
export { generateConventionalAnalysis } from "./conventional";
export { extractScopeCandidates } from "./scope";
export { generateSummary, stripTypePrefix } from "./summary";
export { validateAnalysis, validateScope, validateSummary } from "./validation";
@@ -0,0 +1,216 @@
import type { NumstatEntry } from "$c/commit/types";
import { isExcludedFile } from "$c/commit/utils/exclusions";
interface ScopeCandidate {
path: string;
percentage: number;
confidence: number;
}
const PLACEHOLDER_DIRS = new Set([
"src",
"lib",
"bin",
"crates",
"benches",
"examples",
"internal",
"pkg",
"include",
"tests",
"test",
"docs",
"packages",
"modules",
]);
const SKIP_DIRS = new Set(["test", "tests", "benches", "examples", "target", "build", "node_modules", ".github"]);
export interface ScopeCandidatesResult {
scopeCandidates: string;
isWide: boolean;
}
export function extractScopeCandidates(numstat: NumstatEntry[]): ScopeCandidatesResult {
const componentLines = new Map<string, number>();
const paths: string[] = [];
let totalLines = 0;
for (const entry of numstat) {
const linesChanged = entry.additions + entry.deletions;
if (linesChanged === 0) continue;
const normalizedPath = normalizePathForScope(entry.path);
if (isExcludedFile(normalizedPath)) continue;
paths.push(normalizedPath);
totalLines += linesChanged;
const components = extractComponentsFromPath(normalizedPath);
for (const component of components) {
if (component.split("/").some((segment) => segment.includes("."))) {
continue;
}
componentLines.set(component, (componentLines.get(component) ?? 0) + linesChanged);
}
}
if (totalLines === 0) {
return { scopeCandidates: "(none - no measurable changes)", isWide: false };
}
const candidates = buildScopeCandidates(componentLines, totalLines);
const isWide = isWideChange(candidates, 0.6);
if (isWide) {
const pattern = analyzeWideChange(paths);
if (pattern) {
return { scopeCandidates: `(cross-cutting: ${pattern})`, isWide: true };
}
return { scopeCandidates: "(none - multi-component change)", isWide: true };
}
const suggestionParts: string[] = [];
for (const candidate of candidates.slice(0, 5)) {
if (candidate.percentage < 10) continue;
const confidenceLabel = candidate.path.includes("/")
? candidate.percentage > 60
? "high confidence"
: "moderate confidence"
: "high confidence";
suggestionParts.push(`${candidate.path} (${candidate.percentage.toFixed(0)}%, ${confidenceLabel})`);
}
const scopeCandidates =
suggestionParts.length === 0
? "(none - unclear component)"
: `${suggestionParts.join(", ")}\nPrefer 2-segment scopes marked 'high confidence'`;
return { scopeCandidates, isWide: false };
}
function buildScopeCandidates(componentLines: Map<string, number>, totalLines: number): ScopeCandidate[] {
const candidates: ScopeCandidate[] = [];
for (const [path, lines] of componentLines.entries()) {
if (!path.includes("/") && PLACEHOLDER_DIRS.has(path)) continue;
const root = path.split("/")[0] ?? "";
if (PLACEHOLDER_DIRS.has(root)) continue;
const percentage = (lines / totalLines) * 100;
const isTwoSegment = path.includes("/");
const confidence = isTwoSegment ? (percentage > 60 ? percentage * 1.2 : percentage * 0.8) : percentage;
candidates.push({ path, percentage, confidence });
}
return candidates.sort((a, b) => b.confidence - a.confidence);
}
function isWideChange(candidates: ScopeCandidate[], threshold: number): boolean {
const top = candidates[0];
if (!top) return false;
return top.percentage / 100 < threshold;
}
function extractComponentsFromPath(path: string): string[] {
const segments = path.split("/");
const meaningful: string[] = [];
const stripExt = (segment: string): string => {
const index = segment.lastIndexOf(".");
return index > 0 ? segment.slice(0, index) : segment;
};
const isFile = (segment: string): boolean => {
return segment.includes(".") && !segment.startsWith(".") && segment.lastIndexOf(".") > 0;
};
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index] ?? "";
if (PLACEHOLDER_DIRS.has(segment) && segments.length > index + 1) {
continue;
}
if (isFile(segment)) continue;
if (SKIP_DIRS.has(segment)) continue;
const stripped = stripExt(segment);
if (stripped && !stripped.startsWith(".")) {
meaningful.push(stripped);
}
}
const components: string[] = [];
if (meaningful.length > 0) {
components.push(meaningful[0]!);
if (meaningful.length >= 2) {
components.push(`${meaningful[0]}/${meaningful[1]}`);
}
}
return components;
}
function normalizePathForScope(path: string): string {
const braceStart = path.indexOf("{");
if (braceStart !== -1) {
const arrowPos = path.indexOf(" => ", braceStart);
if (arrowPos !== -1) {
const braceEnd = path.indexOf("}", arrowPos);
if (braceEnd !== -1) {
const prefix = path.slice(0, braceStart);
const newName = path.slice(arrowPos + 4, braceEnd).trim();
return `${prefix}${newName}`;
}
}
}
if (path.includes(" => ")) {
const parts = path.split(" => ");
return parts[1]?.trim() ?? path.trim();
}
return path.trim();
}
function analyzeWideChange(paths: string[]): string | null {
if (paths.length === 0) return null;
const total = paths.length;
let mdCount = 0;
let testCount = 0;
let configCount = 0;
let hasCargoToml = false;
let hasPackageJson = false;
let errorKeywords = 0;
let typeKeywords = 0;
for (const path of paths) {
const lowerPath = path.toLowerCase();
if (lowerPath.endsWith(".md")) {
mdCount += 1;
}
if (lowerPath.includes("/test") || lowerPath.includes("_test.")) {
testCount += 1;
}
if (
lowerPath.endsWith(".toml") ||
lowerPath.endsWith(".yaml") ||
lowerPath.endsWith(".yml") ||
lowerPath.endsWith(".json")
) {
configCount += 1;
}
if (path.includes("Cargo.toml")) {
hasCargoToml = true;
}
if (path.includes("package.json")) {
hasPackageJson = true;
}
if (lowerPath.includes("error") || lowerPath.includes("result") || lowerPath.includes("err")) {
errorKeywords += 1;
}
if (lowerPath.includes("type") || lowerPath.includes("struct") || lowerPath.includes("enum")) {
typeKeywords += 1;
}
}
if (hasCargoToml || hasPackageJson) return "deps";
if ((mdCount * 100) / total > 70) return "docs";
if ((testCount * 100) / total > 60) return "tests";
if ((errorKeywords * 100) / total > 40) return "error-handling";
if ((typeKeywords * 100) / total > 40) return "type-refactor";
if ((configCount * 100) / total > 50) return "config";
return null;
}
@@ -0,0 +1,114 @@
import type { Api, AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
import { Type } from "@sinclair/typebox";
import summarySystemPrompt from "$c/commit/prompts/summary-system.md" with { type: "text" };
import summaryUserPrompt from "$c/commit/prompts/summary-user.md" with { type: "text" };
import type { CommitSummary } from "$c/commit/types";
import { renderPromptTemplate } from "$c/config/prompt-templates";
const SummaryTool = {
name: "create_commit_summary",
description: "Generate the summary line for a conventional commit message.",
parameters: Type.Object({
summary: Type.String(),
}),
};
export interface SummaryInput {
model: Model<Api>;
apiKey: string;
commitType: string;
scope: string | null;
details: string[];
stat: string;
maxChars: number;
userContext?: string;
}
/**
* Generate a commit summary line for the conventional commit header.
*/
export async function generateSummary({
model,
apiKey,
commitType,
scope,
details,
stat,
maxChars,
userContext,
}: SummaryInput): Promise<CommitSummary> {
const systemPrompt = renderSummaryPrompt({ commitType, scope, maxChars });
const userPrompt = renderPromptTemplate(summaryUserPrompt, {
user_context: userContext,
details: details.join("\n"),
stat,
});
const response = await completeSimple(
model,
{
systemPrompt,
messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }],
tools: [SummaryTool],
},
{ apiKey, maxTokens: 200 },
);
return parseSummaryFromResponse(response, commitType, scope);
}
function renderSummaryPrompt({
commitType,
scope,
maxChars,
}: {
commitType: string;
scope: string | null;
maxChars: number;
}): string {
const scopePrefix = scope ? `(${scope})` : "";
return renderPromptTemplate(summarySystemPrompt, {
commit_type: commitType,
scope_prefix: scopePrefix,
chars: String(maxChars),
});
}
function parseSummaryFromResponse(message: AssistantMessage, commitType: string, scope: string | null): CommitSummary {
const toolCall = extractToolCall(message, "create_commit_summary");
if (toolCall) {
const parsed = validateToolCall([SummaryTool], toolCall) as { summary: string };
return { summary: stripTypePrefix(parsed.summary, commitType, scope) };
}
const text = extractTextContent(message);
return { summary: stripTypePrefix(text, commitType, scope) };
}
function extractToolCall(message: AssistantMessage, name: string): ToolCall | undefined {
return message.content.find((content) => content.type === "toolCall" && content.name === name) as
| ToolCall
| undefined;
}
function extractTextContent(message: AssistantMessage): string {
return message.content
.filter((content) => content.type === "text")
.map((content) => content.text)
.join("")
.trim();
}
export function stripTypePrefix(summary: string, commitType: string, scope: string | null): string {
const trimmed = summary.trim();
const scopePart = scope ? `(${scope})` : "";
const withScope = `${commitType}${scopePart}: `;
if (trimmed.startsWith(withScope)) {
return trimmed.slice(withScope.length).trim();
}
const withoutScope = `${commitType}: `;
if (trimmed.startsWith(withoutScope)) {
return trimmed.slice(withoutScope.length).trim();
}
return trimmed;
}
@@ -0,0 +1,66 @@
import type { ConventionalAnalysis } from "$c/commit/types";
export interface ValidationResult {
valid: boolean;
errors: string[];
}
export function validateSummary(summary: string, maxChars: number): ValidationResult {
const errors: string[] = [];
if (!summary.trim()) {
errors.push("Summary is empty");
}
if (summary.length > maxChars) {
errors.push(`Summary exceeds ${maxChars} characters`);
}
if (summary.trimEnd().endsWith(".")) {
errors.push("Summary must not end with a period");
}
if (summary.includes("\n")) {
errors.push("Summary must be a single line");
}
return { valid: errors.length === 0, errors };
}
export function validateScope(scope: string | null): ValidationResult {
if (!scope) return { valid: true, errors: [] };
const errors: string[] = [];
const segments = scope.split("/");
if (segments.length > 2) {
errors.push("Scope may contain at most two segments");
}
for (const segment of segments) {
if (!segment) {
errors.push("Scope segments cannot be empty");
continue;
}
if (segment !== segment.toLowerCase()) {
errors.push("Scope must be lowercase");
}
if (!/^[a-z0-9][a-z0-9-_]*$/.test(segment)) {
errors.push(`Scope segment has invalid characters: ${segment}`);
}
}
return { valid: errors.length === 0, errors };
}
export function validateAnalysis(analysis: ConventionalAnalysis): ValidationResult {
const errors: string[] = [];
const scopeResult = validateScope(analysis.scope);
if (!scopeResult.valid) {
errors.push(...scopeResult.errors);
}
for (const detail of analysis.details) {
if (!detail.text.trim()) {
errors.push("Detail text is empty");
continue;
}
if (!detail.text.trim().endsWith(".")) {
errors.push(`Detail must end with a period: ${detail.text}`);
}
if (detail.text.length > 120) {
errors.push(`Detail exceeds 120 characters: ${detail.text}`);
}
}
return { valid: errors.length === 0, errors };
}
@@ -0,0 +1,36 @@
import { dirname, resolve } from "node:path";
import type { ChangelogBoundary } from "$c/commit/types";
const CHANGELOG_NAME = "CHANGELOG.md";
export async function detectChangelogBoundaries(cwd: string, stagedFiles: string[]): Promise<ChangelogBoundary[]> {
const boundaries = new Map<string, string[]>();
for (const file of stagedFiles) {
if (file.toLowerCase().endsWith("changelog.md")) continue;
const changelogPath = await findNearestChangelog(cwd, file);
if (!changelogPath) continue;
const list = boundaries.get(changelogPath) ?? [];
list.push(file);
boundaries.set(changelogPath, list);
}
return Array.from(boundaries.entries()).map(([changelogPath, files]) => ({
changelogPath,
files,
}));
}
async function findNearestChangelog(cwd: string, filePath: string): Promise<string | null> {
let current = resolve(cwd, dirname(filePath));
const root = resolve(cwd);
while (true) {
const candidate = resolve(current, CHANGELOG_NAME);
if (await Bun.file(candidate).exists()) {
return candidate;
}
if (current === root) return null;
const parent = dirname(current);
if (parent === current) return null;
current = parent;
}
}
@@ -0,0 +1,112 @@
import type { Api, AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
import { Type } from "@sinclair/typebox";
import changelogSystemPrompt from "$c/commit/prompts/changelog-system.md" with { type: "text" };
import changelogUserPrompt from "$c/commit/prompts/changelog-user.md" with { type: "text" };
import type { ChangelogGenerationResult } from "$c/commit/types";
import { renderPromptTemplate } from "$c/config/prompt-templates";
const ChangelogTool = {
name: "create_changelog_entries",
description: "Generate changelog entries grouped by Keep a Changelog categories.",
parameters: Type.Object({
entries: Type.Record(Type.String(), Type.Array(Type.String())),
}),
};
export interface ChangelogPromptInput {
model: Model<Api>;
apiKey: string;
changelogPath: string;
isPackageChangelog: boolean;
existingEntries?: string;
stat: string;
diff: string;
}
export async function generateChangelogEntries({
model,
apiKey,
changelogPath,
isPackageChangelog,
existingEntries,
stat,
diff,
}: ChangelogPromptInput): Promise<ChangelogGenerationResult> {
const prompt = renderPromptTemplate(changelogUserPrompt, {
changelog_path: changelogPath,
is_package_changelog: isPackageChangelog,
existing_entries: existingEntries,
stat,
diff,
});
const response = await completeSimple(
model,
{
systemPrompt: renderPromptTemplate(changelogSystemPrompt),
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
tools: [ChangelogTool],
},
{ apiKey, maxTokens: 1200 },
);
const parsed = parseChangelogResponse(response);
return { entries: dedupeEntries(parsed.entries) };
}
function parseChangelogResponse(message: AssistantMessage): ChangelogGenerationResult {
const toolCall = extractToolCall(message, "create_changelog_entries");
if (toolCall) {
const parsed = validateToolCall([ChangelogTool], toolCall) as ChangelogGenerationResult;
return { entries: parsed.entries ?? {} };
}
const text = extractTextContent(message);
const parsed = parseJsonPayload(text) as ChangelogGenerationResult;
return { entries: parsed.entries ?? {} };
}
function extractToolCall(message: AssistantMessage, name: string): ToolCall | undefined {
return message.content.find((content) => content.type === "toolCall" && content.name === name) as
| ToolCall
| undefined;
}
function extractTextContent(message: AssistantMessage): string {
return message.content
.filter((content) => content.type === "text")
.map((content) => content.text)
.join("")
.trim();
}
function parseJsonPayload(text: string): unknown {
const trimmed = text.trim();
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
return JSON.parse(trimmed) as unknown;
}
const match = trimmed.match(/\{[\s\S]*\}/);
if (!match) {
throw new Error("No JSON payload found in changelog response");
}
return JSON.parse(match[0]) as unknown;
}
function dedupeEntries(entries: Record<string, string[]>): Record<string, string[]> {
const result: Record<string, string[]> = {};
for (const [category, values] of Object.entries(entries)) {
const seen = new Set<string>();
const cleaned: string[] = [];
for (const value of values) {
const trimmed = value.trim().replace(/\.$/, "");
const key = trimmed.toLowerCase();
if (!trimmed || seen.has(key)) continue;
seen.add(key);
cleaned.push(trimmed);
}
if (cleaned.length > 0) {
result[category] = cleaned;
}
}
return result;
}
@@ -0,0 +1,140 @@
import { relative, resolve } from "node:path";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import { detectChangelogBoundaries } from "$c/commit/changelog/detect";
import { generateChangelogEntries } from "$c/commit/changelog/generate";
import { parseUnreleasedSection } from "$c/commit/changelog/parse";
import type { ControlledGit } from "$c/commit/git";
const CHANGELOG_SECTIONS = ["Breaking Changes", "Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"];
const MAX_DIFF_CHARS = 120_000;
export interface ChangelogFlowInput {
git: ControlledGit;
cwd: string;
model: Model<Api>;
apiKey: string;
stagedFiles: string[];
dryRun: boolean;
}
/**
* Update CHANGELOG.md entries for staged changes.
*/
export async function runChangelogFlow({
git,
cwd,
model,
apiKey,
stagedFiles,
dryRun,
}: ChangelogFlowInput): Promise<string[]> {
if (stagedFiles.length === 0) return [];
const boundaries = await detectChangelogBoundaries(cwd, stagedFiles);
if (boundaries.length === 0) return [];
const updated: string[] = [];
for (const boundary of boundaries) {
const diff = await git.getDiffForFiles(boundary.files, true);
if (!diff.trim()) continue;
const stat = await git.getStatForFiles(boundary.files, true);
const diffForPrompt = truncateDiff(diff, MAX_DIFF_CHARS);
const changelogContent = await Bun.file(boundary.changelogPath).text();
let unreleased: { startLine: number; endLine: number; entries: Record<string, string[]> };
try {
unreleased = parseUnreleasedSection(changelogContent);
} catch (error) {
logger.warn("commit changelog parse skipped", { path: boundary.changelogPath, error: String(error) });
continue;
}
const existingEntries = formatExistingEntries(unreleased.entries);
const isPackageChangelog = resolve(boundary.changelogPath) !== resolve(cwd, "CHANGELOG.md");
const generated = await generateChangelogEntries({
model,
apiKey,
changelogPath: boundary.changelogPath,
isPackageChangelog,
existingEntries: existingEntries || undefined,
stat,
diff: diffForPrompt,
});
if (Object.keys(generated.entries).length === 0) continue;
const updatedContent = applyChangelogEntries(changelogContent, unreleased, generated.entries);
if (!dryRun) {
await Bun.write(boundary.changelogPath, updatedContent);
await git.stageFiles([relative(cwd, boundary.changelogPath)]);
}
updated.push(boundary.changelogPath);
}
return updated;
}
function truncateDiff(diff: string, maxChars: number): string {
if (diff.length <= maxChars) return diff;
return `${diff.slice(0, maxChars)}\n... (truncated)`;
}
function formatExistingEntries(entries: Record<string, string[]>): string {
const lines: string[] = [];
for (const section of CHANGELOG_SECTIONS) {
const values = entries[section] ?? [];
if (values.length === 0) continue;
lines.push(`${section}:`);
for (const value of values) {
lines.push(`- ${value}`);
}
}
return lines.join("\n");
}
function applyChangelogEntries(
content: string,
unreleased: { startLine: number; endLine: number; entries: Record<string, string[]> },
entries: Record<string, string[]>,
): string {
const lines = content.split("\n");
const before = lines.slice(0, unreleased.startLine + 1);
const after = lines.slice(unreleased.endLine);
const merged = mergeEntries(unreleased.entries, entries);
const sectionLines = renderUnreleasedSections(merged);
return [...before, ...sectionLines, ...after].join("\n");
}
function mergeEntries(
existing: Record<string, string[]>,
incoming: Record<string, string[]>,
): Record<string, string[]> {
const merged: Record<string, string[]> = { ...existing };
for (const [section, items] of Object.entries(incoming)) {
const current = merged[section] ?? [];
const lower = new Set(current.map((item) => item.toLowerCase()));
for (const item of items) {
if (!lower.has(item.toLowerCase())) {
current.push(item);
}
}
merged[section] = current;
}
return merged;
}
function renderUnreleasedSections(entries: Record<string, string[]>): string[] {
const lines: string[] = [""];
for (const section of CHANGELOG_SECTIONS) {
const items = entries[section] ?? [];
if (items.length === 0) continue;
lines.push(`### ${section}`);
for (const item of items) {
lines.push(`- ${item}`);
}
lines.push("");
}
if (lines[lines.length - 1] === "") {
lines.pop();
}
return lines;
}
@@ -0,0 +1,44 @@
import type { UnreleasedSection } from "$c/commit/types";
const UNRELEASED_PATTERN = /^##\s+\[?Unreleased\]?/i;
const SECTION_PATTERN = /^###\s+(.*)$/;
export function parseUnreleasedSection(content: string): UnreleasedSection {
const lines = content.split("\n");
const startIndex = lines.findIndex((line) => UNRELEASED_PATTERN.test(line.trim()));
if (startIndex === -1) {
throw new Error("No [Unreleased] section found in changelog");
}
let endIndex = lines.length;
for (let i = startIndex + 1; i < lines.length; i += 1) {
if (lines[i]?.startsWith("## ")) {
endIndex = i;
break;
}
}
const sectionLines = lines.slice(startIndex + 1, endIndex);
const entries: Record<string, string[]> = {};
let currentSection: string | null = null;
for (const line of sectionLines) {
const sectionMatch = line.match(SECTION_PATTERN);
if (sectionMatch) {
currentSection = sectionMatch[1]?.trim() || null;
if (currentSection) {
entries[currentSection] = entries[currentSection] ?? [];
}
continue;
}
if (!currentSection) continue;
const trimmed = line.trim();
if (!trimmed.startsWith("-")) continue;
const entry = trimmed.replace(/^[-*]\s*/, "");
if (entry) {
entries[currentSection]?.push(entry);
}
}
return { startLine: startIndex, endLine: endIndex, entries };
}
+89
View File
@@ -0,0 +1,89 @@
import chalk from "chalk";
import type { CommitCommandArgs } from "./types";
const FLAG_ALIASES = new Map<string, string>([
["-c", "--context"],
["-m", "--model"],
]);
export function parseCommitArgs(args: string[]): CommitCommandArgs | undefined {
if (args.length === 0 || args[0] !== "commit") {
return undefined;
}
const result: CommitCommandArgs = {
push: false,
dryRun: false,
noChangelog: false,
};
for (let i = 1; i < args.length; i += 1) {
const raw = args[i] ?? "";
const flag = FLAG_ALIASES.get(raw) ?? raw;
switch (flag) {
case "--push":
result.push = true;
break;
case "--dry-run":
result.dryRun = true;
break;
case "--no-changelog":
result.noChangelog = true;
break;
case "--context": {
const value = args[i + 1];
if (!value || value.startsWith("-")) {
writeStderr(chalk.red("Error: --context requires a value"));
process.exit(1);
}
result.context = value;
i += 1;
break;
}
case "--model": {
const value = args[i + 1];
if (!value || value.startsWith("-")) {
writeStderr(chalk.red("Error: --model requires a value"));
process.exit(1);
}
result.model = value;
i += 1;
break;
}
case "--help":
case "-h":
break;
default:
if (flag.startsWith("-")) {
writeStderr(chalk.red(`Error: Unknown flag ${flag}`));
process.exit(1);
}
}
}
return result;
}
export function printCommitHelp(): void {
const lines = [
"Usage:",
" omp commit [options]",
"",
"Options:",
" --push Push after committing",
" --dry-run Preview without committing",
" --no-changelog Skip changelog updates",
" --context, -c Additional context for the model",
" --model, -m Override model selection",
" --help, -h Show this help message",
];
writeStdout(lines.join("\n"));
}
function writeStdout(message: string): void {
process.stdout.write(`${message}\n`);
}
function writeStderr(message: string): void {
process.stderr.write(`${message}\n`);
}
@@ -0,0 +1,73 @@
import type { FileDiff, NumstatEntry } from "$c/commit/types";
export function parseNumstat(output: string): NumstatEntry[] {
const entries: NumstatEntry[] = [];
for (const line of output.split("\n")) {
if (!line.trim()) continue;
const parts = line.split("\t");
if (parts.length < 3) continue;
const [addedRaw, deletedRaw, pathRaw] = parts;
const additions = Number.parseInt(addedRaw, 10);
const deletions = Number.parseInt(deletedRaw, 10);
const path = extractPathFromRename(pathRaw);
entries.push({
path,
additions: Number.isNaN(additions) ? 0 : additions,
deletions: Number.isNaN(deletions) ? 0 : deletions,
});
}
return entries;
}
export function parseFileDiffs(diff: string): FileDiff[] {
const sections: FileDiff[] = [];
const parts = diff.split("\ndiff --git ");
for (let index = 0; index < parts.length; index += 1) {
const part = index === 0 ? parts[index] : `diff --git ${parts[index]}`;
if (!part.trim()) continue;
const lines = part.split("\n");
const header = lines[0] ?? "";
const match = header.match(/diff --git a\/(.+?) b\/(.+)$/);
if (!match) continue;
const filename = match[2];
const content = part;
const isBinary = lines.some((line) => line.startsWith("Binary files "));
let additions = 0;
let deletions = 0;
for (const line of lines) {
if (line.startsWith("+++") || line.startsWith("---")) continue;
if (line.startsWith("+")) additions += 1;
else if (line.startsWith("-")) deletions += 1;
}
sections.push({
filename,
content,
additions,
deletions,
isBinary,
});
}
return sections;
}
function extractPathFromRename(pathPart: string): string {
const braceStart = pathPart.indexOf("{");
if (braceStart !== -1) {
const arrowPos = pathPart.indexOf(" => ", braceStart);
if (arrowPos !== -1) {
const braceEnd = pathPart.indexOf("}", arrowPos);
if (braceEnd !== -1) {
const prefix = pathPart.slice(0, braceStart);
const newName = pathPart.slice(arrowPos + 4, braceEnd).trim();
return `${prefix}${newName}`;
}
}
}
if (pathPart.includes(" => ")) {
const parts = pathPart.split(" => ");
return parts[1]?.trim() ?? pathPart.trim();
}
return pathPart.trim();
}
@@ -0,0 +1,92 @@
import { logger } from "@oh-my-pi/pi-utils";
import { parseFileDiffs, parseNumstat } from "$c/commit/git/diff";
import { commit, push, runGitCommand, stageFiles } from "$c/commit/git/operations";
import type { FileDiff, NumstatEntry } from "$c/commit/types";
export class ControlledGit {
constructor(private readonly cwd: string) {}
async getDiff(staged: boolean): Promise<string> {
const args = staged ? ["diff", "--cached"] : ["diff"];
const result = await runGitCommand(this.cwd, args);
this.ensureSuccess(result, "git diff");
return result.stdout;
}
async getDiffForFiles(files: string[], staged = true): Promise<string> {
const args = staged ? ["diff", "--cached", "--", ...files] : ["diff", "--", ...files];
const result = await runGitCommand(this.cwd, args);
this.ensureSuccess(result, "git diff (files)");
return result.stdout;
}
async getStat(staged: boolean): Promise<string> {
const args = staged ? ["diff", "--cached", "--stat"] : ["diff", "--stat"];
const result = await runGitCommand(this.cwd, args);
this.ensureSuccess(result, "git diff --stat");
return result.stdout;
}
async getStatForFiles(files: string[], staged = true): Promise<string> {
const args = staged ? ["diff", "--cached", "--stat", "--", ...files] : ["diff", "--stat", "--", ...files];
const result = await runGitCommand(this.cwd, args);
this.ensureSuccess(result, "git diff --stat (files)");
return result.stdout;
}
async getNumstat(staged: boolean): Promise<NumstatEntry[]> {
const args = staged ? ["diff", "--cached", "--numstat"] : ["diff", "--numstat"];
const result = await runGitCommand(this.cwd, args);
this.ensureSuccess(result, "git diff --numstat");
return parseNumstat(result.stdout);
}
async getRecentCommits(count: number): Promise<string[]> {
const result = await runGitCommand(this.cwd, ["log", `-n${count}`, "--pretty=format:%s"]);
this.ensureSuccess(result, "git log");
return result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
async getStagedFiles(): Promise<string[]> {
const result = await runGitCommand(this.cwd, ["diff", "--cached", "--name-only"]);
this.ensureSuccess(result, "git diff --cached --name-only");
return result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
async stageAll(): Promise<void> {
const result = await stageFiles(this.cwd, []);
this.ensureSuccess(result, "git add -A");
}
async stageFiles(files: string[]): Promise<void> {
const result = await stageFiles(this.cwd, files);
this.ensureSuccess(result, "git add");
}
async commit(message: string): Promise<void> {
const result = await commit(this.cwd, message);
this.ensureSuccess(result, "git commit");
}
async push(): Promise<void> {
const result = await push(this.cwd);
this.ensureSuccess(result, "git push");
}
parseDiffFiles(diff: string): FileDiff[] {
return parseFileDiffs(diff);
}
private ensureSuccess(result: { exitCode: number; stderr: string }, label: string): void {
if (result.exitCode !== 0) {
logger.error("commit git command failed", { label, stderr: result.stderr });
throw new Error(`${label} failed: ${result.stderr || "unknown error"}`);
}
}
}
@@ -0,0 +1,52 @@
import { $ } from "bun";
interface GitResult {
exitCode: number;
stdout: string;
stderr: string;
}
export async function runGitCommand(cwd: string, args: string[]): Promise<GitResult> {
const result = await $`git ${args}`.cwd(cwd).quiet().nothrow();
const stdout = result.text();
const stderr = result.stderr?.toString() ?? "";
return {
exitCode: result.exitCode ?? 0,
stdout,
stderr,
};
}
export async function stageFiles(cwd: string, files: string[]): Promise<GitResult> {
const args = files.length === 0 ? ["add", "-A"] : ["add", "--", ...files];
return runGitCommand(cwd, args);
}
export async function push(cwd: string): Promise<GitResult> {
return runGitCommand(cwd, ["push"]);
}
export async function commit(cwd: string, message: string): Promise<GitResult> {
const child = Bun.spawn(["git", "commit", "-F", "-"], {
cwd,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
const writer = child.stdin.getWriter();
await writer.write(new TextEncoder().encode(message));
await writer.close();
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
]);
return {
exitCode: exitCode ?? 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
};
}
@@ -0,0 +1,5 @@
/**
* Entry points for the omp commit command.
*/
export { runCommitCommand } from "./pipeline";
@@ -0,0 +1,49 @@
import type { Api, Model } from "@oh-my-pi/pi-ai";
import { parseFileDiffs } from "$c/commit/git/diff";
import { runMapPhase } from "$c/commit/map-reduce/map-phase";
import { runReducePhase } from "$c/commit/map-reduce/reduce-phase";
import { estimateTokens } from "$c/commit/map-reduce/utils";
import type { ConventionalAnalysis } from "$c/commit/types";
import { isExcludedFile } from "$c/commit/utils/exclusions";
const MIN_FILES_FOR_MAP_REDUCE = 4;
const MAX_FILE_TOKENS = 50_000;
export interface MapReduceInput {
model: Model<Api>;
apiKey: string;
smolModel: Model<Api>;
smolApiKey: string;
diff: string;
stat: string;
scopeCandidates: string;
typesDescription?: string;
}
export function shouldUseMapReduce(diff: string): boolean {
if (process.env.OMP_COMMIT_MAP_REDUCE?.toLowerCase() === "false") return false;
const files = parseFileDiffs(diff).filter((file) => !isExcludedFile(file.filename));
const fileCount = files.length;
if (fileCount >= MIN_FILES_FOR_MAP_REDUCE) return true;
return files.some((file) => estimateTokens(file.content) > MAX_FILE_TOKENS);
}
/**
* Run map-reduce analysis for large diffs using smol + primary models.
*/
export async function runMapReduceAnalysis(input: MapReduceInput): Promise<ConventionalAnalysis> {
const fileDiffs = parseFileDiffs(input.diff).filter((file) => !isExcludedFile(file.filename));
const observations = await runMapPhase({
model: input.smolModel,
apiKey: input.smolApiKey,
files: fileDiffs,
});
return runReducePhase({
model: input.model,
apiKey: input.apiKey,
observations,
stat: input.stat,
scopeCandidates: input.scopeCandidates,
typesDescription: input.typesDescription,
});
}
@@ -0,0 +1,173 @@
import type { Api, AssistantMessage, Model } from "@oh-my-pi/pi-ai";
import { completeSimple } from "@oh-my-pi/pi-ai";
import { truncateToTokenLimit } from "$c/commit/map-reduce/utils";
import fileObserverSystemPrompt from "$c/commit/prompts/file-observer-system.md" with { type: "text" };
import fileObserverUserPrompt from "$c/commit/prompts/file-observer-user.md" with { type: "text" };
import type { FileDiff, FileObservation } from "$c/commit/types";
import { isExcludedFile } from "$c/commit/utils/exclusions";
import { renderPromptTemplate } from "$c/config/prompt-templates";
const MAX_FILE_TOKENS = 50_000;
const MAX_CONTEXT_FILES = 20;
const MAX_CONCURRENCY = 5;
const MAP_PHASE_TIMEOUT_MS = 120_000;
const MAX_RETRIES = 3;
const RETRY_BACKOFF_MS = 1000;
export interface MapPhaseInput {
model: Model<Api>;
apiKey: string;
files: FileDiff[];
}
export async function runMapPhase({ model, apiKey, files }: MapPhaseInput): Promise<FileObservation[]> {
const filtered = files.filter((file) => !isExcludedFile(file.filename));
const systemPrompt = renderPromptTemplate(fileObserverSystemPrompt);
return runWithConcurrency(filtered, MAX_CONCURRENCY, async (file) => {
if (file.isBinary) {
return {
file: file.filename,
observations: ["Binary file changed."],
additions: file.additions,
deletions: file.deletions,
};
}
const contextHeader = generateContextHeader(filtered, file.filename);
const truncated = truncateToTokenLimit(file.content, MAX_FILE_TOKENS);
const prompt = renderPromptTemplate(fileObserverUserPrompt, {
filename: file.filename,
diff: truncated,
context_header: contextHeader,
});
const response = await withRetry(async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), MAP_PHASE_TIMEOUT_MS);
try {
return await completeSimple(
model,
{
systemPrompt: systemPrompt,
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
},
{ apiKey, maxTokens: 400, signal: controller.signal },
);
} finally {
clearTimeout(timeout);
}
}, MAX_RETRIES);
const observations = parseObservations(response);
return {
file: file.filename,
observations,
additions: file.additions,
deletions: file.deletions,
};
});
}
function parseObservations(message: AssistantMessage): string[] {
const text = message.content
.filter((content) => content.type === "text")
.map((content) => content.text)
.join("")
.trim();
if (!text) return [];
const lines = text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.replace(/^[-*]\s+/, ""))
.filter(Boolean);
return lines.slice(0, 5);
}
function generateContextHeader(files: FileDiff[], currentFile: string): string {
if (files.length > 100) {
return `(Large commit with ${files.length} total files)`;
}
const otherFiles = files.filter((file) => file.filename !== currentFile);
if (otherFiles.length === 0) return "";
const sorted = [...otherFiles].sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions));
const toShow = sorted.length > MAX_CONTEXT_FILES ? sorted.slice(0, MAX_CONTEXT_FILES) : sorted;
const lines = ["OTHER FILES IN THIS CHANGE:"];
for (const file of toShow) {
const lineCount = file.additions + file.deletions;
const description = inferFileDescription(file);
lines.push(`- ${file.filename} (${lineCount} lines): ${description}`);
}
if (toShow.length < sorted.length) {
lines.push(`... and ${sorted.length - toShow.length} more files`);
}
return lines.join("\n");
}
function inferFileDescription(file: FileDiff): string {
const filenameLower = file.filename.toLowerCase();
if (filenameLower.includes("test")) return "test file";
if (filenameLower.endsWith(".md")) return "documentation";
if (
filenameLower.includes("config") ||
filenameLower.endsWith(".toml") ||
filenameLower.endsWith(".yaml") ||
filenameLower.endsWith(".yml")
) {
return "configuration";
}
if (filenameLower.includes("error")) return "error definitions";
if (filenameLower.includes("type")) return "type definitions";
if (filenameLower.endsWith("mod.rs") || filenameLower.endsWith("lib.rs")) return "module exports";
if (filenameLower.endsWith("main.rs") || filenameLower.endsWith("main.go") || filenameLower.endsWith("main.py")) {
return "entry point";
}
const content = file.content;
if (content.includes("interface ") || content.includes("type ")) return "type definitions";
if (content.includes("class ") || content.includes("function ") || content.includes("fn ")) return "implementation";
if (content.includes("async ") || content.includes("await")) return "async code";
return "source code";
}
async function runWithConcurrency<T, R>(
items: T[],
limit: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(items.length);
let nextIndex = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (true) {
const current = nextIndex;
nextIndex += 1;
if (current >= items.length) return;
results[current] = await worker(items[current] as T, current);
}
});
await Promise.all(runners);
return results;
}
async function withRetry<T>(fn: () => Promise<T>, attempts: number): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt < attempts - 1) {
await Bun.sleep(RETRY_BACKOFF_MS * (attempt + 1));
}
}
}
throw lastError;
}
@@ -0,0 +1,147 @@
import type { Api, AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
import { Type } from "@sinclair/typebox";
import reduceSystemPrompt from "$c/commit/prompts/reduce-system.md" with { type: "text" };
import reduceUserPrompt from "$c/commit/prompts/reduce-user.md" with { type: "text" };
import type { ChangelogCategory, ConventionalAnalysis, FileObservation } from "$c/commit/types";
import { renderPromptTemplate } from "$c/config/prompt-templates";
const ReduceTool = {
name: "create_conventional_analysis",
description: "Synthesize file observations into a conventional commit analysis.",
parameters: Type.Object({
type: Type.Union([
Type.Literal("feat"),
Type.Literal("fix"),
Type.Literal("refactor"),
Type.Literal("docs"),
Type.Literal("test"),
Type.Literal("chore"),
Type.Literal("style"),
Type.Literal("perf"),
Type.Literal("build"),
Type.Literal("ci"),
Type.Literal("revert"),
]),
scope: Type.Union([Type.String(), Type.Null()]),
details: Type.Array(
Type.Object({
text: Type.String(),
changelog_category: Type.Optional(
Type.Union([
Type.Literal("Added"),
Type.Literal("Changed"),
Type.Literal("Fixed"),
Type.Literal("Deprecated"),
Type.Literal("Removed"),
Type.Literal("Security"),
Type.Literal("Breaking Changes"),
]),
),
user_visible: Type.Optional(Type.Boolean()),
}),
),
issue_refs: Type.Array(Type.String()),
}),
};
export interface ReducePhaseInput {
model: Model<Api>;
apiKey: string;
observations: FileObservation[];
stat: string;
scopeCandidates: string;
typesDescription?: string;
}
export async function runReducePhase({
model,
apiKey,
observations,
stat,
scopeCandidates,
typesDescription,
}: ReducePhaseInput): Promise<ConventionalAnalysis> {
const prompt = renderPromptTemplate(reduceUserPrompt, {
types_description: typesDescription,
observations: observations.flatMap((obs) => obs.observations.map((line) => `- ${obs.file}: ${line}`)).join("\n"),
stat,
scope_candidates: scopeCandidates,
});
const response = await completeSimple(
model,
{
systemPrompt: renderPromptTemplate(reduceSystemPrompt),
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
tools: [ReduceTool],
},
{ apiKey, maxTokens: 2400 },
);
return parseAnalysisResponse(response);
}
function parseAnalysisResponse(message: AssistantMessage): ConventionalAnalysis {
const toolCall = extractToolCall(message, "create_conventional_analysis");
if (toolCall) {
const parsed = validateToolCall([ReduceTool], toolCall) as {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
};
return normalizeAnalysis(parsed);
}
const text = extractTextContent(message);
const parsed = parseJsonPayload(text) as {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
};
return normalizeAnalysis(parsed);
}
function parseJsonPayload(text: string): unknown {
const trimmed = text.trim();
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
return JSON.parse(trimmed) as unknown;
}
const match = trimmed.match(/\{[\s\S]*\}/);
if (!match) {
throw new Error("No JSON payload found in reduce response");
}
return JSON.parse(match[0]) as unknown;
}
function normalizeAnalysis(parsed: {
type: ConventionalAnalysis["type"];
scope: string | null;
details: Array<{ text: string; changelog_category?: ChangelogCategory; user_visible?: boolean }>;
issue_refs: string[];
}): ConventionalAnalysis {
return {
type: parsed.type,
scope: parsed.scope?.trim() || null,
details: parsed.details.map((detail) => ({
text: detail.text.trim(),
changelogCategory: detail.user_visible ? detail.changelog_category : undefined,
userVisible: detail.user_visible ?? false,
})),
issueRefs: parsed.issue_refs ?? [],
};
}
function extractToolCall(message: AssistantMessage, name: string): ToolCall | undefined {
return message.content.find((content) => content.type === "toolCall" && content.name === name) as
| ToolCall
| undefined;
}
function extractTextContent(message: AssistantMessage): string {
return message.content
.filter((content) => content.type === "text")
.map((content) => content.text)
.join("")
.trim();
}
@@ -0,0 +1,9 @@
export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
export function truncateToTokenLimit(text: string, maxTokens: number): string {
const maxChars = maxTokens * 4;
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}\n... (truncated)`;
}
@@ -0,0 +1,277 @@
import { relative } from "node:path";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import { renderPromptTemplate } from "$c/config/prompt-templates";
import { parseModelPattern, parseModelString, SMOL_MODEL_PRIORITY } from "$c/config/model-resolver";
import { SettingsManager } from "$c/config/settings-manager";
import { discoverAuthStorage, discoverModels } from "$c/sdk";
import { loadProjectContextFiles } from "$c/system-prompt";
import typesDescriptionPrompt from "$c/commit/prompts/types-description.md" with { type: "text" };
import {
extractScopeCandidates,
generateConventionalAnalysis,
generateSummary,
validateAnalysis,
validateSummary,
} from "./analysis";
import { runChangelogFlow } from "./changelog";
import { ControlledGit } from "./git";
import { runMapReduceAnalysis, shouldUseMapReduce } from "./map-reduce";
import type { CommitCommandArgs, ConventionalAnalysis } from "./types";
const SUMMARY_MAX_CHARS = 72;
const RECENT_COMMITS_COUNT = 8;
const TYPES_DESCRIPTION = renderPromptTemplate(typesDescriptionPrompt);
/**
* Execute the omp commit pipeline for staged changes.
*/
export async function runCommitCommand(args: CommitCommandArgs): Promise<void> {
const cwd = process.cwd();
const settingsManager = await SettingsManager.create(cwd);
const authStorage = await discoverAuthStorage();
const modelRegistry = await discoverModels(authStorage);
const { model: primaryModel, apiKey: primaryApiKey } = await resolvePrimaryModel(
args.model,
settingsManager,
modelRegistry,
);
const { model: smolModel, apiKey: smolApiKey } = await resolveSmolModel(
settingsManager,
modelRegistry,
primaryModel,
primaryApiKey,
);
const git = new ControlledGit(cwd);
let stagedFiles = await git.getStagedFiles();
if (stagedFiles.length === 0) {
writeStdout("No staged changes detected, staging all changes...");
await git.stageAll();
stagedFiles = await git.getStagedFiles();
}
if (stagedFiles.length === 0) {
writeStderr("No changes to commit.");
return;
}
if (!args.noChangelog) {
await runChangelogFlow({
git,
cwd,
model: primaryModel,
apiKey: primaryApiKey,
stagedFiles,
dryRun: args.dryRun,
});
}
const diff = await git.getDiff(true);
const stat = await git.getStat(true);
const numstat = await git.getNumstat(true);
const scopeCandidates = extractScopeCandidates(numstat).scopeCandidates;
const recentCommits = await git.getRecentCommits(RECENT_COMMITS_COUNT);
const contextFiles = await loadProjectContextFiles({ cwd });
const formattedContextFiles = contextFiles.map((file) => ({
path: relative(cwd, file.path),
content: file.content,
}));
const analysis = await generateAnalysis({
diff,
stat,
scopeCandidates,
recentCommits,
contextFiles: formattedContextFiles,
userContext: args.context,
primaryModel,
primaryApiKey,
smolModel,
smolApiKey,
});
const analysisValidation = validateAnalysis(analysis);
if (!analysisValidation.valid) {
logger.warn("commit analysis validation failed", { errors: analysisValidation.errors });
}
const summary = await generateSummaryWithRetry({
analysis,
stat,
model: primaryModel,
apiKey: primaryApiKey,
userContext: args.context,
});
const commitMessage = formatCommitMessage(analysis, summary.summary);
if (args.dryRun) {
writeStdout("\nGenerated commit message:\n");
writeStdout(commitMessage);
return;
}
await git.commit(commitMessage);
writeStdout("Commit created.");
if (args.push) {
await git.push();
writeStdout("Pushed to remote.");
}
}
async function generateAnalysis(input: {
diff: string;
stat: string;
scopeCandidates: string;
recentCommits: string[];
contextFiles: Array<{ path: string; content: string }>;
userContext?: string;
primaryModel: Model<Api>;
primaryApiKey: string;
smolModel: Model<Api>;
smolApiKey: string;
}): Promise<ConventionalAnalysis> {
if (shouldUseMapReduce(input.diff)) {
writeStdout("Large diff detected, using map-reduce analysis...");
return runMapReduceAnalysis({
model: input.primaryModel,
apiKey: input.primaryApiKey,
smolModel: input.smolModel,
smolApiKey: input.smolApiKey,
diff: input.diff,
stat: input.stat,
scopeCandidates: input.scopeCandidates,
typesDescription: TYPES_DESCRIPTION,
});
}
return generateConventionalAnalysis({
model: input.primaryModel,
apiKey: input.primaryApiKey,
contextFiles: input.contextFiles,
userContext: input.userContext,
typesDescription: TYPES_DESCRIPTION,
recentCommits: input.recentCommits,
scopeCandidates: input.scopeCandidates,
stat: input.stat,
diff: input.diff,
});
}
async function generateSummaryWithRetry(input: {
analysis: ConventionalAnalysis;
stat: string;
model: Model<Api>;
apiKey: string;
userContext?: string;
}): Promise<{ summary: string }> {
let context = input.userContext;
for (let attempt = 0; attempt < 3; attempt += 1) {
const result = await generateSummary({
model: input.model,
apiKey: input.apiKey,
commitType: input.analysis.type,
scope: input.analysis.scope,
details: input.analysis.details.map((detail) => detail.text),
stat: input.stat,
maxChars: SUMMARY_MAX_CHARS,
userContext: context,
});
const validation = validateSummary(result.summary, SUMMARY_MAX_CHARS);
if (validation.valid) {
return result;
}
if (attempt === 2) {
return result;
}
context = buildRetryContext(input.userContext, validation.errors);
}
throw new Error("Summary generation failed");
}
function buildRetryContext(base: string | undefined, errors: string[]): string {
const parts = [base, `Previous summary failed validation: ${errors.join("; ")}`].filter(Boolean);
return parts.join("\n");
}
function formatCommitMessage(analysis: ConventionalAnalysis, summary: string): string {
const scopePart = analysis.scope ? `(${analysis.scope})` : "";
const header = `${analysis.type}${scopePart}: ${summary}`;
const bodyLines = analysis.details.map((detail) => `- ${detail.text.trim()}`);
if (bodyLines.length === 0) {
return header;
}
return `${header}\n\n${bodyLines.join("\n")}`;
}
async function resolvePrimaryModel(
override: string | undefined,
settingsManager: SettingsManager,
modelRegistry: {
getAvailable: () => Model<Api>[];
getApiKey: (model: Model<Api>) => Promise<string | undefined>;
},
): Promise<{ model: Model<Api>; apiKey: string }> {
const available = modelRegistry.getAvailable();
const model = override
? resolveModelFromString(override, available)
: resolveModelFromSettings(settingsManager, available);
if (!model) {
throw new Error("No model available for commit generation");
}
const apiKey = await modelRegistry.getApiKey(model);
if (!apiKey) {
throw new Error(`No API key available for model ${model.provider}/${model.id}`);
}
return { model, apiKey };
}
async function resolveSmolModel(
settingsManager: SettingsManager,
modelRegistry: {
getAvailable: () => Model<Api>[];
getApiKey: (model: Model<Api>) => Promise<string | undefined>;
},
fallbackModel: Model<Api>,
fallbackApiKey: string,
): Promise<{ model: Model<Api>; apiKey: string }> {
const available = modelRegistry.getAvailable();
const role = settingsManager.getModelRole("smol");
const roleModel = role ? resolveModelFromString(role, available) : undefined;
if (roleModel) {
const apiKey = await modelRegistry.getApiKey(roleModel);
if (apiKey) return { model: roleModel, apiKey };
}
for (const pattern of SMOL_MODEL_PRIORITY) {
const candidate = parseModelPattern(pattern, available).model;
if (!candidate) continue;
const apiKey = await modelRegistry.getApiKey(candidate);
if (apiKey) return { model: candidate, apiKey };
}
return { model: fallbackModel, apiKey: fallbackApiKey };
}
function resolveModelFromSettings(settingsManager: SettingsManager, available: Model<Api>[]): Model<Api> | undefined {
const configured = settingsManager.getModelRole("default");
if (!configured) return available[0];
return resolveModelFromString(configured, available) ?? available[0];
}
function resolveModelFromString(value: string, available: Model<Api>[]): Model<Api> | undefined {
const parsed = parseModelString(value);
if (parsed) {
return available.find((model) => model.provider === parsed.provider && model.id === parsed.id);
}
return parseModelPattern(value, available).model;
}
function writeStdout(message: string): void {
process.stdout.write(`${message}\n`);
}
function writeStderr(message: string): void {
process.stderr.write(`${message}\n`);
}
@@ -0,0 +1,155 @@
<context>
You are a senior release engineer who writes precise, changelog-ready commit classifications. Your output feeds directly into automated release tooling.
</context>
<instructions>
Classify this git diff into conventional commit format. Get this right — it affects release notes and semantic versioning.
## 1. Determine Scope
Apply scope when 60%+ of line changes target a single component:
- 150 lines in src/api/, 30 in src/lib.rs -> "api"
- 50 lines in src/api/, 50 in src/types/ -> null (50/50 split)
Use null for: cross-cutting changes, no dominant component, project-wide refactoring.
Forbidden scopes (use null): src, lib, include, tests, benches, examples, docs, project name, app, main, entire, all, misc.
Prefer scopes from <common_scopes> over inventing new ones.
## 2. Generate Details (0-6 items)
Each detail:
1. Past-tense verb, ends with period
2. Explains impact/rationale (skip trivial what-changed)
3. Uses precise names (modules, APIs, files)
4. Under 120 characters
Abstraction preference:
- BEST: "Replaced polling with event-driven model for 10x throughput."
- GOOD: "Consolidated three HTTP builders into unified API."
- SKIP: "Renamed workspacePath to locate."
Group 3+ similar changes: "Updated 5 test files for new API." (not five bullets).
Issue references inline: (#123), (#123, #456), (#123-#125).
Priority: user-visible -> perf/security -> architecture -> internal.
Exclude: import changes, whitespace, formatting, trivial renames, debug prints, comment-only, file moves without modification.
State only visible rationale. If unclear, use neutral: "Updated logic for correctness."
## 3. Assign Changelog Metadata
| Condition | changelog_category |
|-----------|--------------------|
| New public API, feature, capability | "Added" |
| Modified existing behavior | "Changed" |
| Bug fix, correction | "Fixed" |
| Feature marked for removal | "Deprecated" |
| Feature/API removed | "Removed" |
| Security fix or improvement | "Security" |
user_visible: true for: new features, APIs, breaking changes, user-affecting bug fixes, user-facing docs, security fixes.
user_visible: false for: internal refactoring, performance optimizations (unless documented), test/build/CI, code style.
Omit changelog_category when user_visible is false.
</instructions>
<output_format>
Call create_conventional_analysis with:
{
"type": "feat|fix|refactor|docs|test|chore|style|perf|build|ci|revert",
"scope": "component-name" | null,
"details": [
{
"text": "Past-tense description ending with period.",
"changelog_category": "Added|Changed|Fixed|Deprecated|Removed|Security",
"user_visible": true
},
{
"text": "Internal change description.",
"user_visible": false
}
],
"issue_refs": []
}
</output_format>
<examples>
<example name="feature-with-api">
{
"type": "feat",
"scope": "api",
"details": [
{
"text": "Added TLS mutual authentication to prevent man-in-the-middle attacks (#100).",
"changelog_category": "Added",
"user_visible": true
},
{
"text": "Implemented builder pattern to simplify transport configuration (#101).",
"changelog_category": "Added",
"user_visible": true
},
{
"text": "Migrated 6 integration tests to exercise new security features.",
"user_visible": false
}
],
"issue_refs": []
}
</example>
<example name="internal-refactor">
{
"type": "refactor",
"scope": "parser",
"details": [
{
"text": "Extracted validation logic into separate module for reusability.",
"user_visible": false
},
{
"text": "Consolidated error handling across 12 functions to reduce duplication.",
"user_visible": false
}
],
"issue_refs": []
}
</example>
<example name="bug-fix">
{
"type": "fix",
"scope": "parser",
"details": [
{
"text": "Corrected off-by-one error causing buffer overflow on large inputs (#456).",
"changelog_category": "Fixed",
"user_visible": true
},
{
"text": "Added bounds checking to prevent panic on empty files (#457).",
"changelog_category": "Fixed",
"user_visible": true
}
],
"issue_refs": []
}
</example>
<example name="minimal-chore">
{
"type": "chore",
"scope": "deps",
"details": [],
"issue_refs": []
}
</example>
</examples>
Be thorough. This matters.
@@ -0,0 +1,41 @@
{{#if context_files}}
<project_context>
{{#each context_files}}
<file path="{{ path }}">
{{ content }}
</file>
{{/each}}
</project_context>
{{/if}}
{{#if user_context}}
<user_context>
{{ user_context }}
</user_context>
{{/if}}
{{#if types_description}}
<commit_types>
{{ types_description }}
</commit_types>
{{/if}}
<diff_statistics>
{{ stat }}
</diff_statistics>
<scope_candidates>
{{ scope_candidates }}
</scope_candidates>
{{#if common_scopes}}
<common_scopes>
{{ common_scopes }}
</common_scopes>
{{/if}}
{{#if recent_commits}}
<style_patterns>
{{ recent_commits }}
</style_patterns>
{{/if}}
<diff>
{{ diff }}
</diff>
@@ -0,0 +1,56 @@
You are an expert changelog writer who analyzes git diffs and produces Keep a Changelog entries. Get this right—changelogs are how users understand what changed.
<instructions>
Analyze the diff and return JSON changelog entries.
1. Identify user-visible changes only
2. Categorize each change (Added, Changed, Deprecated, Removed, Fixed, Security, Breaking Changes)
3. Write entries starting with past-tense verb describing user impact
4. Omit categories with no entries
5. Return empty entries object for internal-only changes
This matters. Be thorough but precise.
</instructions>
<categories>
- Added: New features, public APIs, user-facing capabilities
- Changed: Modified existing behavior
- Deprecated: Features scheduled for removal
- Removed: Deleted features or APIs
- Fixed: Bug corrections with observable impact
- Security: Vulnerability fixes
- Breaking Changes: API-incompatible modifications (use sparingly)
</categories>
<entry_format>
- Start with past-tense verb (Added, Fixed, Implemented, Updated)
- Describe user-visible impact, not implementation
- Name the specific feature, option, or behavior
- Keep to 1-2 lines, no trailing periods
</entry_format>
<examples>
Good:
- Added --dry-run flag to preview changes without applying them
- Fixed memory leak when processing large files
- Changed default timeout from 30s to 60s for slow connections
Bad:
- **cli**: Added dry-run flag -> scope prefix redundant
- Added new feature. -> vague, has trailing period
- Refactored parser internals -> not user-visible
Breaking Changes example:
- Removed legacy auth flow; users must re-authenticate with OAuth tokens
</examples>
<exclude>
Internal refactoring, code style changes, test-only modifications, minor doc updates, anything invisible to users.
</exclude>
<output_format>
Return ONLY valid JSON. No markdown fences, no explanation.
With entries: {"entries": {"Added": ["entry 1"], "Fixed": ["entry 2"]}}
No changelog-worthy changes: {"entries": {}}
</output_format>
@@ -0,0 +1,19 @@
<context>
Changelog: {{ changelog_path }}
{{#if is_package_changelog}}Scope: Package-level changelog. Omit package name prefix from entries.{{/if}}
</context>
{{#if existing_entries}}
<existing_entries>
Already documented—skip these:
{{ existing_entries }}
</existing_entries>
{{/if}}
<diff_summary>
{{ stat }}
</diff_summary>
<diff>
{{ diff }}
</diff>
@@ -0,0 +1,26 @@
<role>Expert code analyst extracting structured observations from diffs.</role>
<instructions>
Extract factual observations from the diff. This matters—be precise.
1. Use past-tense verb + specific target + optional purpose
2. Max 100 characters per observation
3. Consolidate related changes (e.g., "renamed 5 helper functions")
4. Return 1-5 observations only
</instructions>
<scope>
Include: functions, methods, types, API changes, behavior/logic changes, error handling, performance, security.
Exclude: import reordering, whitespace/formatting, comment-only changes, debug statements.
</scope>
<output_format>
Plain list, no preamble, no summary, no markdown formatting.
- added 'parse_config()' function for TOML configuration loading
- removed deprecated 'legacy_init()' and all callers
- changed 'Connection::new()' to accept '&Config' instead of individual params
</output_format>
Observations only. Classification happens in reduce phase.
@@ -0,0 +1,9 @@
<file path="{{ filename }}">
{{ diff }}
</file>
{{#if context_header}}
<related_files>
{{ context_header }}
</related_files>
{{/if}}
@@ -0,0 +1,60 @@
You are a senior engineer synthesizing file-level observations into a conventional commit analysis.
<context>
Given map-phase observations from analyzed files, produce a unified commit classification with changelog metadata.
</context>
<instructions>
Determine:
1. TYPE: Single classification for entire commit
2. SCOPE: Primary component (null if multi-component)
3. DETAILS: 3-4 summary points (max 6)
4. CHANGELOG: Metadata for user-visible changes
Get this right. Accuracy matters.
</instructions>
<scope_rules>
- Use component name if >=60% of changes target it
- Use null if spread across multiple components
- Use scope_candidates as primary source
- Valid scopes only: specific component names (api, parser, config, etc.)
</scope_rules>
<output_format>
Each detail point:
- Past-tense verb start (added, fixed, moved, extracted)
- Under 120 characters, ends with period
- Group related cross-file changes
Priority: user-visible behavior > performance/security > architecture > internal implementation
changelog_category: Added | Changed | Fixed | Deprecated | Removed | Security
user_visible: true for features, user-facing bugs, breaking changes, security fixes
</output_format>
<example>
Input observations:
- api/client.ts: added token refresh guard to prevent duplicate refreshes
- api/http.ts: introduced retry wrapper for 429 responses
- api/index.ts: updated exports for retry helper
Output:
{
"type": "fix",
"scope": "api",
"details": [
{
"text": "Added token refresh guard to prevent duplicate refreshes.",
"changelog_category": "Fixed",
"user_visible": true
},
{
"text": "Introduced retry wrapper for 429 responses.",
"changelog_category": "Fixed",
"user_visible": true
}
],
"issue_refs": []
}
</example>
@@ -0,0 +1,17 @@
{{#if types_description}}
<type_definitions>
{{ types_description }}
</type_definitions>
{{/if}}
<observations>
{{ observations }}
</observations>
<diff_statistics>
{{ stat }}
</diff_statistics>
<scope_candidates>
{{ scope_candidates }}
</scope_candidates>
@@ -0,0 +1,52 @@
You are a commit message specialist generating precise, informative descriptions.
<context>
Output: ONLY the description after "{{ commit_type }}{{ scope_prefix }}:".
Constraint: {{ chars }} characters max, no trailing period, no type prefix in output.
</context>
<instructions>
1. Start with lowercase past-tense verb (must differ from "{{ commit_type }}")
2. Name the specific subsystem/component affected
3. Include WHY when it clarifies intent
4. One focused concept per message
Get this right.
</instructions>
<verb_reference>
| Type | Use instead |
|----------|-------------------------------------------------|
| feat | added, introduced, implemented, enabled |
| fix | corrected, resolved, patched, addressed |
| refactor | restructured, reorganized, migrated, simplified |
| perf | optimized, reduced, eliminated, accelerated |
| docs | documented, clarified, expanded |
| build | upgraded, pinned, configured |
| chore | cleaned, removed, renamed, organized |
</verb_reference>
<examples>
feat | TLS encryption added to HTTP client for MITM prevention
-> added TLS support to prevent man-in-the-middle attacks
refactor | Consolidated HTTP transport into unified builder pattern
-> migrated HTTP transport to unified builder API
fix | Race condition in connection pool causing exhaustion under load
-> corrected race condition causing connection pool exhaustion
perf | Batch processing optimized to reduce memory allocations
-> eliminated allocation overhead in batch processing
build | Updated serde to fix CVE-2024-1234
-> upgraded serde to 1.0.200 for CVE-2024-1234
</examples>
<banned_words>
comprehensive, various, several, improved, enhanced, quickly, simply, basically, this change, this commit, now
</banned_words>
<output_format>
Output the description text only. Include motivation, name specifics, stay focused.
</output_format>
@@ -0,0 +1,13 @@
{{#if user_context}}
<user_context>
{{ user_context }}
</user_context>
{{/if}}
<detail_points>
{{ details }}
</detail_points>
<diff_stat>
{{ stat }}
</diff_stat>
@@ -0,0 +1,2 @@
Types: feat, fix, refactor, perf, docs, test, build, ci, chore, style, revert.
Format: <type>(<scope>): <summary> with past-tense summary.
+91
View File
@@ -0,0 +1,91 @@
/**
* Types for the omp commit pipeline.
*/
export type CommitType =
| "feat"
| "fix"
| "refactor"
| "perf"
| "docs"
| "test"
| "build"
| "ci"
| "chore"
| "style"
| "revert";
export type ChangelogCategory =
| "Breaking Changes"
| "Added"
| "Changed"
| "Deprecated"
| "Removed"
| "Fixed"
| "Security";
export interface CommitCommandArgs {
/** Push after commit */
push: boolean;
/** Preview without committing */
dryRun: boolean;
/** Skip changelog updates */
noChangelog: boolean;
/** Additional user context for the model */
context?: string;
/** Override the model selection */
model?: string;
}
export interface NumstatEntry {
path: string;
additions: number;
deletions: number;
}
export interface ConventionalDetail {
text: string;
changelogCategory?: ChangelogCategory;
userVisible: boolean;
}
export interface ConventionalAnalysis {
type: CommitType;
scope: string | null;
details: ConventionalDetail[];
issueRefs: string[];
}
export interface CommitSummary {
summary: string;
}
export interface FileObservation {
file: string;
observations: string[];
additions: number;
deletions: number;
}
export interface FileDiff {
filename: string;
content: string;
additions: number;
deletions: number;
isBinary: boolean;
}
export interface ChangelogBoundary {
changelogPath: string;
files: string[];
}
export interface UnreleasedSection {
startLine: number;
endLine: number;
entries: Record<string, string[]>;
}
export interface ChangelogGenerationResult {
entries: Record<string, string[]>;
}
@@ -0,0 +1,33 @@
const EXCLUDED_FILES = [
"Cargo.lock",
"package-lock.json",
"npm-shrinkwrap.json",
"yarn.lock",
"pnpm-lock.yaml",
"shrinkwrap.yaml",
"bun.lock",
"bun.lockb",
"deno.lock",
"composer.lock",
"Gemfile.lock",
"poetry.lock",
"Pipfile.lock",
"pdm.lock",
"uv.lock",
"go.sum",
"flake.lock",
"pubspec.lock",
"Podfile.lock",
"Packages.resolved",
"mix.lock",
"packages.lock.json",
];
export function isExcludedFile(path: string): boolean {
const lower = path.toLowerCase();
return EXCLUDED_FILES.some((name) => lower.endsWith(name.toLowerCase()));
}
export function filterExcludedFiles<T extends { filename: string }>(files: T[]): T[] {
return files.filter((file) => !isExcludedFile(file.filename));
}
+13
View File
@@ -21,6 +21,8 @@ import { selectSession } from "./cli/session-picker";
import { parseSetupArgs, printSetupHelp, runSetupCommand } from "./cli/setup-cli";
import { parseStatsArgs, printStatsHelp, runStatsCommand } from "./cli/stats-cli";
import { parseUpdateArgs, printUpdateHelp, runUpdateCommand } from "./cli/update-cli";
import { runCommitCommand } from "./commit";
import { parseCommitArgs, printCommitHelp } from "./commit/cli";
import { findConfigFile, getModelsPath, VERSION } from "./config";
import type { ModelRegistry } from "./config/model-registry";
import { parseModelPattern, parseModelString, resolveModelScope, type ScopedModel } from "./config/model-resolver";
@@ -532,6 +534,17 @@ export async function main(args: string[]) {
return;
}
// Handle commit subcommand
const commitCmd = parseCommitArgs(args);
if (commitCmd) {
if (args.includes("--help") || args.includes("-h")) {
printCommitHelp();
return;
}
await runCommitCommand(commitCmd);
return;
}
const parsed = parseArgs(args);
time("parseArgs");
await maybeAutoChdir(parsed);