/** * Service for fetching commit log data from a worktree. * * Extracts the heavy Git command execution and parsing logic from the * commit-log route handler so the handler only validates input, * invokes this service, streams lifecycle events, and sends the response. * * Follows the same approach as branch-commit-log-service: a single * `git log --name-only` call with custom separators to fetch both * commit metadata and file lists, avoiding N+1 git invocations. */ import { execGitCommand } from '../lib/git.js'; // ============================================================================ // Types // ============================================================================ export interface CommitLogEntry { hash: string; shortHash: string; author: string; authorEmail: string; date: string; subject: string; body: string; files: string[]; } export interface CommitLogResult { branch: string; commits: CommitLogEntry[]; total: number; } // ============================================================================ // Service // ============================================================================ /** * Fetch the commit log for a worktree (HEAD). * * Runs a single `git log --name-only` invocation plus `git rev-parse` * inside the given worktree path and returns a structured result. * * @param worktreePath - Absolute path to the worktree / repository * @param limit - Maximum number of commits to return (clamped 1-100) */ export async function getCommitLog(worktreePath: string, limit: number): Promise { // Clamp limit to a reasonable range const parsedLimit = Number(limit); const commitLimit = Math.min(Math.max(1, Number.isFinite(parsedLimit) ? parsedLimit : 20), 100); // Use custom separators to parse both metadata and file lists from // a single git log invocation (same approach as branch-commit-log-service). // // -m causes merge commits to be diffed against each parent so all // files touched by the merge are listed (without -m, --name-only // produces no file output for merge commits because they have 2+ parents). // This means merge commits appear multiple times in the output (once per // parent), so we deduplicate by hash below and merge their file lists. // We over-fetch (2x the limit) to compensate for -m duplicating merge // commit entries, then trim the result to the requested limit. // Use ASCII control characters as record separators – these cannot appear in // git commit messages, so these delimiters are safe regardless of commit // body content. %x00 and %x01 in git's format string emit literal NUL / // SOH bytes respectively. // // COMMIT_SEP (\x00) – marks the start of each commit record. // META_END (\x01) – separates commit metadata from the --name-only file list. // // Full per-commit layout emitted by git: // \x00\n\n\n...\n\n\x01 const COMMIT_SEP = '\x00'; const META_END = '\x01'; const fetchLimit = commitLimit * 2; const logOutput = await execGitCommand( [ 'log', `--max-count=${fetchLimit}`, '-m', '--name-only', `--format=%x00%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x01`, ], worktreePath ); // Split output into per-commit blocks and drop the empty first chunk // (the output starts with a NUL commit separator). const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim()); // Use a Map to deduplicate merge commit entries (which appear once per // parent when -m is used) while preserving insertion order. const commitMap = new Map(); for (const block of commitBlocks) { const metaEndIdx = block.indexOf(META_END); if (metaEndIdx === -1) continue; // malformed block, skip // --- Parse metadata (everything before the META_END delimiter) --- const metaRaw = block.substring(0, metaEndIdx); const metaLines = metaRaw.split('\n'); // The first line may be empty (newline right after COMMIT_SEP), skip it const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== ''); if (nonEmptyStart === -1) continue; const fields = metaLines.slice(nonEmptyStart); if (fields.length < 6) continue; // need at least hash..subject const hash = fields[0].trim(); if (!hash) continue; // defensive: skip if hash is empty const shortHash = fields[1]?.trim() ?? ''; const author = fields[2]?.trim() ?? ''; const authorEmail = fields[3]?.trim() ?? ''; const date = fields[4]?.trim() ?? ''; const subject = fields[5]?.trim() ?? ''; const body = fields.slice(6).join('\n').trim(); // --- Parse file list (everything after the META_END delimiter) --- const filesRaw = block.substring(metaEndIdx + META_END.length); const blockFiles = filesRaw .trim() .split('\n') .filter((f) => f.trim()); // Merge file lists for duplicate entries (merge commits with -m) const existing = commitMap.get(hash); if (existing) { // Add new files to the existing entry's file set const fileSet = new Set(existing.files); for (const f of blockFiles) fileSet.add(f); existing.files = [...fileSet]; } else { commitMap.set(hash, { hash, shortHash, author, authorEmail, date, subject, body, files: [...new Set(blockFiles)], }); } } // Trim to the requested limit (we over-fetched to account for -m duplicates) const commits = [...commitMap.values()].slice(0, commitLimit); // Get current branch name const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath); const branch = branchOutput.trim(); return { branch, commits, total: commits.length, }; }