yday
Version:
Git retrospective analysis tool - smart views of recent development work
353 lines (297 loc) • 11.5 kB
JavaScript
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs').promises;
class GitAnalysis {
async findRepositories(parentDir) {
const repos = [];
try {
const entries = await fs.readdir(parentDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const repoPath = path.join(parentDir, entry.name);
const isGitRepo = await this.isGitRepository(repoPath);
if (isGitRepo) {
repos.push({
name: entry.name,
path: repoPath
});
}
}
}
} catch (error) {
throw new Error(`Failed to scan directory ${parentDir}: ${error.message}`);
}
return repos.sort((a, b) => a.name.localeCompare(b.name));
}
async isGitRepository(dirPath) {
try {
const gitDir = path.join(dirPath, '.git');
const stat = await fs.stat(gitDir);
return stat.isDirectory() || stat.isFile(); // Handle both .git directories and worktree .git files
} catch {
return false;
}
}
async getCommits(parentDir, timeConfig) {
// Change to parent directory for git-standup
const originalCwd = process.cwd();
process.chdir(parentDir);
try {
// Use git-standup for commit collection
const standupArgs = this.buildStandupArgs(parentDir, timeConfig);
const standupOutput = await this.runGitStandup(standupArgs);
return this.parseStandupOutput(standupOutput, parentDir, timeConfig);
} finally {
// Always restore original directory
process.chdir(originalCwd);
}
}
buildStandupArgs(parentDir, timeConfig) {
// Handle null/undefined configs
if (!timeConfig || !timeConfig.type) {
return ['-d', '1']; // Default to yesterday
}
// If this is a git-standup style config, use the prebuilt args
if (timeConfig.type === 'git-standup' && timeConfig.standupArgs) {
return timeConfig.standupArgs;
}
const args = [];
// Always use local date format for more precise parsing
args.push('-D', 'local');
if (timeConfig.type === 'today') {
// Show only today
args.push('-d', '0', '-u', '0');
} else if (timeConfig.type === 'timeline-week') {
// For timeline week view, show since Monday of that week
args.push('-d', timeConfig.days.toString());
} else if (timeConfig.type === 'on-day' && timeConfig.singleDay) {
// Show only that specific day
args.push('-d', timeConfig.days.toString(), '-u', timeConfig.days.toString());
} else if (timeConfig.type === 'smart-yesterday') {
// For smart yesterday, show since N days ago (not limited by -u)
args.push('-d', timeConfig.days.toString());
} else if (timeConfig.days === 1) {
// Show only yesterday - use -d 1 -u 0 to get exactly yesterday
args.push('-d', '1', '-u', '0');
} else if (timeConfig.type && timeConfig.type.startsWith('last-') && timeConfig.startDate && timeConfig.endDate && timeConfig.startDate.getTime() === timeConfig.endDate.getTime()) {
// Single day query (startDate === endDate) - show only that day
// Use -d X to get commits since X days ago, then filter in parseStandupOutput
args.push('-d', timeConfig.days.toString());
} else {
// For multiple days, -d shows "since N days ago"
const days = timeConfig.days || 1;
args.push('-d', days.toString());
}
return args;
}
async runGitStandup(args) {
return new Promise((resolve, reject) => {
// Access verbose flag from parent module
const verbose = require('./index').verbose;
if (verbose) {
console.log(require('chalk').gray(`Running: git-standup ${args.join(' ')}`));
console.log(require('chalk').gray(`Working directory: ${process.cwd()}`));
}
const child = spawn('git-standup', args, {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: process.cwd()
});
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 (verbose) {
console.log(require('chalk').gray(`git-standup exit code: ${code}`));
if (stdout) {
console.log(require('chalk').gray(`git-standup output (${stdout.length} chars):`));
console.log(require('chalk').gray(stdout.substring(0, 500) + (stdout.length > 500 ? '...' : '')));
}
}
if (code === 0) {
resolve(stdout);
} else {
reject(new Error(`git-standup failed: ${stderr}`));
}
});
child.on('error', (error) => {
if (error.code === 'ENOENT') {
reject(new Error('git-standup not found. Install with: npm install -g git-standup'));
} else {
reject(error);
}
});
});
}
parseStandupOutput(standupOutput, parentDir, timeConfig) {
const repoCommits = new Map();
let currentRepo = '';
let currentCommits = [];
const lines = standupOutput.split('\n');
// Debug: log the first few lines if verbose is enabled
const verbose = require('./index').verbose;
if (verbose) {
console.log(require('chalk').gray('First 10 lines of git-standup output:'));
lines.slice(0, 10).forEach((line, i) => {
console.log(require('chalk').gray(`${i}: ${line}`));
});
}
for (const line of lines) {
// Skip empty lines
if (!line.trim()) continue;
// Check if this is a repository header (starts with /, contains git repos)
if (line.match(/^\/.*\/([^/]+)$/)) {
// Save previous repo data if we have it
if (currentRepo && currentCommits.length > 0) {
const repoName = path.basename(currentRepo);
repoCommits.set(repoName, [...currentCommits]);
}
// Extract repo name from path
currentRepo = line;
currentCommits = [];
continue;
}
// Skip "No commits" lines
if (line.match(/^No commits from .* during this period\.$/)) {
continue;
}
// Check if this line contains commit info (starts with hash and has " - ")
if (line.match(/^[a-f0-9]+.*\s-\s/)) {
// Extract commit message and timestamp - handle both relative and local date formats
let commitMatch = line.match(/^[a-f0-9]+.*\s-\s(.+)\s\(([^)]*\sago)\)\s<[^>]*>.*$/);
// Try local date format if relative format doesn't match
if (!commitMatch) {
commitMatch = line.match(/^[a-f0-9]+.*\s-\s(.+)\s\(([^)]+)\)\s<[^>]*>.*$/);
}
if (commitMatch) {
let commitMessage = commitMatch[1].trim();
const timeString = commitMatch[2]; // Could be "2 minutes ago" or "Mon Jul 22 14:30:00 2025"
if (commitMessage) {
// Parse the time string to get a date
const commitDate = this.parseTimeString(timeString);
currentCommits.push({
message: commitMessage,
date: commitDate,
timeAgo: timeString
});
}
}
}
}
// Don't forget the last repo (only if it has commits)
if (currentRepo && currentCommits.length > 0) {
const repoName = path.basename(currentRepo);
repoCommits.set(repoName, [...currentCommits]);
}
// Convert to the format expected by semantic analysis
const result = [];
for (const [repoName, commits] of repoCommits) {
let filteredCommits = commits;
// For single-day queries (but not timeline-week), filter commits to only include those from the target day
if (timeConfig && timeConfig.type && timeConfig.type.startsWith('last-') &&
timeConfig.startDate && timeConfig.endDate &&
timeConfig.startDate.getTime() === timeConfig.endDate.getTime()) {
const targetDate = new Date(timeConfig.startDate);
targetDate.setHours(0, 0, 0, 0);
filteredCommits = commits.filter(commit => {
if (commit.date) {
const commitDate = new Date(commit.date);
commitDate.setHours(0, 0, 0, 0);
return commitDate.getTime() === targetDate.getTime();
}
return false;
});
}
// Only include repos that have commits (after filtering)
if (filteredCommits.length > 0) {
result.push({
name: repoName,
path: currentRepo, // This should be calculated properly, but for now...
commits: filteredCommits,
commitCount: filteredCommits.length
});
}
}
if (verbose) {
console.log(require('chalk').gray(`Parsed ${result.length} repos with commits`));
}
return {
parentDir,
repos: result
};
}
// Parse time strings from git-standup (both relative and local date formats)
parseTimeString(timeString) {
// First try to parse as relative time ("X days ago")
const relativeMatch = timeString.match(/^(\d+)\s+(minute|hour|day|week|month|year)s?\s+ago$/);
if (relativeMatch) {
return this.parseTimeAgo(timeString);
}
// Try to parse as local date format
const localDate = new Date(timeString);
if (!isNaN(localDate.getTime())) {
return localDate;
}
// Fallback to current time
return new Date();
}
// Parse "time ago" strings from git-standup into actual dates
parseTimeAgo(timeAgo) {
const now = new Date();
// Parse patterns like "2 minutes ago", "3 hours ago", "1 day ago"
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;
}
}
// Helper method to get remote repository URL
async getRemoteUrl(repoPath) {
try {
return new Promise((resolve, reject) => {
const child = spawn('git', ['remote', 'get-url', 'origin'], {
cwd: repoPath,
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve(stdout.trim());
} else {
resolve(null); // No remote or error
}
});
child.on('error', () => {
resolve(null);
});
});
} catch {
return null;
}
}
}
module.exports = GitAnalysis;