Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ tempfile = "3.3"
open = "3.0"
colored = "2.0"
futures = "0.3"
dialoguer = "0.10.4"
dialoguer = "0.10.4"
chrono = "0.4"
dirs = "5.0"
314 changes: 287 additions & 27 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ use colored::*;
use futures::future::join_all;
use dialoguer::Input;
use serde::{Serialize, Deserialize};
use chrono::{Local, Duration};

fn run_git_command(repo_path: &str, git_command: &str, index: Option<usize>) -> Result<String, String> {
let mut command = Command::new("sh");
command.arg("-c")
.arg(format!("cd {} && {}", repo_path, git_command));

let index_str = index.map_or("".to_string(), |i| format!("[{}] ", i + 1));
println!("{}", format!("{}Running git command: {}", index_str, git_command).cyan());

Expand Down Expand Up @@ -100,22 +101,175 @@ fn save_commit_state(data_dir: &Path, state: &CommitState) {
file.write_all(json.as_bytes()).expect("failed to write commit state file");
}

#[tokio::main]
async fn main() {
let args: Vec<String> = env::args().collect();
let repo_path = if args.len() > 1 {
args[1].clone()
/// Parse a time string in HH:MM format, returning (hour, minute).
fn parse_time(s: &str) -> Result<(u32, u32), String> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 2 {
return Err(format!("Invalid time format '{}': expected HH:MM", s));
}
let hour: u32 = parts[0].parse().map_err(|_| format!("Invalid hour in '{}'", s))?;
let minute: u32 = parts[1].parse().map_err(|_| format!("Invalid minute in '{}'", s))?;
if hour > 23 || minute > 59 {
return Err(format!("Time out of range: '{}'", s));
}
Ok((hour, minute))
}

/// Generate a macOS LaunchAgent plist for scheduled execution.
fn generate_launchagent(hour: u32, minute: u32, repo_path: &str, git_command: &str) {
let binary = env::current_exe().unwrap_or_else(|_| "summarize_recent_commit".into());
let binary_str = binary.display();

let plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.summarize-recent-commit.daily</string>

<key>ProgramArguments</key>
<array>
<string>{binary}</string>
<string>{repo}</string>
<string>{cmd}</string>
</array>

<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>{hour}</integer>
<key>Minute</key>
<integer>{minute}</integer>
</dict>

<key>StandardOutPath</key>
<string>/tmp/summarize-recent-commit.log</string>
<key>StandardErrorPath</key>
<string>/tmp/summarize-recent-commit.err</string>

<key>RunAtLoad</key>
<false/>
</dict>
</plist>"#,
binary = binary_str,
repo = repo_path,
cmd = git_command,
hour = hour,
minute = minute,
);

let plist_path = dirs::home_dir()
.map(|h| h.join("Library/LaunchAgents/com.summarize-recent-commit.daily.plist"))
.unwrap_or_else(|| "com.summarize-recent-commit.daily.plist".into());

println!("{}", "--- macOS LaunchAgent ---".cyan().bold());
println!("{}", format!("Plist path: {}", plist_path.display()).cyan());
println!();
println!("{}", plist);
println!();
println!("{}", "To install:".green().bold());
println!(" 1. Save the above to: {}", plist_path.display());
println!(" 2. launchctl load {}", plist_path.display());
println!();
println!("{}", "To uninstall:".yellow());
println!(" launchctl unload {}", plist_path.display());
}

/// Generate a crontab entry for scheduled execution.
fn generate_crontab(hour: u32, minute: u32, repo_path: &str, git_command: &str) {
let binary = env::current_exe().unwrap_or_else(|_| "summarize_recent_commit".into());
let binary_str = binary.display();

let cron_line = format!(
"{minute} {hour} * * * {binary} '{repo}' '{cmd}' >> /tmp/summarize-recent-commit.log 2>&1",
minute = minute,
hour = hour,
binary = binary_str,
repo = repo_path,
cmd = git_command,
);

println!("{}", "--- Crontab Entry ---".cyan().bold());
println!();
println!("{}", cron_line);
println!();
println!("{}", "To install:".green().bold());
println!(" crontab -e # then paste the line above");
println!();
println!("{}", "Or append directly:".green());
println!(" (crontab -l 2>/dev/null; echo '{}') | crontab -", cron_line);
}

/// Print schedule configuration for the current platform.
fn handle_schedule(hour: u32, minute: u32, repo_path: &str, git_command: &str) {
println!("{}", format!("Generating schedule config: daily at {:02}:{:02}", hour, minute).green().bold());
println!();

if cfg!(target_os = "macos") {
generate_launchagent(hour, minute, repo_path, git_command);
println!("{}", "--- Alternative: Crontab ---".cyan());
println!();
generate_crontab(hour, minute, repo_path, git_command);
} else {
Input::new().with_prompt("Enter the repository absolute path").interact_text().unwrap()
};
generate_crontab(hour, minute, repo_path, git_command);
}
}

let git_command = if args.len() > 2 {
args[2].clone()
/// Compute seconds until the next occurrence of HH:MM local time.
fn seconds_until(hour: u32, minute: u32) -> u64 {
let now = Local::now();
let today = now.date_naive()
.and_hms_opt(hour, minute, 0)
.expect("valid time");
let target = if now.naive_local() < today {
today
} else {
Input::new().with_prompt("Enter the full git command").interact_text().unwrap()
today + Duration::days(1)
};
let diff = target - now.naive_local();
diff.num_seconds().max(0) as u64
}

/// Run in foreground, waking at the scheduled time each day to execute the summarization.
async fn handle_watch(hour: u32, minute: u32, repo_path: &str, git_command: &str) {
println!("{}", format!(
"Watch mode: will run daily at {:02}:{:02}. Press Ctrl+C to stop.",
hour, minute
).green().bold());
println!("{}", format!(" repo: {}", repo_path).cyan());
println!("{}", format!(" command: {}", git_command).cyan());
println!();

loop {
let wait_secs = seconds_until(hour, minute);
let next_run = Local::now() + Duration::seconds(wait_secs as i64);
println!("{}", format!(
"Next run in {} hours {} minutes (at {})",
wait_secs / 3600,
(wait_secs % 3600) / 60,
next_run.format("%Y-%m-%d %H:%M")
).cyan());

tokio::time::sleep(std::time::Duration::from_secs(wait_secs)).await;

println!("{}", format!(
"\n=== Scheduled run at {} ===",
Local::now().format("%Y-%m-%d %H:%M:%S")
).green().bold());

// Run the same summarization logic as the normal path
run_summarize(repo_path, git_command).await;

// Sleep a brief moment to avoid re-triggering within the same minute
tokio::time::sleep(std::time::Duration::from_secs(61)).await;
}
}

match run_git_command(&repo_path, &git_command, None) {
/// Core summarization logic, extracted so both normal and watch modes can call it.
async fn run_summarize(repo_path: &str, git_command: &str) {
match run_git_command(repo_path, git_command, None) {
Ok(changes) => {
if changes.trim().is_empty() {
eprintln!("{}", "No changes found in the specified range.".yellow());
Expand All @@ -139,20 +293,36 @@ async fn main() {

let mut commit_state = load_commit_state(&data_dir);

// Save summaries in date-organized folders: data/summaries/YYYY-MM-DD/
let now = Local::now();
let date_str = now.format("%Y-%m-%d").to_string();
let time_str = now.format("%H%M%S").to_string();
let summaries_dir = current_dir.join("data").join("summaries").join(&date_str);
create_dir_all(&summaries_dir).expect("failed to create summaries directory");

let dated_filename = format!("summary-{}.md", time_str);
let dated_file_path = summaries_dir.join(&dated_filename);

// Also write to the root file for backward compatibility
let file_path = current_dir.join("git_commit_summaries.md");
let file_path_str = file_path.to_str().expect("Failed to convert file path to string");
let mut file = File::create(&file_path).expect("Failed to create file");
let mut dated_file = File::create(&dated_file_path).expect("Failed to create dated summary file");

writeln!(file, "# Git Commit Summaries\n").expect("Failed to write to file");
println!("{}", format!("Summary will be saved to: {}", dated_file_path.display()).cyan());

writeln!(file, "-----------------------------------------------------------------------").expect("Failed to write to file");
writeln!(file, "-----------------------------------------------------------------------").expect("Failed to write to file");
writeln!(file, " ").expect("Failed to write to file");
writeln!(file, "PRESS CMD+SHIFT+V TO VIEW IN MARKDOWN").expect("Failed to write to file");
writeln!(file, " ").expect("Failed to write to file");
writeln!(file, "_______________________________________________________________________").expect("Failed to write to file");
writeln!(file, "-----------------------------------------------------------------------").expect("Failed to write to file");
writeln!(file, "Total number of commits: {}\n", commit_hashes.len()).expect("Failed to write to file");
// Write header to both files
for f in [&mut file, &mut dated_file] {
writeln!(f, "# Git Commit Summaries — {}\n", date_str).expect("Failed to write to file");
writeln!(f, "-----------------------------------------------------------------------").expect("Failed to write to file");
writeln!(f, "-----------------------------------------------------------------------").expect("Failed to write to file");
writeln!(f, " ").expect("Failed to write to file");
writeln!(f, "PRESS CMD+SHIFT+V TO VIEW IN MARKDOWN").expect("Failed to write to file");
writeln!(f, " ").expect("Failed to write to file");
writeln!(f, "_______________________________________________________________________").expect("Failed to write to file");
writeln!(f, "-----------------------------------------------------------------------").expect("Failed to write to file");
writeln!(f, "Total number of commits: {}\n", commit_hashes.len()).expect("Failed to write to file");
}

let mut tasks = Vec::new();

Expand Down Expand Up @@ -195,10 +365,12 @@ async fn main() {
for result in results {
if let Ok(Some((index, commit_hash, summary))) = result {
println!("{}", format!("processing commit {} of {}: {}", index + 1, commit_hashes.len(), commit_hash).cyan());
writeln!(file, "<details>\n<summary>summary for commit {} ({})</summary>\n\n{}\n</details>\n", index + 1, commit_hash, summary)
.expect("Failed to write to file");
writeln!(file, "------------------------------------------------------------------------\n")
.expect("Failed to write to file");
for f in [&mut file, &mut dated_file] {
writeln!(f, "<details>\n<summary>summary for commit {} ({})</summary>\n\n{}\n</details>\n", index + 1, commit_hash, summary)
.expect("Failed to write to file");
writeln!(f, "------------------------------------------------------------------------\n")
.expect("Failed to write to file");
}
combined_changes.push(summary);
commit_state.processed_commits.push(commit_hash);
}
Expand All @@ -209,16 +381,104 @@ async fn main() {
let overall_summary = summarize_changes(&combined_changes.join("\n")).await
.unwrap_or_else(|e| format!("Error generating overall summary: {}", e));

writeln!(file, "# Overall Summary of Changes\n\n{}", overall_summary)
.expect("Failed to write overall summary to file");
for f in [&mut file, &mut dated_file] {
writeln!(f, "# Overall Summary of Changes\n\n{}", overall_summary)
.expect("Failed to write overall summary to file");
}

open_md_in_preview(file_path_str);

println!("{}", "Job finished successfully!".green());
println!("{}", format!("Summary file created at: {}", file_path_str).green());
println!("{}", format!("Dated copy saved to: {}", dated_file_path.display()).green());

save_commit_state(&data_dir, &commit_state);
}
Err(e) => eprintln!("{}", format!("Error running git command: {}", e).red()),
}
}

fn print_usage() {
let binary = env::args().next().unwrap_or_else(|| "summarize_recent_commit".to_string());
println!("{}", "Usage:".green().bold());
println!(" {} <repo_path> <git_command>", binary);
println!(" {} --schedule [--time HH:MM] <repo_path> <git_command>", binary);
println!(" {} --watch [--time HH:MM] <repo_path> <git_command>", binary);
println!();
println!("{}", "Options:".green().bold());
println!(" --schedule Generate platform scheduling config (LaunchAgent/crontab)");
println!(" --watch Run in foreground, executing daily at the scheduled time");
println!(" --time Set schedule time (default: 09:00)");
println!(" --help Show this help message");
println!();
println!("{}", "Examples:".green().bold());
println!(" {} /path/to/repo 'git log --since=\"24 hours ago\"'", binary);
println!(" {} --schedule /path/to/repo 'git log --since=\"24 hours ago\"'", binary);
println!(" {} --watch --time 08:30 /path/to/repo 'git log --since=\"24 hours ago\"'", binary);
}

#[tokio::main]
async fn main() {
let args: Vec<String> = env::args().skip(1).collect();

// Parse flags: --schedule, --watch, --time HH:MM, --help
let mut schedule = false;
let mut watch = false;
let mut time_str = "09:00".to_string();
let mut positional: Vec<String> = Vec::new();

let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--schedule" => schedule = true,
"--watch" => watch = true,
"--time" => {
i += 1;
if i >= args.len() {
eprintln!("{}", "Error: --time requires a value (e.g., --time 09:00)".red());
std::process::exit(1);
}
time_str = args[i].clone();
}
"--help" | "-h" => {
print_usage();
return;
}
_ => positional.push(args[i].clone()),
}
i += 1;
}

// Resolve repo_path and git_command from positional args or interactive prompts
let repo_path = if !positional.is_empty() {
positional[0].clone()
} else {
Input::new().with_prompt("Enter the repository absolute path").interact_text().unwrap()
};

let git_command = if positional.len() > 1 {
positional[1].clone()
} else {
Input::new().with_prompt("Enter the full git command").interact_text().unwrap()
};

if schedule || watch {
let (hour, minute) = match parse_time(&time_str) {
Ok(t) => t,
Err(e) => {
eprintln!("{}", format!("Error: {}", e).red());
std::process::exit(1);
}
};

if schedule {
handle_schedule(hour, minute, &repo_path, &git_command);
}
if watch {
handle_watch(hour, minute, &repo_path, &git_command).await;
}
} else {
// Normal one-shot mode (original behavior)
run_summarize(&repo_path, &git_command).await;
}
}