Created
December 19, 2025 21:30
-
-
Save dashw00d/564eb26484cf2bdd9c0dd18d600f625b to your computer and use it in GitHub Desktop.
Estimate Total Work Hours using Git - Adjusted for AI Use
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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