import simpleGit, { SimpleGit } from 'simple-git';
import { format, subDays, startOfDay, endOfDay } from 'date-fns';
import path from 'path';
import fs from 'fs/promises';
interface CommitStats {
hash: string;
message: string;
author: string;
date: string;
insertions: number;
deletions: number;
files: number;
}
interface AuthorStats {
name: string;
email: string;
commits: number;
firstCommit: string;
lastCommit: string;
}
interface FileStats {
path: string;
commits: number;
authors: string[];
lastModified: string;
insertions: number;
deletions: number;
}
interface RepositoryStats {
totalCommits: number;
totalAuthors: number;
activeBranches: number;
totalTags: number;
firstCommit: string;
lastCommit: string;
recentActivity: CommitStats[];
}
interface BranchStats {
name: string;
commits: number;
lastCommit: string;
ahead: number;
behind: number;
}
export class GitAnalytics {
private git: SimpleGit;
private repoPath: string;
constructor(repoPath: string) {
this.repoPath = path.resolve(repoPath);
this.git = simpleGit(this.repoPath);
}
async validateRepository(): Promise<boolean> {
try {
const isRepo = await this.git.checkIsRepo();
return isRepo;
} catch (error) {
return false;
}
}
async getRepositoryStats(): Promise<RepositoryStats> {
const [log, branches, tags] = await Promise.all([
this.git.log(['--all', '--numstat']),
this.git.branch(),
this.git.tags()
]);
const allCommits = log.all;
const authors = new Set(allCommits.map(commit => commit.author_name));
const firstCommit = allCommits[allCommits.length - 1];
const lastCommit = allCommits[0];
return {
totalCommits: allCommits.length,
totalAuthors: authors.size,
activeBranches: Object.keys(branches.branches).length,
totalTags: Object.keys(tags).length,
firstCommit: firstCommit?.date || '',
lastCommit: lastCommit?.date || '',
recentActivity: allCommits.slice(0, 5).map(commit => ({
hash: commit.hash,
message: commit.message,
author: commit.author_name,
date: commit.date,
insertions: commit.diff?.insertions || 0,
deletions: commit.diff?.deletions || 0,
files: commit.diff?.files?.length || 0
}))
};
}
async getAuthorStats(): Promise<AuthorStats[]> {
const log = await this.git.log(['--all', '--numstat']);
const authorStats = new Map<string, AuthorStats>();
for (const commit of log.all) {
const author = commit.author_name;
if (!authorStats.has(author)) {
authorStats.set(author, {
name: author,
email: commit.author_email,
commits: 0,
firstCommit: commit.date,
lastCommit: commit.date
});
}
const stats = authorStats.get(author)!;
stats.commits++;
stats.lastCommit = commit.date;
}
return Array.from(authorStats.values())
.sort((a, b) => b.commits - a.commits);
}
async getBranchStats(): Promise<BranchStats[]> {
const branches = await this.git.branch(['-v']);
const branchStats: BranchStats[] = [];
for (const branch of branches.all) {
if (branch.startsWith('remotes/')) continue;
try {
const log = await this.git.log([branch]);
const commits = log.all.length;
const lastCommit = log.latest?.date || 'N/A';
// Get ahead/behind info relative to main/master
let ahead = 0, behind = 0;
try {
const mainBranch = branches.all.includes('main') ? 'main' : 'master';
if (branch !== mainBranch && branches.all.includes(mainBranch)) {
const comparison = await this.git.raw(['rev-list', '--left-right', '--count', `${mainBranch}...${branch}`]);
const [behindStr, aheadStr] = comparison.trim().split('\t');
behind = parseInt(behindStr) || 0;
ahead = parseInt(aheadStr) || 0;
}
} catch (error) {
// Ignore comparison errors
}
branchStats.push({
name: branch,
commits,
lastCommit,
ahead,
behind
});
} catch (error) {
// Skip branches that can't be analyzed
}
}
return branchStats.sort((a, b) => b.commits - a.commits);
}
async getFileStats(limit: number = 50): Promise<FileStats[]> {
const log = await this.git.log(['--all', '--numstat', '--pretty=format:%H|%an|%ad']);
const fileMap = new Map<string, FileStats>();
log.all.forEach(commit => {
commit.diff?.files?.forEach(file => {
if (!fileMap.has(file.file)) {
fileMap.set(file.file, {
path: file.file,
commits: 0,
authors: [],
lastModified: commit.date,
insertions: 0,
deletions: 0
});
}
const fileStats = fileMap.get(file.file)!;
fileStats.commits++;
fileStats.insertions += (file as any).insertions || 0;
fileStats.deletions += (file as any).deletions || 0;
if (!fileStats.authors.includes(commit.author_name)) {
fileStats.authors.push(commit.author_name);
}
if (new Date(commit.date) > new Date(fileStats.lastModified)) {
fileStats.lastModified = commit.date;
}
});
});
return Array.from(fileMap.values())
.sort((a, b) => b.commits - a.commits)
.slice(0, limit);
}
async getCommitHistory(days: number = 30): Promise<CommitStats[]> {
const since = format(subDays(new Date(), days), 'yyyy-MM-dd');
const log = await this.git.log(['--all', '--numstat', `--since=${since}`]);
return log.all.map(commit => ({
hash: commit.hash,
date: commit.date,
author: commit.author_name,
message: commit.message,
insertions: commit.diff?.insertions || 0,
deletions: commit.diff?.deletions || 0,
files: commit.diff?.files?.length || 0
}));
}
async getCommitFrequency(days: number = 30): Promise<Record<string, number>> {
const commits = await this.getCommitHistory(days);
const frequency: Record<string, number> = {};
commits.forEach(commit => {
const date = format(new Date(commit.date), 'yyyy-MM-dd');
frequency[date] = (frequency[date] || 0) + 1;
});
return frequency;
}
async getCodeChurn(days: number = 30): Promise<{ date: string; insertions: number; deletions: number; net: number }[]> {
const commits = await this.getCommitHistory(days);
const churnMap: Record<string, { insertions: number; deletions: number }> = {};
commits.forEach(commit => {
const date = format(new Date(commit.date), 'yyyy-MM-dd');
if (!churnMap[date]) {
churnMap[date] = { insertions: 0, deletions: 0 };
}
churnMap[date].insertions += commit.insertions;
churnMap[date].deletions += commit.deletions;
});
return Object.entries(churnMap)
.map(([date, data]) => ({
date,
insertions: data.insertions,
deletions: data.deletions,
net: data.insertions - data.deletions
}))
.sort((a, b) => a.date.localeCompare(b.date));
}
}