yday
Version:
Git retrospective analysis tool - smart views of recent development work
295 lines (248 loc) • 8.68 kB
JavaScript
/**
* Git Commit Analysis Logic
*
* Step 2 of clean timeline architecture.
* Analyzes git-standup output and filters commits to exact timespan.
*/
const { spawn } = require('child_process');
class CommitAnalyzer {
/**
* Analyze commits for the exact timespan (test version - takes git-standup output)
* @param {Object} timespan - Timespan object from Step 1
* @param {string} gitStandupOutput - Raw output from git-standup
* @returns {Array} Array of repo objects with filtered commits
*/
analyzeCommits(timespan, gitStandupOutput) {
const repos = this.parseGitStandupOutput(gitStandupOutput);
// Filter commits to exact timespan
const filteredRepos = repos.map(repo => {
const filteredCommits = repo.commits.filter(commit => {
return this.isCommitInTimespan(commit, timespan);
});
return {
repo: repo.repo,
commits: filteredCommits,
commitCount: filteredCommits.length
};
}).filter(repo => repo.commitCount > 0); // Only include repos with commits
return filteredRepos;
}
/**
* Analyze commits for the exact timespan (production version - runs git-standup)
* @param {string} parentDir - Parent directory to scan for repos
* @param {Object} timespan - Timespan object from Step 1
* @returns {Array} Array of repo objects with filtered commits
*/
async analyzeCommitsFromGit(parentDir, timespan) {
// Run git-standup to get commit data
const gitStandupOutput = await this.runGitStandup(parentDir, timespan);
// Use the test version to parse the output
return this.analyzeCommits(timespan, gitStandupOutput);
}
/**
* Run git-standup command to get commit data
* @param {string} parentDir - Parent directory to scan for repos
* @param {Object} timespan - Timespan object from Step 1
* @returns {string} git-standup output
*/
async runGitStandup(parentDir, timespan) {
const args = this.buildStandupArgs(timespan);
return new Promise((resolve, reject) => {
const child = spawn('git-standup', args, {
cwd: parentDir,
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(`git-standup failed with code ${code}: ${stderr}`));
return;
}
resolve(stdout);
});
child.on('error', (error) => {
reject(new Error(`Failed to spawn git-standup: ${error.message}`));
});
});
}
/**
* Build git-standup arguments from timespan
* @param {Object} timespan - Timespan object
* @returns {Array} git-standup arguments
*/
buildStandupArgs(timespan) {
// ISSUE: git-standup's -A and -B flags don't work reliably for date ranges
// SOLUTION: Use -d (days back) but filter results by timespan in analyzeCommits()
// Calculate days back from today to the start of the timespan
const now = new Date();
const daysBack = Math.ceil((now - timespan.startDate) / (1000 * 60 * 60 * 24));
// Use a reasonable maximum to avoid too much data
const maxDaysBack = Math.min(daysBack + 1, 7); // Add 1 day buffer, max 7 days
return ['-d', maxDaysBack.toString(), '-D', 'iso'];
}
/**
* Parse git-standup output into structured data
* @param {string} output - Raw git-standup output
* @returns {Array} Array of repo objects
*/
parseGitStandupOutput(output) {
const lines = output.trim().split('\n');
const repos = [];
let currentRepo = null;
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine === '') continue;
// Check if this is a repo path line (starts with / or ~ or contains workspace)
if (this.isRepoPathLine(trimmedLine)) {
// Save previous repo if it exists
if (currentRepo) {
repos.push(currentRepo);
}
// Start new repo
const repoName = this.extractRepoName(trimmedLine);
currentRepo = {
repo: repoName,
commits: []
};
continue;
}
// Check if this is a "no commits" line
if (trimmedLine.includes('No commits from') || trimmedLine.includes('during this period')) {
continue;
}
// Parse commit line
if (currentRepo && this.isCommitLine(trimmedLine)) {
const commit = this.parseCommitLine(trimmedLine);
if (commit) {
currentRepo.commits.push(commit);
}
}
}
// Don't forget the last repo
if (currentRepo) {
repos.push(currentRepo);
}
return repos;
}
/**
* Check if a line is a repository path
*/
isRepoPathLine(line) {
return line.startsWith('/') || line.startsWith('~') ||
line.includes('/workspace/') || line.includes('/repos/');
}
/**
* Extract repository name from path
*/
extractRepoName(path) {
const parts = path.split('/');
return parts[parts.length - 1] || path;
}
/**
* Check if a line is a commit line (has hash and message)
*/
isCommitLine(line) {
// Commit lines can have two formats:
// Old format: "abc123 - Message (time ago) <Author>"
// New ISO format: "abc123 - Message (2025-08-05 13:25:28 -0400) <Author>"
return /^[a-f0-9]+\s+-\s+.+\s+\(.+\)\s+<.+>/.test(line);
}
/**
* Parse a commit line into structured data
*/
parseCommitLine(line) {
// Two patterns to support:
// Old: "abc123 - Message (26 hours ago) <Author>"
// New: "abc123 - Message (2025-08-05 13:25:28 -0400) <Author>"
const match = line.match(/^([a-f0-9]+)\s+-\s+(.+?)\s+\((.+?)\)\s+<(.+?)>/);
if (!match) {
return null;
}
const [, hash, message, dateInfo, author] = match;
let authorDate;
if (dateInfo.includes('ago')) {
// Old relative format
authorDate = this.parseTimeAgo(dateInfo.trim());
} else {
// New ISO format: "2025-08-05 13:25:28 -0400"
authorDate = new Date(dateInfo.trim());
}
return {
hash,
message: message.trim(),
timeAgo: dateInfo.trim(),
author: author.trim(),
authorDate
};
}
/**
* Convert "time ago" string to actual date
* @param {string} timeAgo - e.g., "26 hours ago", "2 days ago"
* @returns {Date} Approximate commit date
*/
parseTimeAgo(timeAgo) {
const now = new Date();
const match = timeAgo.match(/^(\d+)\s+(minute|hour|day|week|month|year)s?\s+ago$/);
if (!match) {
return now; // Fallback to now if we can't parse
}
const value = parseInt(match[1]);
const unit = match[2];
switch (unit) {
case 'minute':
return new Date(now.getTime() - value * 60 * 1000);
case 'hour':
return new Date(now.getTime() - value * 60 * 60 * 1000);
case 'day':
return new Date(now.getTime() - value * 24 * 60 * 60 * 1000);
case 'week':
return new Date(now.getTime() - value * 7 * 24 * 60 * 60 * 1000);
case 'month':
return new Date(now.getTime() - value * 30 * 24 * 60 * 60 * 1000);
case 'year':
return new Date(now.getTime() - value * 365 * 24 * 60 * 60 * 1000);
default:
return now;
}
}
/**
* Check if a commit falls within the specified timespan
* @param {Object} commit - Commit object with authorDate
* @param {Object} timespan - Timespan object from Step 1
* @returns {boolean} True if commit is within timespan
*/
isCommitInTimespan(commit, timespan) {
if (!commit.authorDate) {
return false;
}
const commitDate = commit.authorDate;
// Normalize commit date to start of day for comparison
const commitDay = new Date(Date.UTC(
commitDate.getUTCFullYear(),
commitDate.getUTCMonth(),
commitDate.getUTCDate(),
0, 0, 0, 0
));
const startDay = new Date(Date.UTC(
timespan.startDate.getUTCFullYear(),
timespan.startDate.getUTCMonth(),
timespan.startDate.getUTCDate(),
0, 0, 0, 0
));
const endDay = new Date(Date.UTC(
timespan.endDate.getUTCFullYear(),
timespan.endDate.getUTCMonth(),
timespan.endDate.getUTCDate(),
0, 0, 0, 0
));
return commitDay >= startDay && commitDay <= endDay;
}
}
module.exports = CommitAnalyzer;