use std::fs;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use anstyle::Reset;
use color_print::cformat;
use minijinja::Environment;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use worktrunk::cache;
use worktrunk::git::Repository;
use worktrunk::path::sanitize_for_filename;
use worktrunk::styling::INFO_SYMBOL;
use worktrunk::sync::Semaphore;
use worktrunk::utils::epoch_now;
use crate::llm::{execute_llm_command, prepare_diff};
static LLM_SEMAPHORE: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(8));
const KIND: &str = "summary";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct CachedSummary {
pub summary: String,
pub branch: String,
#[serde(default)]
pub generated_at: u64,
}
pub(crate) struct CombinedDiff {
pub diff: String,
pub stat: String,
}
const SUMMARY_TEMPLATE: &str = r#"<task>Write a summary of this branch's changes as a commit message.</task>
<format>
- Subject line under 50 chars, imperative mood ("Add feature" not "Adds feature")
- Blank line, then a body paragraph or bullet list explaining the key changes
- Output only the message — no quotes, code blocks, or labels
</format>
<diffstat>
{{ git_diff_stat }}
</diffstat>
<diff>
{{ git_diff }}
</diff>
"#;
impl CachedSummary {
pub(crate) fn cache_root(repo: &Repository) -> PathBuf {
cache::cache_dir(repo, KIND)
}
fn branch_dir(repo: &Repository, branch: &str) -> PathBuf {
Self::cache_root(repo).join(sanitize_for_filename(branch))
}
pub(crate) fn cache_file(repo: &Repository, branch: &str, hash: &str) -> PathBuf {
Self::branch_dir(repo, branch).join(format!("{hash}.json"))
}
pub(crate) fn read(repo: &Repository, branch: &str, hash: &str) -> Option<Self> {
cache::read_json(&Self::cache_file(repo, branch, hash))
}
pub(crate) fn write(&self, repo: &Repository, hash: &str) {
cache::write_json(&Self::cache_file(repo, &self.branch, hash), self);
cache::sweep_lru(&Self::branch_dir(repo, &self.branch), 1);
}
pub(crate) fn list_all(repo: &Repository) -> Vec<Self> {
let root = Self::cache_root(repo);
let Ok(branch_dirs) = fs::read_dir(&root) else {
return Vec::new();
};
let mut out: Vec<Self> = branch_dirs
.filter_map(|e| e.ok())
.filter_map(|entry| {
if !entry.file_type().ok()?.is_dir() {
return None;
}
freshest_entry(&entry.path())
})
.collect();
out.sort_by(|a, b| {
b.generated_at
.cmp(&a.generated_at)
.then_with(|| a.branch.cmp(&b.branch))
});
out
}
pub(crate) fn clear_all(repo: &Repository) -> anyhow::Result<usize> {
let root = Self::cache_root(repo);
let branch_dirs = match fs::read_dir(&root) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(e) => {
return Err(
anyhow::Error::new(e).context(format!("failed to read {}", root.display()))
);
}
};
let mut cleared = 0;
for entry in branch_dirs.flatten() {
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
let dir = entry.path();
cleared += cache::clear_json_files(&dir)?;
let _ = fs::remove_dir(&dir);
}
Ok(cleared)
}
}
fn freshest_entry(dir: &Path) -> Option<CachedSummary> {
fs::read_dir(dir)
.ok()?
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_str().is_some_and(|s| s.ends_with(".json")))
.filter_map(|e| cache::read_json::<CachedSummary>(&e.path()))
.max_by_key(|s| s.generated_at)
}
pub(crate) fn hash_diff(diff: &str) -> String {
use std::fmt::Write as _;
let mut hasher = Sha256::new();
hasher.update(diff.as_bytes());
let mut out = String::with_capacity(16);
for b in hasher.finalize().iter().take(8) {
let _ = write!(out, "{b:02x}");
}
out
}
pub(crate) fn compute_combined_diff(
branch: &str,
head: &str,
worktree_path: Option<&Path>,
repo: &Repository,
) -> Option<CombinedDiff> {
let default_branch = repo.default_branch();
let mut diff = String::new();
let mut stat = String::new();
if let Some(ref default_branch) = default_branch {
let is_default_branch = branch == *default_branch;
if !is_default_branch {
let merge_base = format!("{}...{}", default_branch, head);
if let Ok(branch_stat) =
repo.run_command(&["diff", "--stat", "--end-of-options", &merge_base])
{
stat.push_str(&branch_stat);
}
if let Ok(branch_diff) = repo.run_command(&["diff", "--end-of-options", &merge_base]) {
diff.push_str(&branch_diff);
}
}
}
if let Some(wt_path) = worktree_path {
let path = wt_path.display().to_string();
if let Ok(wt_stat) = repo.run_command(&["-C", &path, "diff", "HEAD", "--stat"])
&& !wt_stat.trim().is_empty()
{
stat.push_str(&wt_stat);
}
if let Ok(wt_diff) = repo.run_command(&["-C", &path, "diff", "HEAD"])
&& !wt_diff.trim().is_empty()
{
diff.push_str(&wt_diff);
}
}
if diff.trim().is_empty() {
return None;
}
Some(CombinedDiff { diff, stat })
}
pub(crate) fn render_prompt(diff: &str, stat: &str) -> anyhow::Result<String> {
let env = Environment::new();
let tmpl = env.template_from_str(SUMMARY_TEMPLATE)?;
let rendered = tmpl.render(minijinja::context! {
git_diff => diff,
git_diff_stat => stat,
})?;
Ok(rendered)
}
pub(crate) fn generate_summary_core(
branch: &str,
head: &str,
worktree_path: Option<&Path>,
llm_command: &str,
repo: &Repository,
) -> anyhow::Result<Option<String>> {
let Some(combined) = compute_combined_diff(branch, head, worktree_path, repo) else {
return Ok(None);
};
let diff_hash = hash_diff(&combined.diff);
if let Some(cached) = CachedSummary::read(repo, branch, &diff_hash) {
return Ok(Some(cached.summary));
}
let prepared = prepare_diff(combined.diff, combined.stat);
let prompt = render_prompt(&prepared.diff, &prepared.stat)?;
let _permit = LLM_SEMAPHORE.acquire();
let summary = execute_llm_command(llm_command, &prompt)?;
let cached = CachedSummary {
summary: summary.clone(),
branch: branch.to_string(),
generated_at: epoch_now(),
};
cached.write(repo, &diff_hash);
Ok(Some(summary))
}
#[cfg_attr(windows, allow(dead_code))] pub(crate) fn generate_summary(
branch: &str,
head: &str,
worktree_path: Option<&Path>,
llm_command: &str,
repo: &Repository,
) -> String {
match generate_summary_core(branch, head, worktree_path, llm_command, repo) {
Ok(Some(summary)) => summary,
Ok(None) => {
let reset = Reset;
cformat!("{INFO_SYMBOL}{reset} <bold>{branch}</>{reset} has no changes to summarize\n")
}
Err(e) => format!("Error: {e:#}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_prompt_includes_diff_and_stat() {
let result = render_prompt("diff content here", "stat content here").unwrap();
insta::assert_snapshot!(result, @r#"
<task>Write a summary of this branch's changes as a commit message.</task>
<format>
- Subject line under 50 chars, imperative mood ("Add feature" not "Adds feature")
- Blank line, then a body paragraph or bullet list explaining the key changes
- Output only the message — no quotes, code blocks, or labels
</format>
<diffstat>
stat content here
</diffstat>
<diff>
diff content here
</diff>
"#);
}
#[test]
fn test_hash_diff_is_sha256_prefix() {
assert_eq!(hash_diff("hello world"), "b94d27b9934d3e08");
assert_eq!(hash_diff("hello world"), hash_diff("hello world"));
assert_ne!(hash_diff("hello"), hash_diff("world"));
}
}