Claude Code Decoded: Git History Context
You're debugging a function with bizarre logic. There's a mysterious edge case handler that seems wrong, but removing it breaks tests. You ask Claude Code for help, but it's as confused as you are - the code makes no sense in isolation.
Then you run git blame:
commit a3f5b89 - 6 months ago - Sarah Chenfix: handle timezone edge case for Australian DSTWe discovered users in Adelaide were getting incorrect timestampsduring DST transitions. South Australian DST rules differ fromother states. This edge case handler is necessary despite looking odd.Relates to: TICKET-1247Suddenly it makes sense. The "bizarre" logic is a carefully considered fix for a specific edge case. But Claude never saw that context - it only sees the current state of the code.
Without git history, Claude is flying blind. It sees the code as it is now, with no understanding of the journey that got it here. And when you're mid-investigation, asking Claude to trace through commits to understand why something exists:
Your session dies. All that historical context you were building - gone. The commit messages you pasted, the blame annotations, the evolution of the code - all lost.
The gap: Code tells you what. Git history tells you why.
Why git history matters
The context in commits
Every commit message, every blame annotation, every changed file tells a story:
What developers capture in commits:
- Intent: Why the change was made
- Context: What problem it solves
- Tradeoffs: What alternatives were considered
- Relationships: What tickets, PRs, or discussions drove it
- Gotchas: What edge cases or bugs it addresses
What Claude sees without history:
// Current state: confusing without contextfunction calculatePrice(amount: number, region: string): number { if (region === 'EU' && amount > 1000) { return amount * 0.85; // What? Why 15% discount? } return amount;}What the git history reveals:
commit f7e2a91 - 2 months ago - Legal team mandatefeat: implement EU bulk purchase VAT exemptionEU regulations require VAT exemption for B2B purchases >€1000.Legal reviewed and approved this implementation.The 0.85 multiplier removes the 15% VAT that was baked into prices.Refs: LEGAL-445, EU-VAT-REGULATION-2024With history, Claude can:
- Understand the legal requirement driving the logic
- Preserve the behavior when refactoring
- Suggest similar handling for other regions
- Avoid "simplifying" code that serves a critical purpose
Common scenarios where history helps
1. Understanding technical debt
You: "Why is this code so convoluted?"# git log shows incremental patchescommit 1: Initial implementation (clean)commit 2: Fix for Safari bug (workaround added)commit 3: IE11 compatibility (more workarounds)commit 4: Performance fix (optimization that hurt readability)Claude: "This evolved to handle browser edge cases. Here's a modern refactor that maintains compatibility..."2. Finding related changes
You: "I'm changing the auth flow. What else should I check?"# git log shows files changed togetherFiles frequently modified together with auth.ts:- session.ts (changed together in 15 commits)- middleware.ts (changed together in 12 commits)- user-service.ts (changed together in 8 commits)Claude: "Based on history, auth changes typically require updates to session handling and middleware. Let me check those..."3. Understanding performance decisions
You: "Why are we caching this specific query?"# git blame + commit messagecommit 3a9f: "cache user preference query - reducing DB load by 60%"Performance data from commit:- Before: 2000 queries/sec to DB- After: 800 queries/sec to DB- Impact: Eliminated production slowdownClaude: "This cache was added to solve a specific performance issue. If you're refactoring, maintain caching or verify the DB can handle the load."Building the Git History Context Server
Architecture
┌─────────────────┐│ Claude Code ││ ││ /git blame │ ← Show who changed what and when│ /git history │ ← Show commit history for file/function│ /git related │ ← Find related changes└────────┬────────┘ │ │ MCP Protocol │┌────────▼────────┐│ Git Context ││ MCP Server ││ ││ • git blame ││ • git log ││ • git diff ││ • co-change │└────────┬────────┘ │ │ ┌────▼────┐ │ .git │ │ repo │ └─────────┘Implementation
1. Git operations wrapper
// src/git-ops.tsimport { execFile } from 'child_process';import { promisify } from 'util';const execFileAsync = promisify(execFile);export interface BlameInfo { line: number; commit: string; author: string; date: string; content: string;}export interface CommitInfo { hash: string; author: string; date: string; message: string; files: string[];}export class GitOps { constructor(private repoPath: string) {} async blame(filePath: string, startLine?: number, endLine?: number): Promise<BlameInfo[]> { const args = ['blame', '--line-porcelain']; if (startLine && endLine) { args.push(`-L${startLine},${endLine}`); } args.push(filePath); const { stdout } = await execFileAsync('git', args, { cwd: this.repoPath, }); return this.parseBlameOutput(stdout); } private parseBlameOutput(output: string): BlameInfo[] { const lines = output.split('\n'); const result: BlameInfo[] = []; let current: Partial<BlameInfo> = {}; for (const line of lines) { if (line.match(/^[0-9a-f]{40}/)) { // New blame block if (current.commit) { result.push(current as BlameInfo); } current = { commit: line.split(' ')[0] }; } else if (line.startsWith('author ')) { current.author = line.slice(7); } else if (line.startsWith('author-time ')) { const timestamp = parseInt(line.slice(12)); current.date = new Date(timestamp * 1000).toISOString().split('T')[0]; } else if (line.startsWith('\t')) { current.content = line.slice(1); current.line = result.length + 1; } } if (current.commit) { result.push(current as BlameInfo); } return result; } async getFileHistory(filePath: string, maxCommits: number = 20): Promise<CommitInfo[]> { const { stdout } = await execFileAsync('git', [ 'log', `--max-count=${maxCommits}`, '--pretty=format:%H|||%an|||%ad|||%s', '--date=short', '--name-only', '--', filePath, ], { cwd: this.repoPath, }); return this.parseLogOutput(stdout); } private parseLogOutput(output: string): CommitInfo[] { const commits: CommitInfo[] = []; const blocks = output.split('\n\n'); for (const block of blocks) { const lines = block.split('\n'); if (lines.length < 1) continue; const [hash, author, date, message] = lines[0].split('|||'); const files = lines.slice(1).filter(Boolean); commits.push({ hash, author, date, message, files }); } return commits; } async getCommitMessage(commitHash: string): Promise<string> { const { stdout } = await execFileAsync('git', [ 'log', '-1', '--pretty=format:%B', commitHash, ], { cwd: this.repoPath, }); return stdout.trim(); } async getCommitDiff(commitHash: string, filePath?: string): Promise<string> { const args = ['show', commitHash, '--pretty=format:', '--unified=3']; if (filePath) { args.push('--', filePath); } const { stdout } = await execFileAsync('git', args, { cwd: this.repoPath, }); return stdout.trim(); } async findCoChangedFiles(filePath: string, minCoChanges: number = 3): Promise<Map<string, number>> { // Find commits that changed this file const { stdout } = await execFileAsync('git', [ 'log', '--pretty=format:%H', '--', filePath, ], { cwd: this.repoPath, }); const commits = stdout.trim().split('\n'); const coChanges = new Map<string, number>(); // For each commit, find other files changed for (const commit of commits.slice(0, 50)) { // Limit to recent commits const { stdout: filesOutput } = await execFileAsync('git', [ 'show', '--pretty=format:', '--name-only', commit, ], { cwd: this.repoPath, }); const files = filesOutput.trim().split('\n').filter(Boolean); for (const file of files) { if (file !== filePath) { coChanges.set(file, (coChanges.get(file) || 0) + 1); } } } // Filter to files changed together at least minCoChanges times const filtered = new Map<string, number>(); for (const [file, count] of coChanges.entries()) { if (count >= minCoChanges) { filtered.set(file, count); } } return filtered; }}2. Function-level blame tracking
// src/function-blame.tsimport { GitOps, BlameInfo } from './git-ops.js';import { Parser } from 'tree-sitter';import TypeScript from 'tree-sitter-typescript';import fs from 'fs/promises';interface FunctionBlame { functionName: string; startLine: number; endLine: number; primaryAuthor: string; lastModified: string; totalCommits: number; contributors: Map<string, number>; recentChanges: Array<{ commit: string; author: string; date: string; message: string; }>;}export class FunctionBlameTracker { private parser: Parser; private gitOps: GitOps; constructor(repoPath: string) { this.parser = new Parser(); this.parser.setLanguage(TypeScript.typescript); this.gitOps = new GitOps(repoPath); } async getFunctionBlame(filePath: string, functionName: string): Promise<FunctionBlame | null> { // Find function location const content = await fs.readFile(filePath, 'utf-8'); const tree = this.parser.parse(content); const functionLocation = this.findFunction(tree, content, functionName); if (!functionLocation) return null; // Get blame for function lines const blameInfo = await this.gitOps.blame( filePath, functionLocation.startLine, functionLocation.endLine ); // Aggregate blame data const contributors = new Map<string, number>(); for (const blame of blameInfo) { contributors.set(blame.author, (contributors.get(blame.author) || 0) + 1); } // Find primary author (most lines) let primaryAuthor = ''; let maxLines = 0; for (const [author, lines] of contributors.entries()) { if (lines > maxLines) { maxLines = lines; primaryAuthor = author; } } // Get recent commits affecting this function const history = await this.gitOps.getFileHistory(filePath, 50); const recentChanges = history.slice(0, 5).map(commit => ({ commit: commit.hash, author: commit.author, date: commit.date, message: commit.message, })); // Find last modification date const lastModified = blameInfo.length > 0 ? blameInfo.reduce((latest, blame) => blame.date > latest ? blame.date : latest, blameInfo[0].date ) : 'unknown'; return { functionName, startLine: functionLocation.startLine, endLine: functionLocation.endLine, primaryAuthor, lastModified, totalCommits: new Set(blameInfo.map(b => b.commit)).size, contributors, recentChanges, }; } private findFunction(tree: Parser.Tree, content: string, functionName: string): { startLine: number; endLine: number } | null { const cursor = tree.walk(); const visit = (): { startLine: number; endLine: number } | null => { const node = cursor.currentNode; if (node.type === 'function_declaration' || node.type === 'method_definition') { const nameNode = node.childForFieldName('name'); if (nameNode) { const name = content.slice(nameNode.startIndex, nameNode.endIndex); if (name === functionName) { return { startLine: node.startPosition.row + 1, endLine: node.endPosition.row + 1, }; } } } if (cursor.gotoFirstChild()) { do { const result = visit(); if (result) return result; } while (cursor.gotoNextSibling()); cursor.gotoParent(); } return null; }; return visit(); }}3. MCP Server
// src/index.tsimport { Server } from '@modelcontextprotocol/sdk/server/index.js';import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';import { GitOps } from './git-ops.js';import { FunctionBlameTracker } from './function-blame.js';class GitHistoryMCPServer { private server: Server; private gitOps: GitOps; private functionBlame: FunctionBlameTracker; constructor(repoPath: string) { this.server = new Server( { name: 'git-history-context', version: '1.0.0' }, { capabilities: { tools: {} } } ); this.gitOps = new GitOps(repoPath); this.functionBlame = new FunctionBlameTracker(repoPath); this.setupHandlers(); } private setupHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'git_blame', description: 'Show who last modified each line of a file or function', inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'Path to file' }, start_line: { type: 'number', description: 'Start line (optional)' }, end_line: { type: 'number', description: 'End line (optional)' }, }, required: ['file_path'], }, }, { name: 'git_history', description: 'Show commit history for a file', inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'Path to file' }, max_commits: { type: 'number', description: 'Max commits to show', default: 20 }, }, required: ['file_path'], }, }, { name: 'git_commit_details', description: 'Get full details of a specific commit', inputSchema: { type: 'object', properties: { commit_hash: { type: 'string', description: 'Commit hash' }, file_path: { type: 'string', description: 'Show diff for specific file (optional)' }, }, required: ['commit_hash'], }, }, { name: 'git_function_history', description: 'Get history and blame for a specific function', inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'Path to file' }, function_name: { type: 'string', description: 'Function name' }, }, required: ['file_path', 'function_name'], }, }, { name: 'git_related_files', description: 'Find files frequently changed together with this file', inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'Path to file' }, min_co_changes: { type: 'number', description: 'Minimum co-changes', default: 3 }, }, required: ['file_path'], }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { if (name === 'git_blame') { const blame = await this.gitOps.blame( args.file_path as string, args.start_line as number | undefined, args.end_line as number | undefined ); let output = `# Git Blame: ${args.file_path}\n\n`; for (const line of blame) { output += `${line.line}: ${line.author} (${line.date}) - ${line.content}\n`; } return { content: [{ type: 'text', text: output }] }; } if (name === 'git_history') { const history = await this.gitOps.getFileHistory( args.file_path as string, args.max_commits as number | undefined ); let output = `# Git History: ${args.file_path}\n\n`; for (const commit of history) { output += `## ${commit.hash.slice(0, 7)} - ${commit.date}\n`; output += `**Author:** ${commit.author}\n`; output += `**Message:** ${commit.message}\n\n`; } return { content: [{ type: 'text', text: output }] }; } if (name === 'git_commit_details') { const message = await this.gitOps.getCommitMessage(args.commit_hash as string); const diff = await this.gitOps.getCommitDiff( args.commit_hash as string, args.file_path as string | undefined ); let output = `# Commit: ${args.commit_hash}\n\n`; output += `## Message\n${message}\n\n`; output += `## Diff\n\`\`\`diff\n${diff}\n\`\`\`\n`; return { content: [{ type: 'text', text: output }] }; } if (name === 'git_function_history') { const blame = await this.functionBlame.getFunctionBlame( args.file_path as string, args.function_name as string ); if (!blame) { return { content: [{ type: 'text', text: `Function '${args.function_name}' not found` }], isError: true, }; } let output = `# Function History: ${blame.functionName}\n\n`; output += `**Lines:** ${blame.startLine}-${blame.endLine}\n`; output += `**Primary Author:** ${blame.primaryAuthor}\n`; output += `**Last Modified:** ${blame.lastModified}\n`; output += `**Total Commits:** ${blame.totalCommits}\n\n`; output += `## Contributors\n`; for (const [author, lines] of blame.contributors.entries()) { output += `- ${author}: ${lines} lines\n`; } output += `\n## Recent Changes\n`; for (const change of blame.recentChanges) { output += `- ${change.date}: ${change.message} (${change.author})\n`; } return { content: [{ type: 'text', text: output }] }; } if (name === 'git_related_files') { const related = await this.gitOps.findCoChangedFiles( args.file_path as string, args.min_co_changes as number | undefined ); let output = `# Related Files: ${args.file_path}\n\n`; output += `Files frequently changed together:\n\n`; const sorted = Array.from(related.entries()).sort((a, b) => b[1] - a[1]); for (const [file, count] of sorted) { output += `- ${file}: ${count} co-changes\n`; } return { content: [{ type: 'text', text: output }] }; } return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true, }; } catch (error) { return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true, }; } }); } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Git History Context MCP Server running'); }}// Get repo path from environment or argumentconst repoPath = process.env.GIT_REPO_PATH || process.cwd();new GitHistoryMCPServer(repoPath).run().catch(console.error);Usage examples
Understanding mysterious code
You: Why does this function have such weird logic?# Use git_function_historyFunction History: calculateDSTOffset**Primary Author:** Sarah Chen**Last Modified:** 2024-04-15**Total Commits:** 7## Recent Changes- 2024-04-15: fix: handle Adelaide DST edge case (Sarah Chen)- 2024-02-10: refactor: simplify timezone handling (Mike Johnson)- 2023-11-20: bug: fix DST calculation for southern hemisphere (Sarah Chen)# Use git_commit_details on the most recentCommit: a3f5b89## Messagefix: handle Adelaide DST edge caseWe discovered users in Adelaide were getting incorrect timestampsduring DST transitions. South Australian DST rules differ from otherAustralian states - they use a different transition date.This edge case handler is necessary despite looking odd. The alternativewould be a timezone library, but that adds 50KB to our bundle.Tested with production data from Adelaide users.Relates to: TICKET-1247Claude: "This handles a specific timezone edge case for Adelaide, Australia. The 'weird' logic is intentional and well-tested. If refactoring, preserve this behavior or ensure comprehensive timezone testing."Finding related changes
You: I'm refactoring the auth module. What else should I check?# Use git_related_filesRelated Files: src/auth/authenticate.tsFiles frequently changed together:- src/auth/session.ts: 15 co-changes- src/middleware/auth-middleware.ts: 12 co-changes- src/services/user-service.ts: 8 co-changes- src/utils/token-generator.ts: 6 co-changesClaude: "Based on git history, auth changes typically affect session handling and middleware. I should check those files for consistency..."Understanding performance decisions
You: Should we remove this cache? Seems unnecessary.# Use git_blame on the caching codeLine 45: John Smith (2024-01-15) - this.cache = new LRU(1000);# Use git_commit_detailsCommit: 7f2e8a1## Messageperf: cache user preferences queryProduction monitoring showed this query was hitting the database2000 times per second, causing DB CPU to spike to 90%.Adding LRU cache reduced DB queries by 60% and CPU usage to 35%.Before: p95 response time 450msAfter: p95 response time 120msDo not remove without load testing.Claude: "This cache solved a critical production performance issue. The commit shows specific metrics. If you want to remove it, you'll need to load test to ensure the DB can handle 2000 QPS."Best practices
When to check git history
✅ Always check for:
- Refactoring existing code
- Removing "dead" or "unnecessary" code
- Simplifying "overly complex" logic
- Understanding edge cases
✅ Often helpful for:
- Debugging production issues
- Planning architectural changes
- Onboarding to new codebases
❌ Skip for:
- Brand new features
- Well-documented standard patterns
- Simple bug fixes in recent code
Writing better commit messages
Since git history becomes AI context, write commits for future readers (human and AI):
❌ Bad:
git commit -m "fix bug"git commit -m "update code"git commit -m "changes"✅ Good:
git commit -m "fix: prevent null pointer in user validationWhen users have no email set, the validation function threw NPE.Added null check and default to 'no-email@example.com'.This is a stopgap - longer term we should make email required.Relates to: TICKET-123"What makes a good commit message:
- Why the change was made
- What problem it solves
- How it was tested
- Context for future maintainers
- References to tickets/docs
Code is a snapshot. Git history is the movie.
When you only give Claude the current state, you're asking it to understand a building without seeing the blueprints, the renovation plans, or the notes about why that weird pillar is load-bearing.
Every deleted line, every refactor, every bug fix has a story. And that story - captured in commits, blame annotations, and change patterns - is often more valuable than the code itself.
The irony: Your team already wrote all this context. Commit messages, PR descriptions, inline explanations of tricky logic. It's all there, timestamped and attributed, waiting to be used. You're just not loading it.
Building git history context into your MCP workflow isn't about being thorough. It's about not throwing away information you already paid to create.
Or you can keep wondering why that function does something weird, while the explanation sits three git blame commands away.
What's next
Git history tells you why code changed. But there's another source of truth: documentation. API specs, architecture decisions, runbooks - all the context that doesn't belong in code. Next up: Documentation Integration - giving Claude complete context by combining code, history, and docs.
Series navigation
← Previous: Claude Code Decoded: Multi-Repo Context - Loading context across repositories
→ Next: Claude Code Decoded: Documentation Integration - Pulling in docs alongside code
Other posts in the series:
- Claude Code Decoded: The Token Tax - Understanding token waste patterns
- Claude Code Decoded: The Handoff Protocol - Efficient session handoffs
- Claude Code Decoded: Smart Context Loading - Minimal context loading
