Skip to main content
Glama
GitIntegrator.tsโ€ข10.6 kB
/** * GitIntegrator - Integrate git history with writing sessions * * Enables writers to: * - Link commits to writing sessions * - Track file revision history * - Understand WHEN and WHY content changed * - Connect version control to decision memory */ import { nanoid } from "nanoid"; import { exec } from "child_process"; import { promisify } from "util"; import type { SQLiteManager } from "../storage/SQLiteManager.js"; const execAsync = promisify(exec); export interface ManuscriptCommit { commitHash: string; timestamp: number; author?: string; message: string; filesChanged: string[]; sessionId?: string; createdAt: number; } export interface FileRevision { id: string; filePath: string; commitHash: string; linesAdded?: number; linesRemoved?: number; diffSummary?: string; rationale?: string; createdAt: number; } export interface CommitQuery { filePath?: string; sessionId?: string; startDate?: Date; endDate?: Date; limit?: number; } export class GitIntegrator { private db: SQLiteManager; private projectPath: string; constructor(db: SQLiteManager, projectPath: string) { this.db = db; this.projectPath = projectPath; } /** * Check if project is a git repository */ async isGitRepository(): Promise<boolean> { try { await execAsync("git rev-parse --git-dir", { cwd: this.projectPath }); return true; } catch { return false; } } /** * Index git commits for manuscript files */ async indexCommits(options?: { since?: Date; filePattern?: string; }): Promise<{ commitsIndexed: number; revisionsCreated: number }> { const isGit = await this.isGitRepository(); if (!isGit) { throw new Error("Project is not a git repository"); } let gitLogCmd = `git log --all --pretty=format:"%H|%at|%an|%s" --numstat`; if (options?.since) { const sinceTimestamp = Math.floor(options.since.getTime() / 1000); gitLogCmd += ` --since=${sinceTimestamp}`; } if (options?.filePattern) { gitLogCmd += ` -- ${options.filePattern}`; } const { stdout } = await execAsync(gitLogCmd, { cwd: this.projectPath }); const commits = this.parseGitLog(stdout); let commitsIndexed = 0; let revisionsCreated = 0; for (const commit of commits) { const existing = this.getCommit(commit.commitHash); if (existing) { continue; // Skip already indexed commits } this.storeCommit(commit); commitsIndexed++; // Create revisions for each file change for (const filePath of commit.filesChanged) { if (filePath.endsWith(".md")) { // Only track markdown files this.createRevision({ filePath, commitHash: commit.commitHash, }); revisionsCreated++; } } } return { commitsIndexed, revisionsCreated }; } /** * Link a commit to a writing session */ linkCommitToSession(commitHash: string, sessionId: string): void { this.db .prepare( `UPDATE manuscript_commits SET session_id = ? WHERE commit_hash = ?` ) .run(sessionId, commitHash); } /** * Get commits for a file */ getCommitsForFile(filePath: string, limit = 10): ManuscriptCommit[] { return this.findCommits({ filePath, limit }); } /** * Get commit by hash */ getCommit(commitHash: string): ManuscriptCommit | undefined { const row = this.db .prepare( `SELECT commit_hash, timestamp, author, message, files_changed, session_id, created_at FROM manuscript_commits WHERE commit_hash = ?` ) .get(commitHash) as CommitRow | undefined; return row ? this.rowToCommit(row) : undefined; } /** * Find commits matching query criteria */ findCommits(query: CommitQuery): ManuscriptCommit[] { let sql = `SELECT commit_hash, timestamp, author, message, files_changed, session_id, created_at FROM manuscript_commits WHERE 1=1`; const params: unknown[] = []; if (query.filePath) { sql += ` AND files_changed LIKE ?`; params.push(`%"${query.filePath}"%`); } if (query.sessionId) { sql += ` AND session_id = ?`; params.push(query.sessionId); } if (query.startDate) { sql += ` AND timestamp >= ?`; params.push(Math.floor(query.startDate.getTime() / 1000)); } if (query.endDate) { sql += ` AND timestamp <= ?`; params.push(Math.floor(query.endDate.getTime() / 1000)); } sql += ` ORDER BY timestamp DESC`; if (query.limit) { sql += ` LIMIT ?`; params.push(query.limit); } const rows = this.db.prepare(sql).all(...params) as CommitRow[]; return rows.map((row) => this.rowToCommit(row)); } /** * Get file revision history */ getFileHistory(filePath: string, limit = 10): FileRevision[] { const rows = this.db .prepare( `SELECT id, file_path, commit_hash, lines_added, lines_removed, diff_summary, rationale, created_at FROM file_revisions WHERE file_path = ? ORDER BY created_at DESC LIMIT ?` ) .all(filePath, limit) as RevisionRow[]; return rows.map((row) => this.rowToRevision(row)); } /** * Get file evolution with commit details */ async getFileEvolution( filePath: string ): Promise< Array<{ revision: FileRevision; commit: ManuscriptCommit; }> > { const revisions = this.getFileHistory(filePath, 20); const evolution = []; for (const revision of revisions) { const commit = this.getCommit(revision.commitHash); if (commit) { evolution.push({ revision, commit }); } } return evolution; } /** * Get diff for a specific commit and file */ async getFileDiff( commitHash: string, filePath: string ): Promise<string | undefined> { try { const { stdout } = await execAsync( `git show ${commitHash}:${filePath}`, { cwd: this.projectPath } ); return stdout; } catch { return undefined; } } /** * Store a commit in the database */ private storeCommit(commit: ManuscriptCommit): void { this.db .prepare( `INSERT INTO manuscript_commits (commit_hash, timestamp, author, message, files_changed, session_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)` ) .run( commit.commitHash, commit.timestamp, commit.author || null, commit.message, JSON.stringify(commit.filesChanged), commit.sessionId || null, commit.createdAt ); } /** * Create a file revision record */ private createRevision(options: { filePath: string; commitHash: string; linesAdded?: number; linesRemoved?: number; diffSummary?: string; rationale?: string; }): FileRevision { const revision: FileRevision = { id: nanoid(), filePath: options.filePath, commitHash: options.commitHash, linesAdded: options.linesAdded, linesRemoved: options.linesRemoved, diffSummary: options.diffSummary, rationale: options.rationale, createdAt: Date.now(), }; this.db .prepare( `INSERT INTO file_revisions (id, file_path, commit_hash, lines_added, lines_removed, diff_summary, rationale, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ) .run( revision.id, revision.filePath, revision.commitHash, revision.linesAdded || null, revision.linesRemoved || null, revision.diffSummary || null, revision.rationale || null, revision.createdAt ); return revision; } /** * Parse git log output */ private parseGitLog(output: string): ManuscriptCommit[] { const commits: ManuscriptCommit[] = []; const lines = output.split("\n").filter((l) => l.trim()); let currentCommit: Partial<ManuscriptCommit> | null = null; const filesChanged: Set<string> = new Set(); for (const line of lines) { if (line.includes("|")) { // Commit line: hash|timestamp|author|message if (currentCommit && currentCommit.commitHash) { commits.push({ ...currentCommit, filesChanged: Array.from(filesChanged), createdAt: Date.now(), } as ManuscriptCommit); filesChanged.clear(); } const [hash, timestamp, author, message] = line.split("|"); currentCommit = { commitHash: hash, timestamp: parseInt(timestamp), author, message, }; } else { // File change line: added removed filename const parts = line.trim().split(/\s+/); if (parts.length >= 3) { const filename = parts[2]; filesChanged.add(filename); } } } // Add last commit if (currentCommit && currentCommit.commitHash) { commits.push({ ...currentCommit, filesChanged: Array.from(filesChanged), createdAt: Date.now(), } as ManuscriptCommit); } return commits; } /** * Convert database row to ManuscriptCommit */ private rowToCommit(row: CommitRow): ManuscriptCommit { return { commitHash: row.commit_hash, timestamp: row.timestamp, author: row.author || undefined, message: row.message, filesChanged: JSON.parse(row.files_changed), sessionId: row.session_id || undefined, createdAt: row.created_at, }; } /** * Convert database row to FileRevision */ private rowToRevision(row: RevisionRow): FileRevision { return { id: row.id, filePath: row.file_path, commitHash: row.commit_hash, linesAdded: row.lines_added || undefined, linesRemoved: row.lines_removed || undefined, diffSummary: row.diff_summary || undefined, rationale: row.rationale || undefined, createdAt: row.created_at, }; } } // Database row types interface CommitRow { commit_hash: string; timestamp: number; author: string | null; message: string; files_changed: string; session_id: string | null; created_at: number; } interface RevisionRow { id: string; file_path: string; commit_hash: string; lines_added: number | null; lines_removed: number | null; diff_summary: string | null; rationale: string | null; created_at: number; }

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/xiaolai/claude-writers-aid-mcp'

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