Skip to content

Instantly share code, notes, and snippets.

@dashw00d
Created December 19, 2025 21:30
Show Gist options
  • Select an option

  • Save dashw00d/564eb26484cf2bdd9c0dd18d600f625b to your computer and use it in GitHub Desktop.

Select an option

Save dashw00d/564eb26484cf2bdd9c0dd18d600f625b to your computer and use it in GitHub Desktop.
Estimate Total Work Hours using Git - Adjusted for AI Use
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
// Limit buffer size for large logs (10MB)
const MAX_BUFFER = 10 * 1024 * 1024;
// Parse command-line arguments
function parseDateArg() {
const args = process.argv.slice(2);
if (args.length === 0) {
// Default to 2025-04-12 if no argument provided
return '2025-04-12';
}
if (args.length > 1) {
console.error('Error: Too many arguments. Please provide a single date in MM-DD-YYYY format.');
console.error('Usage: node estimate-git-hours.js [MM-DD-YYYY]');
process.exit(1);
}
const dateArg = args[0];
// Parse MM-DD-YYYY format
const match = dateArg.match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/);
if (!match) {
console.error('Error: Invalid date format. Please use MM-DD-YYYY format (e.g., 12-19-2025)');
process.exit(1);
}
const [, month, day, year] = match;
const monthNum = parseInt(month, 10);
const dayNum = parseInt(day, 10);
const yearNum = parseInt(year, 10);
// Validate date
if (monthNum < 1 || monthNum > 12) {
console.error('Error: Month must be between 1 and 12');
process.exit(1);
}
if (dayNum < 1 || dayNum > 31) {
console.error('Error: Day must be between 1 and 31');
process.exit(1);
}
// Convert to YYYY-MM-DD format for git
const formattedMonth = String(monthNum).padStart(2, '0');
const formattedDay = String(dayNum).padStart(2, '0');
return `${yearNum}-${formattedMonth}-${formattedDay}`;
}
// Configuration
const START_DATE = parseDateArg();
const REPO_PATH = process.cwd();
// Helper to run git commands
function gitCommand(cmd) {
try {
return execSync(`git ${cmd}`, {
cwd: REPO_PATH,
encoding: 'utf-8',
stdio: 'pipe',
maxBuffer: MAX_BUFFER
}).trim();
} catch (error) {
// If no commits found or error, return empty
return '';
}
}
// Get all commits since start date with stats
// We use --numstat to get lines changed in the same command, avoiding N+1 git calls
function getCommits() {
const separator = '||COMMIT_SEPARATOR||';
// Format: Hash | Date | Author | Subject
const format = `${separator}%H|%ai|%an|%s`;
const cmd = `log --no-merges --since="${START_DATE}" --pretty=format:"${format}" --numstat --date=iso`;
const output = gitCommand(cmd);
if (!output) return [];
// Files to exclude from line counts (generated, vendor, lock files, etc.)
const EXCLUDED_PATTERNS = [
/^node_modules\//,
/^vendor\//,
/^public\/build\//,
/^public\/hot$/,
/^storage\//,
/^bootstrap\/cache\//,
/\.lock$/,
/package-lock\.json$/,
/composer\.lock$/,
/\.min\.js$/,
/\.min\.css$/,
/\.map$/,
/\.chunk\.js$/,
/\.bundle\.js$/,
];
const isExcludedFile = (filePath) => {
return EXCLUDED_PATTERNS.some(pattern => pattern.test(filePath));
};
// Split by our separator
const rawCommits = output.split(separator).slice(1); // First element is empty split
return rawCommits.map(raw => {
const lines = raw.trim().split('\n');
const header = lines[0];
const statLines = lines.slice(1);
const [hash, date, author, subject] = header.split('|');
// Calculate total lines changed (excluding generated files)
let added = 0;
let removed = 0;
statLines.forEach(line => {
const parts = line.split(/\s+/);
// git numstat output: 10 5 path/to/file
if (parts.length >= 3) {
const filePath = parts.slice(2).join(' '); // Handle filenames with spaces
// Skip excluded files
if (isExcludedFile(filePath)) return;
// Binary files show as '-'
const addCount = parts[0] === '-' ? 0 : parseInt(parts[0], 10);
const remCount = parts[1] === '-' ? 0 : parseInt(parts[1], 10);
if (!isNaN(addCount)) added += addCount;
if (!isNaN(remCount)) removed += remCount;
}
});
return {
hash,
date: new Date(date),
author: author ? author.trim() : 'Unknown',
subject,
totalLines: added + removed
};
});
}
// Calculate hours based on sessions
function calculateHours(commits) {
// 1. Group by Author
const authors = {};
commits.forEach(c => {
if (!authors[c.author]) authors[c.author] = [];
authors[c.author].push(c);
});
const dailyStats = {};
let totalHours = 0;
// Estimate hours based on lines changed
// AI-assisted development is MUCH faster than traditional coding
// Adjust rates accordingly (100-200+ lines/hour is realistic with AI)
const estimateHoursFromLines = (lines) => {
if (lines <= 0) return 0.1; // Minimum for any commit (config changes, etc.)
if (lines <= 20) return 0.15; // Tiny fix
if (lines <= 100) return lines / 150; // Small changes: ~150 lines/hour with AI
if (lines <= 500) return lines / 120; // Medium: ~120 lines/hour
return lines / 100; // Large refactors: ~100 lines/hour (still need to think)
};
// 2. Process each author's timeline independently
Object.keys(authors).forEach(author => {
const timeline = authors[author].sort((a, b) => a.date - b.date);
if (timeline.length === 0) return;
// Constants
const SESSION_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours context
// Estimate "Startup Cost" for a fresh session based on commit size
const getStartupCost = (lines) => {
if (lines <= 20) return 15; // Quick fix: 15 mins
if (lines > 500) return 60; // Huge feature/refactor: 1 hour
return 30; // Standard: 30 mins
};
let lastCommitTime = null;
timeline.forEach((commit, index) => {
let sessionMinutes = 0;
if (index === 0) {
// First commit ever for this period
sessionMinutes = getStartupCost(commit.totalLines);
} else {
const timeDiff = commit.date - lastCommitTime; // ms
if (timeDiff <= SESSION_THRESHOLD_MS) {
// Continuous work - count actual time gap
sessionMinutes = timeDiff / (1000 * 60);
} else {
// New session started
sessionMinutes = getStartupCost(commit.totalLines);
}
}
// Calculate lines-based estimate for this commit
const linesBasedHours = estimateHoursFromLines(commit.totalLines);
const sessionBasedHours = sessionMinutes / 60;
// Take the MAXIMUM of session-based vs lines-based
// This ensures big commits aren't undervalued when they happen quickly
// Cap at 4 hours per commit to prevent outliers (huge refactors, generated code that slipped through)
const hours = Math.min(4, Math.max(sessionBasedHours, linesBasedHours));
totalHours += hours;
// Add to daily bucket
// Use local date string to align with the displayed times and user's wall clock
// en-CA gives YYYY-MM-DD format
const dateKey = commit.date.toLocaleDateString('en-CA');
if (!dailyStats[dateKey]) {
dailyStats[dateKey] = {
commits: 0,
hours: 0,
first: commit.date,
last: commit.date,
entries: [] // Track individual commits for timesheet
};
}
const dayStat = dailyStats[dateKey];
dayStat.commits++;
dayStat.hours += hours;
// Store entry for timesheet
dayStat.entries.push({
time: commit.date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
hours: hours,
lines: commit.totalLines,
message: commit.subject,
hash: commit.hash.substring(0, 7)
});
// Update time range for the day
if (commit.date < dayStat.first) dayStat.first = commit.date;
if (commit.date > dayStat.last) dayStat.last = commit.date;
lastCommitTime = commit.date;
});
});
// Apply daily cap (14 hours max - even on adderall you gotta sleep sometime)
const DAILY_CAP = 14;
let cappedTotalHours = 0;
Object.keys(dailyStats).forEach(date => {
dailyStats[date].hours = Math.min(dailyStats[date].hours, DAILY_CAP);
cappedTotalHours += dailyStats[date].hours;
});
return { dailyStats, totalHours: cappedTotalHours };
}
// Main function
function main() {
const [year, month, day] = START_DATE.split('-');
const displayDate = `${month}-${day}-${year}`;
console.log(`\n๐Ÿ“Š Estimating Git Hours Since ${displayDate}`);
console.log(` (Algorithm: Session-based tracking + Author grouping)\n`);
console.log('='.repeat(80));
const commits = getCommits();
if (commits.length === 0) {
console.log('No commits found since', displayDate);
return;
}
const { dailyStats, totalHours } = calculateHours(commits);
const dayKeys = Object.keys(dailyStats).sort();
const totalDays = dayKeys.length;
console.log('\nDaily Breakdown:\n');
console.log('Date | Commits | Est. Hours | Work Window');
console.log('-'.repeat(80));
dayKeys.forEach(date => {
const stats = dailyStats[date];
const firstStr = stats.first.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const lastStr = stats.last.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
console.log(
`${date} | ${String(stats.commits).padStart(7)} | ${String(stats.hours.toFixed(2)).padStart(10)} | ${firstStr} - ${lastStr}`
);
});
console.log('-'.repeat(80));
console.log(`\n๐Ÿ“ˆ Summary:`);
console.log(` Days Active: ${totalDays}`);
console.log(` Total Commits: ${commits.length}`);
console.log(` Total Est. Hours: ${totalHours.toFixed(2)}`);
if (totalDays > 0) {
console.log(` Avg Hours/Day: ${(totalHours / totalDays).toFixed(2)}`);
}
// Generate timesheet data
console.log('\n' + '='.repeat(80));
console.log('\n๐Ÿ“‹ TIMESHEET DATA (for copy/paste)\n');
console.log('-'.repeat(80));
dayKeys.forEach(date => {
const stats = dailyStats[date];
// Group similar commit messages
const grouped = {};
stats.entries.forEach(entry => {
// Extract meaningful category from commit message
// Look for patterns like "feat:", "fix:", "refactor:", etc.
let category = 'Development';
const prefixMatch = entry.message.match(/^(feat|fix|refactor|style|docs|test|chore|perf|build|ci)(\(.+?\))?:/i);
if (prefixMatch) {
const type = prefixMatch[1].toLowerCase();
const typeMap = {
'feat': 'Feature Development',
'fix': 'Bug Fixes',
'refactor': 'Code Refactoring',
'style': 'UI/Styling',
'docs': 'Documentation',
'test': 'Testing',
'chore': 'Maintenance',
'perf': 'Performance',
'build': 'Build/Deploy',
'ci': 'CI/CD'
};
category = typeMap[type] || 'Development';
}
if (!grouped[category]) {
grouped[category] = { hours: 0, messages: [] };
}
grouped[category].hours += entry.hours;
grouped[category].messages.push(entry.message);
});
console.log(`\n๐Ÿ“… ${date} (${stats.hours.toFixed(2)} hours)`);
Object.keys(grouped).forEach(category => {
const g = grouped[category];
console.log(` ${category}: ${g.hours.toFixed(2)}h`);
// Show unique messages (dedupe)
const uniqueMessages = [...new Set(g.messages)];
uniqueMessages.slice(0, 5).forEach(msg => {
const truncated = msg.length > 60 ? msg.substring(0, 60) + '...' : msg;
console.log(` - ${truncated}`);
});
if (uniqueMessages.length > 5) {
console.log(` ... and ${uniqueMessages.length - 5} more`);
}
});
});
// Export JSON for programmatic use
const timesheetData = dayKeys.map(date => {
const stats = dailyStats[date];
return {
date,
totalHours: Math.round(stats.hours * 100) / 100,
commits: stats.commits,
workWindow: {
start: stats.first.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
end: stats.last.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
},
entries: stats.entries.map(e => ({
time: e.time,
hours: Math.round(e.hours * 100) / 100,
lines: e.lines,
message: e.message,
hash: e.hash
}))
};
});
// Write JSON file
const jsonPath = `timesheet-${START_DATE}-to-${new Date().toLocaleDateString('en-CA')}.json`;
fs.writeFileSync(jsonPath, JSON.stringify(timesheetData, null, 2));
console.log('\n' + '='.repeat(80));
console.log(`\n๐Ÿ’พ JSON exported to: ${jsonPath}`);
console.log(' Use this file to generate invoices or import into time tracking tools.\n');
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment