@zubenelakrab/gitstats
Version:
Powerful Git repository analyzer with comprehensive statistics and insights
449 lines • 18.5 kB
JavaScript
import { toWeekKey, getWeekNumber, getYearNumber, daysDifference, } from '../utils/date.js';
export class VelocityAnalyzer {
name = 'velocity-analyzer';
description = 'Analyzes team velocity and commit trends';
async analyze(commits, _config, tags) {
if (commits.length === 0) {
return this.emptyStats();
}
// Sort commits by date
const sorted = [...commits].sort((a, b) => a.date.getTime() - b.date.getTime());
const firstDate = sorted[0].date;
const lastDate = sorted[sorted.length - 1].date;
const totalDays = daysDifference(firstDate, lastDate) || 1;
// Weekly breakdown
const weeklyMap = new Map();
for (const commit of sorted) {
const weekKey = toWeekKey(commit.date);
if (!weeklyMap.has(weekKey)) {
weeklyMap.set(weekKey, {
commits: 0,
additions: 0,
deletions: 0,
authors: new Set(),
year: getYearNumber(commit.date),
weekNumber: getWeekNumber(commit.date),
});
}
const week = weeklyMap.get(weekKey);
week.commits++;
week.authors.add(commit.author.email);
for (const file of commit.files) {
week.additions += file.additions;
week.deletions += file.deletions;
}
}
const weeklyVelocity = Array.from(weeklyMap.entries())
.map(([week, data]) => ({
week,
year: data.year,
weekNumber: data.weekNumber,
commits: data.commits,
additions: data.additions,
deletions: data.deletions,
authors: data.authors.size,
}))
.sort((a, b) => a.week.localeCompare(b.week));
// Calculate trend (compare first half vs second half)
const midpoint = Math.floor(weeklyVelocity.length / 2);
const firstHalf = weeklyVelocity.slice(0, midpoint);
const secondHalf = weeklyVelocity.slice(midpoint);
const firstHalfAvg = firstHalf.length > 0
? firstHalf.reduce((sum, w) => sum + w.commits, 0) / firstHalf.length
: 0;
const secondHalfAvg = secondHalf.length > 0
? secondHalf.reduce((sum, w) => sum + w.commits, 0) / secondHalf.length
: 0;
const trendPercentage = firstHalfAvg > 0
? ((secondHalfAvg - firstHalfAvg) / firstHalfAvg) * 100
: 0;
let trend;
if (trendPercentage > 10) {
trend = 'accelerating';
}
else if (trendPercentage < -10) {
trend = 'decelerating';
}
else {
trend = 'stable';
}
// Find busiest and slowest weeks
const sortedByCommits = [...weeklyVelocity].sort((a, b) => b.commits - a.commits);
const busiestWeek = sortedByCommits[0] || { week: 'N/A', commits: 0 };
const slowestWeek = sortedByCommits[sortedByCommits.length - 1] || { week: 'N/A', commits: 0 };
// Author velocity
const authorMap = new Map();
for (const commit of sorted) {
const key = commit.author.email.toLowerCase();
if (!authorMap.has(key)) {
authorMap.set(key, {
name: commit.author.name,
email: commit.author.email,
commits: [],
firstCommit: commit.date,
lastCommit: commit.date,
activeDays: new Set(),
});
}
const author = authorMap.get(key);
author.commits.push(commit.date);
author.lastCommit = commit.date;
author.activeDays.add(commit.date.toISOString().split('T')[0]);
}
const authorVelocity = Array.from(authorMap.values()).map(author => {
const authorDays = daysDifference(author.firstCommit, author.lastCommit) || 1;
const timeBetweenCommits = author.commits.length > 1
? this.calculateAverageTimeBetween(author.commits)
: 0;
return {
name: author.name,
email: author.email,
commitsPerDay: author.commits.length / authorDays,
averageTimeBetweenCommits: timeBetweenCommits,
activeDays: author.activeDays.size,
totalDays: authorDays,
};
}).sort((a, b) => b.commitsPerDay - a.commitsPerDay);
// Consistency score (based on standard deviation of weekly commits)
const weeklyCommits = weeklyVelocity.map(w => w.commits);
const consistencyScore = this.calculateConsistencyScore(weeklyCommits);
// Average time between commits
const averageTimeBetweenCommits = this.calculateAverageTimeBetween(sorted.map(c => c.date));
// Calculate MTBLC (Mean Time Between Large Commits)
const largeCommits = sorted.filter(c => {
let totalChanges = 0;
for (const file of c.files) {
totalChanges += file.additions + file.deletions;
}
return totalChanges > 500;
});
const mtblc = this.calculateAverageTimeBetween(largeCommits.map(c => c.date));
const largeCommitFrequency = this.formatTimeDuration(mtblc);
// Velocity by day of week
const velocityByDayOfWeek = [0, 0, 0, 0, 0, 0, 0]; // Sun-Sat
for (const commit of sorted) {
velocityByDayOfWeek[commit.date.getDay()]++;
}
// Calculate release rhythm from tags
const releaseRhythm = this.calculateReleaseRhythm(sorted, tags || []);
// Detect sprint cycles (2-week periods with high activity)
const sprintCycles = this.detectSprintCycles(weeklyVelocity);
// Calculate codebase evolution
const codebaseEvolution = this.calculateCodebaseEvolution(sorted);
return {
commitsPerDay: commits.length / totalDays,
commitsPerWeek: (commits.length / totalDays) * 7,
commitsPerMonth: (commits.length / totalDays) * 30,
trend,
trendPercentage,
weeklyVelocity,
authorVelocity,
busiestWeek: { week: busiestWeek.week, commits: busiestWeek.commits },
slowestWeek: { week: slowestWeek.week, commits: slowestWeek.commits },
consistencyScore,
averageTimeBetweenCommits,
mtblc,
largeCommitFrequency,
releaseRhythm,
velocityByDayOfWeek,
sprintCycles,
codebaseEvolution,
};
}
calculateAverageTimeBetween(dates) {
if (dates.length < 2)
return 0;
const sorted = [...dates].sort((a, b) => a.getTime() - b.getTime());
let totalHours = 0;
for (let i = 1; i < sorted.length; i++) {
const diff = sorted[i].getTime() - sorted[i - 1].getTime();
totalHours += diff / (1000 * 60 * 60);
}
return totalHours / (sorted.length - 1);
}
calculateConsistencyScore(values) {
if (values.length < 2)
return 100;
const mean = values.reduce((a, b) => a + b, 0) / values.length;
if (mean === 0)
return 0;
const variance = values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / values.length;
const stdDev = Math.sqrt(variance);
const coefficientOfVariation = stdDev / mean;
// Convert to 0-100 score (lower CV = higher consistency)
const score = Math.max(0, 100 - (coefficientOfVariation * 100));
return Math.round(score);
}
formatTimeDuration(hours) {
if (hours === 0)
return 'N/A';
if (hours < 24)
return `${Math.round(hours)} hours`;
const days = Math.round(hours / 24);
if (days < 7)
return `${days} days`;
const weeks = Math.round(days / 7);
if (weeks < 4)
return `${weeks} weeks`;
const months = Math.round(days / 30);
return `${months} months`;
}
calculateReleaseRhythm(commits, tags) {
const now = new Date();
// Filter version-like tags and sort by date
const versionTags = tags
.filter(t => /^v?\d+\.\d+/.test(t.name))
.sort((a, b) => a.date.getTime() - b.date.getTime());
if (versionTags.length === 0) {
return {
averageDaysBetweenReleases: 0,
releases: [],
releaseFrequency: 'No releases detected',
lastRelease: null,
daysSinceLastRelease: 0,
};
}
const releases = [];
let previousDate = null;
let previousCommitIndex = 0;
for (const tag of versionTags) {
const daysSinceLastRelease = previousDate
? daysDifference(previousDate, tag.date)
: 0;
// Count commits since last release
let commitsSinceLastRelease = 0;
for (let i = previousCommitIndex; i < commits.length; i++) {
if (commits[i].date <= tag.date) {
commitsSinceLastRelease++;
}
else {
previousCommitIndex = i;
break;
}
}
releases.push({
tag: tag.name,
date: tag.date,
commitsSinceLastRelease,
daysSinceLastRelease,
});
previousDate = tag.date;
}
// Calculate average days between releases
const intervals = releases.slice(1).map(r => r.daysSinceLastRelease);
const averageDaysBetweenReleases = intervals.length > 0
? intervals.reduce((a, b) => a + b, 0) / intervals.length
: 0;
const lastRelease = versionTags[versionTags.length - 1].date;
const daysSinceLastRelease = daysDifference(lastRelease, now);
let releaseFrequency;
if (averageDaysBetweenReleases === 0) {
releaseFrequency = 'Single release';
}
else if (averageDaysBetweenReleases < 7) {
releaseFrequency = 'Weekly';
}
else if (averageDaysBetweenReleases < 14) {
releaseFrequency = 'Bi-weekly';
}
else if (averageDaysBetweenReleases < 35) {
releaseFrequency = 'Monthly';
}
else if (averageDaysBetweenReleases < 100) {
releaseFrequency = 'Quarterly';
}
else {
releaseFrequency = 'Infrequent';
}
return {
averageDaysBetweenReleases: Math.round(averageDaysBetweenReleases),
releases: releases.slice(-10), // Last 10 releases
releaseFrequency,
lastRelease,
daysSinceLastRelease,
};
}
detectSprintCycles(weeklyVelocity) {
if (weeklyVelocity.length < 4)
return [];
const cycles = [];
const avgCommits = weeklyVelocity.reduce((sum, w) => sum + w.commits, 0) / weeklyVelocity.length;
// Look at 2-week windows
for (let i = 0; i < weeklyVelocity.length - 1; i += 2) {
const week1 = weeklyVelocity[i];
const week2 = weeklyVelocity[i + 1];
if (!week1 || !week2)
continue;
const totalCommits = week1.commits + week2.commits;
const totalAuthors = new Set([...Array(week1.authors).keys(), ...Array(week2.authors).keys()]).size;
let intensity;
if (totalCommits > avgCommits * 3)
intensity = 'high';
else if (totalCommits > avgCommits * 1.5)
intensity = 'medium';
else
intensity = 'low';
if (intensity !== 'low') {
cycles.push({
startDate: week1.week,
endDate: week2.week,
commits: totalCommits,
authors: Math.max(week1.authors, week2.authors),
intensity,
});
}
}
return cycles.slice(0, 10);
}
calculateCodebaseEvolution(commits) {
if (commits.length === 0) {
return {
monthly: [],
totalGrowth: 0,
averageMonthlyGrowth: 0,
largestExpansion: { month: 'N/A', additions: 0 },
largestRefactor: { month: 'N/A', deletions: 0 },
fileCountTrend: 'stable',
};
}
// Group commits by month
const monthlyMap = new Map();
// Track all files ever seen
const allFilesEver = new Set();
const deletedFiles = new Set();
for (const commit of commits) {
const monthKey = commit.date.toISOString().slice(0, 7); // YYYY-MM
if (!monthlyMap.has(monthKey)) {
monthlyMap.set(monthKey, {
additions: 0,
deletions: 0,
filesAdded: new Set(),
filesDeleted: new Set(),
filesModified: new Set(),
});
}
const month = monthlyMap.get(monthKey);
for (const file of commit.files) {
month.additions += file.additions;
month.deletions += file.deletions;
// Detect file status based on additions/deletions
if (file.additions > 0 && file.deletions === 0 && !allFilesEver.has(file.path)) {
// New file (only additions, never seen before)
month.filesAdded.add(file.path);
allFilesEver.add(file.path);
}
else if (file.deletions > 0 && file.additions === 0) {
// Potentially deleted file (only deletions)
month.filesDeleted.add(file.path);
deletedFiles.add(file.path);
}
else {
// Modified file
month.filesModified.add(file.path);
allFilesEver.add(file.path);
}
}
}
// Build monthly evolution array with cumulative totals
const sortedMonths = Array.from(monthlyMap.keys()).sort();
const monthly = [];
let cumulativeLOC = 0;
let cumulativeFiles = 0;
let largestExpansion = { month: 'N/A', additions: 0 };
let largestRefactor = { month: 'N/A', deletions: 0 };
for (const monthKey of sortedMonths) {
const data = monthlyMap.get(monthKey);
const netChange = data.additions - data.deletions;
cumulativeLOC += netChange;
// Track unique files (approximation based on new files seen)
cumulativeFiles += data.filesAdded.size;
cumulativeFiles -= data.filesDeleted.size;
cumulativeFiles = Math.max(0, cumulativeFiles);
monthly.push({
month: monthKey,
additions: data.additions,
deletions: data.deletions,
netChange,
filesAdded: data.filesAdded.size,
filesDeleted: data.filesDeleted.size,
filesModified: data.filesModified.size,
cumulativeLOC,
cumulativeFiles,
});
// Track largest expansion
if (data.additions > largestExpansion.additions) {
largestExpansion = { month: monthKey, additions: data.additions };
}
// Track largest refactor (most deletions)
if (data.deletions > largestRefactor.deletions) {
largestRefactor = { month: monthKey, deletions: data.deletions };
}
}
// Calculate totals and trends
const totalGrowth = cumulativeLOC;
const averageMonthlyGrowth = monthly.length > 0
? totalGrowth / monthly.length
: 0;
// Determine file count trend (compare first third to last third)
let fileCountTrend = 'stable';
if (monthly.length >= 3) {
const thirdLength = Math.floor(monthly.length / 3);
const firstThird = monthly.slice(0, thirdLength);
const lastThird = monthly.slice(-thirdLength);
const firstAvgFiles = firstThird.reduce((sum, m) => sum + m.filesAdded.valueOf(), 0) / thirdLength;
const lastAvgFiles = lastThird.reduce((sum, m) => sum + m.filesAdded.valueOf(), 0) / thirdLength;
if (lastAvgFiles > firstAvgFiles * 1.2) {
fileCountTrend = 'growing';
}
else if (lastAvgFiles < firstAvgFiles * 0.8) {
fileCountTrend = 'shrinking';
}
}
return {
monthly,
totalGrowth,
averageMonthlyGrowth: Math.round(averageMonthlyGrowth),
largestExpansion,
largestRefactor,
fileCountTrend,
};
}
emptyStats() {
return {
commitsPerDay: 0,
commitsPerWeek: 0,
commitsPerMonth: 0,
trend: 'stable',
trendPercentage: 0,
weeklyVelocity: [],
authorVelocity: [],
busiestWeek: { week: 'N/A', commits: 0 },
slowestWeek: { week: 'N/A', commits: 0 },
consistencyScore: 0,
averageTimeBetweenCommits: 0,
mtblc: 0,
largeCommitFrequency: 'N/A',
releaseRhythm: {
averageDaysBetweenReleases: 0,
releases: [],
releaseFrequency: 'No releases detected',
lastRelease: null,
daysSinceLastRelease: 0,
},
velocityByDayOfWeek: [0, 0, 0, 0, 0, 0, 0],
sprintCycles: [],
codebaseEvolution: {
monthly: [],
totalGrowth: 0,
averageMonthlyGrowth: 0,
largestExpansion: { month: 'N/A', additions: 0 },
largestRefactor: { month: 'N/A', deletions: 0 },
fileCountTrend: 'stable',
},
};
}
}
export function createVelocityAnalyzer() {
return new VelocityAnalyzer();
}
//# sourceMappingURL=velocity-analyzer.js.map