Back to Blog

Claude Code Decoded: Git History Context

Your git history knows why code exists, not just what it does. Build an MCP server that surfaces commit history, blame data, and change patterns to give Claude temporal context.

Black Dog Labs Team
8/19/2025
16 min read
claude-codeai-developmentmcpgitversion-control

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 Chen
fix: handle timezone edge case for Australian DST
We discovered users in Adelaide were getting incorrect timestamps
during DST transitions. South Australian DST rules differ from
other states. This edge case handler is necessary despite looking odd.
Relates to: TICKET-1247

Suddenly 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:

claude-code

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 context
function 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 mandate
feat: implement EU bulk purchase VAT exemption
EU 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-2024

With 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 patches
commit 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 together
Files 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 message
commit 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 slowdown
Claude: "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.ts
import { 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.ts
import { 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.ts
import { 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 argument
const 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_history
Function 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 recent
Commit: a3f5b89
## Message
fix: handle Adelaide DST edge case
We discovered users in Adelaide were getting incorrect timestamps
during DST transitions. South Australian DST rules differ from other
Australian states - they use a different transition date.
This edge case handler is necessary despite looking odd. The alternative
would be a timezone library, but that adds 50KB to our bundle.
Tested with production data from Adelaide users.
Relates to: TICKET-1247
Claude: "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_files
Related Files: src/auth/authenticate.ts
Files 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-changes
Claude: "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 code
Line 45: John Smith (2024-01-15) - this.cache = new LRU(1000);
# Use git_commit_details
Commit: 7f2e8a1
## Message
perf: cache user preferences query
Production monitoring showed this query was hitting the database
2000 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 450ms
After: p95 response time 120ms
Do 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 validation
When 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: