Skip to main content
Glama
commit.ts13.3 kB
import simpleGit from "simple-git"; import { z } from "zod"; import type { SQLiteDatabase } from "@/database/sqlite"; import type { EmbeddingClient } from "@/embeddings/provider"; import type { Memory } from "@/types"; import type { VectorStore } from "@/vectors/interface"; export const CommitMessageInputSchema = z.object({ diff: z .string() .optional() .describe("Git diff to analyze. If not provided, uses staged changes."), projectPath: z .string() .optional() .describe("Project path. Defaults to current directory."), }); export type CommitMessageInput = z.infer<typeof CommitMessageInputSchema>; export interface CommitMessageResult { suggestedMessage: string; type: string; scope: string | null; summary: string; body: string | null; filesChanged: string[]; relatedMemories: Array<{ id: string; title: string; type: string; relevance: number; }>; relatedIssues: string[]; } export async function generateCommitMessage( input: CommitMessageInput, db: SQLiteDatabase, vectors: VectorStore, embeddings: EmbeddingClient, ): Promise<CommitMessageResult> { const projectPath = input.projectPath ?? process.cwd(); const git = simpleGit(projectPath); // Get staged diff if not provided const diff = input.diff ?? (await git.diff(["--cached"])); if (!diff.trim()) { throw new Error( "No staged changes found. Stage changes with 'git add' first.", ); } // Parse the diff to understand what changed const analysis = analyzeDiff(diff); // Search for related memories based on files and diff content const relatedMemories = await findRelatedMemories( analysis, diff, db, vectors, embeddings, ); // Extract related issues from memories const relatedIssues = extractRelatedIssues(relatedMemories); // Generate conventional commit message with context const type = determineCommitType(analysis, relatedMemories); const scope = determineScope(analysis); const summary = generateSummary(analysis, relatedMemories); const body = generateBody(analysis, relatedMemories, relatedIssues); const scopePart = scope ? `(${scope})` : ""; const suggestedMessage = body ? `${type}${scopePart}: ${summary}\n\n${body}` : `${type}${scopePart}: ${summary}`; return { suggestedMessage, type, scope, summary, body, filesChanged: analysis.files, relatedMemories: relatedMemories.map((m) => ({ id: m.memory.id, title: m.memory.title, type: m.memory.type, relevance: m.score, })), relatedIssues, }; } interface RelatedMemory { memory: Memory; score: number; } async function findRelatedMemories( analysis: DiffAnalysis, diff: string, db: SQLiteDatabase, vectors: VectorStore, embeddings: EmbeddingClient, ): Promise<RelatedMemory[]> { const results: RelatedMemory[] = []; const seenIds = new Set<string>(); // 1. Search by changed files if (analysis.files.length > 0) { const fileSearchResults = await vectors.search( await embeddings.embed(analysis.files.join(" ")), { relatedFiles: analysis.files }, 5, ); for (const result of fileSearchResults) { if (!seenIds.has(result.memoryId)) { const memory = db.getMemory(result.memoryId); if (memory) { results.push({ memory, score: result.score }); seenIds.add(result.memoryId); } } } } // 2. Search by significant changes content if (analysis.significantChanges.length > 0) { const changesQuery = analysis.significantChanges.slice(0, 5).join("\n"); const changesVector = await embeddings.embed(changesQuery); const changesResults = await vectors.search(changesVector, {}, 5); for (const result of changesResults) { if (!seenIds.has(result.memoryId) && result.score > 0.5) { const memory = db.getMemory(result.memoryId); if (memory) { results.push({ memory, score: result.score }); seenIds.add(result.memoryId); } } } } // 3. Search for decisions/patterns related to the diff summary const diffSummary = `${analysis.patterns.isNewFile ? "adding" : "modifying"} ${analysis.files.slice(0, 3).join(", ")}`; const summaryVector = await embeddings.embed(diffSummary); const decisionResults = await vectors.search( summaryVector, { type: "decision" }, 3, ); for (const result of decisionResults) { if (!seenIds.has(result.memoryId) && result.score > 0.6) { const memory = db.getMemory(result.memoryId); if (memory) { results.push({ memory, score: result.score }); seenIds.add(result.memoryId); } } } // Sort by score and limit to top 5 return results.sort((a, b) => b.score - a.score).slice(0, 5); } function extractRelatedIssues(memories: RelatedMemory[]): string[] { const issues: string[] = []; const issuePattern = /#(\d+)|([A-Z]+-\d+)/g; for (const { memory } of memories) { // Check sourcePr field if (memory.sourcePr) { const prMatch = memory.sourcePr.match(/\d+/); if (prMatch) { issues.push(`#${prMatch[0]}`); } } // Extract issues from content const contentMatches = memory.content.matchAll(issuePattern); for (const match of contentMatches) { const issue = match[1] ? `#${match[1]}` : match[2]; if (issue && !issues.includes(issue)) { issues.push(issue); } } // Extract issues from title const titleMatches = memory.title.matchAll(issuePattern); for (const match of titleMatches) { const issue = match[1] ? `#${match[1]}` : match[2]; if (issue && !issues.includes(issue)) { issues.push(issue); } } } return [...new Set(issues)].slice(0, 5); } interface DiffAnalysis { files: string[]; additions: number; deletions: number; significantChanges: string[]; patterns: { isNewFile: boolean; isDelete: boolean; isFix: boolean; isRefactor: boolean; isTest: boolean; isDocs: boolean; isConfig: boolean; isStyle: boolean; }; } function analyzeDiff(diff: string): DiffAnalysis { const files: string[] = []; const significantChanges: string[] = []; let additions = 0; let deletions = 0; const patterns = { isNewFile: false, isDelete: false, isFix: false, isRefactor: false, isTest: false, isDocs: false, isConfig: false, isStyle: false, }; const lines = diff.split("\n"); for (const line of lines) { // Extract file names if (line.startsWith("diff --git")) { const match = line.match(/b\/(.+)$/); if (match) { files.push(match[1]); } } // Count additions/deletions if (line.startsWith("+") && !line.startsWith("+++")) { additions++; // Capture significant additions (function definitions, class definitions, etc.) if ( line.match( /^[+]\s*(export\s+)?(function|class|const|interface|type)\s+\w+/, ) ) { significantChanges.push(line.slice(1).trim()); } } if (line.startsWith("-") && !line.startsWith("---")) { deletions++; } // Detect patterns if (line.includes("new file mode")) patterns.isNewFile = true; if (line.includes("deleted file mode")) patterns.isDelete = true; if ( line.toLowerCase().includes("fix") || line.toLowerCase().includes("bug") ) patterns.isFix = true; } // Detect file type patterns for (const file of files) { if ( file.match(/\.(test|spec)\.(ts|js|tsx|jsx)$/) || file.includes("__tests__") ) { patterns.isTest = true; } if ( file.match(/\.(md|mdx|txt|rst)$/) || file.toLowerCase().includes("readme") ) { patterns.isDocs = true; } if ( file.match(/\.(json|yaml|yml|toml|ini|env)$/) || file.includes("config") ) { patterns.isConfig = true; } if (file.match(/\.(css|scss|less|styled)/) || file.includes("style")) { patterns.isStyle = true; } } // Detect refactor (mostly deletions with additions) if (deletions > additions * 0.5 && additions > 10) { patterns.isRefactor = true; } return { files, additions, deletions, significantChanges, patterns }; } function determineCommitType( analysis: DiffAnalysis, relatedMemories: RelatedMemory[], ): string { const { patterns } = analysis; // Check if related memories suggest a fix (bug reports, solutions) const hasBugMemory = relatedMemories.some( (m) => m.memory.type === "solution" || m.memory.title.toLowerCase().includes("fix") || m.memory.title.toLowerCase().includes("bug"), ); if (hasBugMemory && !patterns.isNewFile) return "fix"; if (patterns.isDelete && analysis.files.length === analysis.deletions) return "chore"; if (patterns.isTest) return "test"; if (patterns.isDocs) return "docs"; if (patterns.isFix) return "fix"; if (patterns.isConfig) return "chore"; if (patterns.isStyle) return "style"; if (patterns.isRefactor) return "refactor"; if (patterns.isNewFile) return "feat"; // Default based on a change ratio if (analysis.additions > analysis.deletions * 2) return "feat"; if (analysis.deletions > analysis.additions) return "refactor"; return "feat"; } function determineScope(analysis: DiffAnalysis): string | null { if (analysis.files.length === 0) return null; // Find common directory const dirs = analysis.files .map((f) => { const parts = f.split("/"); return parts.length > 1 ? parts[0] : null; }) .filter(Boolean); if (dirs.length === 0) return null; // If all files in same directory, use it as scope const uniqueDirs = [...new Set(dirs)]; if (uniqueDirs.length === 1 && uniqueDirs[0]) return uniqueDirs[0]; // Common patterns if (analysis.files.some((f) => f.includes("auth"))) return "auth"; if (analysis.files.some((f) => f.includes("api"))) return "api"; if (analysis.files.some((f) => f.includes("db") || f.includes("database"))) return "db"; if (analysis.files.some((f) => f.includes("ui") || f.includes("component"))) return "ui"; return null; } function generateSummary( analysis: DiffAnalysis, relatedMemories: RelatedMemory[], ): string { const { files, patterns, significantChanges } = analysis; // If high-relevance memory exists, try to use context from it const topMemory = relatedMemories.find((m) => m.score > 0.7); if (topMemory && topMemory.memory.type === "solution") { // For bug fixes, try to capture the essence const title = topMemory.memory.title.toLowerCase(); if (title.length < 50) { return title.startsWith("fix") ? title : `fix ${title}`; } } // If we have significant changes, describe the first one if (significantChanges.length > 0) { const first = significantChanges[0]; const match = first?.match(/(function|class|const|interface|type)\s+(\w+)/); if (match) { const [, kind, name] = match; if (patterns.isNewFile) return `add ${kind} ${name}`; return `update ${kind} ${name}`; } } // Describe based on files if (files.length === 1) { const file = files[0]?.split("/").pop() ?? files[0]; if (patterns.isNewFile) return `add ${file}`; if (patterns.isDelete) return `remove ${file}`; return `update ${file}`; } // Multiple files if (patterns.isNewFile) return `add ${files.length} files`; if (patterns.isDelete) return `remove ${files.length} files`; return `update ${files.length} files`; } function generateBody( analysis: DiffAnalysis, relatedMemories: RelatedMemory[], relatedIssues: string[], ): string | null { const lines: string[] = []; // Add context from related memories if (relatedMemories.length > 0) { const decisions = relatedMemories.filter( (m) => m.memory.type === "decision", ); const patterns = relatedMemories.filter((m) => m.memory.type === "pattern"); if (decisions.length > 0) { lines.push("Related decisions:"); for (const { memory } of decisions.slice(0, 2)) { lines.push(`- ${memory.title}`); } lines.push(""); } if (patterns.length > 0) { lines.push("Follows patterns:"); for (const { memory } of patterns.slice(0, 2)) { lines.push(`- ${memory.title}`); } lines.push(""); } } // Add significant code changes if (analysis.significantChanges.length > 0) { lines.push("Changes:"); for (const change of analysis.significantChanges.slice(0, 5)) { lines.push(`- ${change}`); } if (analysis.significantChanges.length > 5) { lines.push(`- ... and ${analysis.significantChanges.length - 5} more`); } lines.push(""); } // Add files if multiple if (analysis.files.length > 3) { lines.push("Files:"); for (const file of analysis.files.slice(0, 10)) { lines.push(`- ${file}`); } if (analysis.files.length > 10) { lines.push(`- ... and ${analysis.files.length - 10} more`); } lines.push(""); } // Add related issues if (relatedIssues.length > 0) { lines.push(`Relates to: ${relatedIssues.join(", ")}`); } const body = lines.join("\n").trim(); return body.length > 0 ? body : null; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/docleaai/doclea-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server