diff --git a/crates/pu-cli/src/commands/diff.rs b/crates/pu-cli/src/commands/diff.rs new file mode 100644 index 0000000..91dcbf1 --- /dev/null +++ b/crates/pu-cli/src/commands/diff.rs @@ -0,0 +1,29 @@ +use crate::client; +use crate::daemon_ctrl; +use crate::error::CliError; +use crate::output; +use pu_core::protocol::Request; +use std::path::Path; + +pub async fn run( + socket: &Path, + worktree: Option, + stat: bool, + json: bool, +) -> Result<(), CliError> { + daemon_ctrl::ensure_daemon(socket).await?; + + let project_root = crate::commands::cwd_string()?; + let resp = client::send_request( + socket, + &Request::Diff { + project_root, + worktree_id: worktree, + stat, + }, + ) + .await?; + let resp = output::check_response(resp, json)?; + output::print_response(&resp, json); + Ok(()) +} diff --git a/crates/pu-cli/src/commands/mod.rs b/crates/pu-cli/src/commands/mod.rs index 9d46413..cb25e00 100644 --- a/crates/pu-cli/src/commands/mod.rs +++ b/crates/pu-cli/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod agent_def; pub mod attach; pub mod clean; +pub mod diff; pub mod grid; pub mod health; pub mod init; diff --git a/crates/pu-cli/src/main.rs b/crates/pu-cli/src/main.rs index aab3112..f748b84 100644 --- a/crates/pu-cli/src/main.rs +++ b/crates/pu-cli/src/main.rs @@ -144,6 +144,18 @@ enum Commands { #[command(subcommand)] action: ScheduleAction, }, + /// Show git diffs across agent worktrees + Diff { + /// Diff a specific worktree + #[arg(long)] + worktree: Option, + /// Show file summary instead of full diff + #[arg(long)] + stat: bool, + /// Output as JSON + #[arg(long)] + json: bool, + }, /// Remove worktrees, their agents, and branches Clean { /// Remove a specific worktree @@ -588,6 +600,11 @@ async fn main() { json, } => commands::send::run(&socket, &agent_id, text, no_enter, keys, json).await, Commands::Grid { action } => commands::grid::run(&socket, action).await, + Commands::Diff { + worktree, + stat, + json, + } => commands::diff::run(&socket, worktree, stat, json).await, Commands::Clean { worktree, all, diff --git a/crates/pu-cli/src/output.rs b/crates/pu-cli/src/output.rs index 8cbe6e5..f542bab 100644 --- a/crates/pu-cli/src/output.rs +++ b/crates/pu-cli/src/output.rs @@ -388,6 +388,39 @@ pub fn print_response(response: &Response, json_mode: bool) { } } } + Response::DiffResult { diffs } => { + if diffs.is_empty() { + println!("No worktree diffs"); + return; + } + for (i, d) in diffs.iter().enumerate() { + if i > 0 { + println!(); + } + let base = d.base_branch.as_deref().unwrap_or("(unknown)"); + println!( + "{} {} ({} -> {})", + "Worktree".bold(), + d.worktree_name.bold(), + base.dimmed(), + d.branch.green() + ); + if let Some(ref err) = d.error { + println!(" {}: {}", "error".red().bold(), err); + } else if d.files_changed == 0 && d.diff_output.trim().is_empty() { + println!(" {}", "No changes".dimmed()); + } else { + println!( + " {} file(s) changed, {} insertion(s), {} deletion(s)", + d.files_changed, d.insertions, d.deletions + ); + if !d.diff_output.trim().is_empty() { + println!(); + print!("{}", d.diff_output); + } + } + } + } Response::ScheduleList { schedules } => { if schedules.is_empty() { println!("No schedules"); @@ -796,6 +829,50 @@ mod tests { assert!(s.contains("waiting")); } + // --- diff output --- + + #[test] + fn given_diff_result_should_not_panic() { + let resp = Response::DiffResult { + diffs: vec![pu_core::protocol::WorktreeDiffEntry { + worktree_id: "wt-1".into(), + worktree_name: "fix-bug".into(), + branch: "pu/fix-bug".into(), + base_branch: Some("main".into()), + diff_output: "+line\n".into(), + files_changed: 1, + insertions: 1, + deletions: 0, + error: None, + }], + }; + print_response(&resp, false); + } + + #[test] + fn given_empty_diff_result_should_not_panic() { + let resp = Response::DiffResult { diffs: vec![] }; + print_response(&resp, false); + } + + #[test] + fn given_diff_result_no_changes_should_not_panic() { + let resp = Response::DiffResult { + diffs: vec![pu_core::protocol::WorktreeDiffEntry { + worktree_id: "wt-1".into(), + worktree_name: "clean".into(), + branch: "pu/clean".into(), + base_branch: None, + diff_output: String::new(), + files_changed: 0, + insertions: 0, + deletions: 0, + error: None, + }], + }; + print_response(&resp, false); + } + // --- schedule output --- #[test] diff --git a/crates/pu-core/src/protocol.rs b/crates/pu-core/src/protocol.rs index 3f300a5..da3ff5e 100644 --- a/crates/pu-core/src/protocol.rs +++ b/crates/pu-core/src/protocol.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::types::{AgentStatus, WorktreeEntry}; -pub const PROTOCOL_VERSION: u32 = 1; +pub const PROTOCOL_VERSION: u32 = 2; /// Serde helper: encode Vec as hex in JSON for binary PTY data. mod hex_bytes { @@ -235,6 +235,13 @@ pub enum Request { name: String, }, Shutdown, + Diff { + project_root: String, + #[serde(default)] + worktree_id: Option, + #[serde(default)] + stat: bool, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -494,6 +501,9 @@ pub enum Response { scope: String, created_at: DateTime, }, + DiffResult { + diffs: Vec, + }, Ok, ShuttingDown, Error { @@ -522,6 +532,21 @@ pub struct AgentStatusReport { pub suspended: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct WorktreeDiffEntry { + pub worktree_id: String, + pub worktree_name: String, + pub branch: String, + pub base_branch: Option, + pub diff_output: String, + pub files_changed: usize, + pub insertions: usize, + pub deletions: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + #[cfg(test)] mod tests { use super::*; @@ -661,6 +686,84 @@ mod tests { assert!(matches!(parsed, Request::Shutdown)); } + #[test] + fn given_diff_request_should_round_trip() { + let req = Request::Diff { + project_root: "/test".into(), + worktree_id: Some("wt-abc".into()), + stat: true, + }; + let json = serde_json::to_string(&req).unwrap(); + let parsed: Request = serde_json::from_str(&json).unwrap(); + match parsed { + Request::Diff { + project_root, + worktree_id, + stat, + } => { + assert_eq!(project_root, "/test"); + assert_eq!(worktree_id.unwrap(), "wt-abc"); + assert!(stat); + } + _ => panic!("expected Diff"), + } + } + + #[test] + fn given_diff_request_with_defaults_should_round_trip() { + let json = r#"{"type":"diff","project_root":"/test"}"#; + let req: Request = serde_json::from_str(json).unwrap(); + match req { + Request::Diff { + worktree_id, stat, .. + } => { + assert!(worktree_id.is_none()); + assert!(!stat); + } + _ => panic!("expected Diff"), + } + } + + #[test] + fn given_diff_result_should_round_trip() { + let resp = Response::DiffResult { + diffs: vec![WorktreeDiffEntry { + worktree_id: "wt-1".into(), + worktree_name: "fix-bug".into(), + branch: "pu/fix-bug".into(), + base_branch: Some("main".into()), + diff_output: "+added line\n-removed line\n".into(), + files_changed: 2, + insertions: 5, + deletions: 3, + error: None, + }], + }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: Response = serde_json::from_str(&json).unwrap(); + match parsed { + Response::DiffResult { diffs } => { + assert_eq!(diffs.len(), 1); + assert_eq!(diffs[0].worktree_id, "wt-1"); + assert_eq!(diffs[0].files_changed, 2); + assert_eq!(diffs[0].insertions, 5); + assert_eq!(diffs[0].deletions, 3); + } + _ => panic!("expected DiffResult"), + } + } + + #[test] + fn given_empty_diff_result_should_round_trip() { + let resp = Response::DiffResult { diffs: vec![] }; + let json = serde_json::to_string(&resp).unwrap(); + let parsed: Response = serde_json::from_str(&json).unwrap(); + match parsed { + Response::DiffResult { diffs } => assert!(diffs.is_empty()), + _ => panic!("expected DiffResult"), + } + } + // --- Response round-trips --- #[test] @@ -818,8 +921,8 @@ mod tests { } #[test] - fn given_protocol_version_should_be_1() { - assert_eq!(PROTOCOL_VERSION, 1); + fn given_protocol_version_should_be_current() { + assert_eq!(PROTOCOL_VERSION, 2); } // --- GridCommand round-trips --- diff --git a/crates/pu-engine/src/engine.rs b/crates/pu-engine/src/engine.rs index d5c3197..9872e97 100644 --- a/crates/pu-engine/src/engine.rs +++ b/crates/pu-engine/src/engine.rs @@ -167,7 +167,8 @@ impl Engine { | Request::ListSchedules { project_root } | Request::SaveSchedule { project_root, .. } | Request::EnableSchedule { project_root, .. } - | Request::DisableSchedule { project_root, .. } => { + | Request::DisableSchedule { project_root, .. } + | Request::Diff { project_root, .. } => { self.register_project(project_root); } _ => {} @@ -393,6 +394,14 @@ impl Engine { Request::DisableSchedule { project_root, name } => { self.handle_disable_schedule(&project_root, &name).await } + Request::Diff { + project_root, + worktree_id, + stat, + } => { + self.handle_diff(&project_root, worktree_id.as_deref(), stat) + .await + } } } @@ -632,7 +641,12 @@ impl Engine { // Root agents and existing-worktree agents get auto-generated names name.unwrap_or_else(pu_core::id::root_agent_name) }; - let base_branch = base.unwrap_or_else(|| "HEAD".into()); + let base_branch = match base { + Some(b) => b, + None => git::resolve_base_ref(root_path, "HEAD") + .await + .unwrap_or_else(|_| "HEAD".into()), + }; // Build command with prompt let (command, cmd_args) = match Self::parse_agent_command(&agent_cfg, agent_type) { @@ -936,7 +950,12 @@ impl Engine { }; } - let base_branch = base.unwrap_or_else(|| "HEAD".into()); + let base_branch = match base { + Some(b) => b, + None => git::resolve_base_ref(root_path, "HEAD") + .await + .unwrap_or_else(|_| "HEAD".into()), + }; let wt_id = pu_core::id::worktree_id(); let wt_path = paths::worktree_path(root_path, &wt_id); let branch = format!("pu/{worktree_name}"); @@ -2928,6 +2947,96 @@ impl Engine { )), } } + + async fn handle_diff( + &self, + project_root: &str, + worktree_id: Option<&str>, + stat: bool, + ) -> Response { + let m = match self.read_manifest_async(project_root).await { + Ok(m) => m, + Err(e) => return Self::error_response(&e), + }; + + let worktrees: Vec = if let Some(wt_id) = worktree_id { + match m.worktrees.get(wt_id) { + Some(wt) => vec![wt.clone()], + None => { + return Response::Error { + code: "NOT_FOUND".into(), + message: format!("worktree '{wt_id}' not found"), + }; + } + } + } else { + m.worktrees + .into_values() + .filter(|wt| wt.status == WorktreeStatus::Active) + .collect() + }; + + if worktrees.is_empty() { + return Response::DiffResult { diffs: vec![] }; + } + + let is_targeted = worktree_id.is_some(); + let mut diffs = Vec::new(); + for wt in &worktrees { + let wt_path = std::path::PathBuf::from(&wt.path); + if !wt_path.exists() { + if is_targeted { + // Targeted query: report the error so callers can distinguish + // a deleted worktree from a clean one. + diffs.push(pu_core::protocol::WorktreeDiffEntry { + worktree_id: wt.id.clone(), + worktree_name: wt.name.clone(), + branch: wt.branch.clone(), + base_branch: wt.base_branch.clone(), + diff_output: String::new(), + files_changed: 0, + insertions: 0, + deletions: 0, + error: Some(format!("worktree directory not found: {}", wt.path)), + }); + } + // Bulk query: skip missing dirs (best-effort) + continue; + } + let base = wt.base_branch.as_deref(); + match git::diff_worktree(&wt_path, base, stat).await { + Ok(output) => { + diffs.push(pu_core::protocol::WorktreeDiffEntry { + worktree_id: wt.id.clone(), + worktree_name: wt.name.clone(), + branch: wt.branch.clone(), + base_branch: wt.base_branch.clone(), + diff_output: output.diff, + files_changed: output.files_changed, + insertions: output.insertions, + deletions: output.deletions, + error: None, + }); + } + Err(e) => { + tracing::warn!("failed to diff worktree {}: {}", wt.id, e); + diffs.push(pu_core::protocol::WorktreeDiffEntry { + worktree_id: wt.id.clone(), + worktree_name: wt.name.clone(), + branch: wt.branch.clone(), + base_branch: wt.base_branch.clone(), + diff_output: String::new(), + files_changed: 0, + insertions: 0, + deletions: 0, + error: Some(format!("{e}")), + }); + } + } + } + + Response::DiffResult { diffs } + } } impl Drop for Engine { diff --git a/crates/pu-engine/src/git.rs b/crates/pu-engine/src/git.rs index d5f60a8..c1514b2 100644 --- a/crates/pu-engine/src/git.rs +++ b/crates/pu-engine/src/git.rs @@ -40,11 +40,132 @@ pub async fn remove_worktree(repo_root: &Path, worktree_path: &Path) -> Result<( Ok(()) } +/// Resolve a symbolic ref (like "HEAD") to a branch name (like "main"). +/// Falls back to a commit SHA if HEAD is detached. +pub async fn resolve_base_ref(repo_root: &Path, refspec: &str) -> Result { + if refspec == "HEAD" { + // Try to get the branch name HEAD points to + if let Ok(branch) = run_git(&["symbolic-ref", "--short", "HEAD"], repo_root).await { + if !branch.is_empty() { + return Ok(branch); + } + } + // Detached HEAD — fall back to SHA + run_git(&["rev-parse", "HEAD"], repo_root).await + } else { + Ok(refspec.to_string()) + } +} + pub async fn delete_local_branch(repo_root: &Path, branch: &str) -> Result<(), std::io::Error> { run_git(&["branch", "-D", branch], repo_root).await?; Ok(()) } +/// Result of running `git diff` against a worktree. +#[derive(Debug)] +pub struct DiffOutput { + pub diff: String, + pub files_changed: usize, + pub insertions: usize, + pub deletions: usize, +} + +/// Compute the diff for a worktree against its base branch. +/// +/// When `base` is provided, computes the merge-base between HEAD and `base` so +/// the diff shows only the worktree's own changes (not upstream commits that +/// landed on base after the worktree was created). When `base` is `None`, diffs +/// uncommitted changes against HEAD. Untracked files are not included. +/// +/// When `stat` is true, returns `--stat` summary instead of full diff. +pub async fn diff_worktree( + worktree_path: &Path, + base: Option<&str>, + stat: bool, +) -> Result { + // Resolve the comparison target: use merge-base when a base branch is given + // so we only see the worktree's own changes, not upstream commits. + let merge_base: Option = match base { + Some(b) => { + let mb = run_git_allow_empty(&["merge-base", "HEAD", b], worktree_path).await?; + let mb = mb.trim().to_string(); + if mb.is_empty() { None } else { Some(mb) } + } + None => None, + }; + + let target = merge_base.as_deref().unwrap_or("HEAD"); + + // Get the stat summary (always needed for counts) + let stat_output = run_git_allow_empty(&["diff", "--stat", target], worktree_path).await?; + let (files_changed, insertions, deletions) = parse_diff_stat(&stat_output); + + let diff = if stat { + stat_output + } else { + run_git_allow_empty(&["diff", target], worktree_path).await? + }; + + Ok(DiffOutput { + diff, + files_changed, + insertions, + deletions, + }) +} + +/// Like run_git but treats empty output as success (no changes = no error). +async fn run_git_allow_empty(args: &[&str], cwd: &Path) -> Result { + let output = tokio::process::Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(std::io::Error::other(format!( + "git {} failed: {stderr}", + args.join(" ") + ))); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +/// Parse the summary line of `git diff --stat` to extract counts. +/// Example: " 3 files changed, 10 insertions(+), 2 deletions(-)" +fn parse_diff_stat(stat: &str) -> (usize, usize, usize) { + let Some(summary) = stat.lines().last() else { + return (0, 0, 0); + }; + let mut files = 0; + let mut ins = 0; + let mut del = 0; + for part in summary.split(',') { + let part = part.trim(); + if part.contains("file") { + files = part + .split_whitespace() + .next() + .and_then(|n| n.parse().ok()) + .unwrap_or(0); + } else if part.contains("insertion") { + ins = part + .split_whitespace() + .next() + .and_then(|n| n.parse().ok()) + .unwrap_or(0); + } else if part.contains("deletion") { + del = part + .split_whitespace() + .next() + .and_then(|n| n.parse().ok()) + .unwrap_or(0); + } + } + (files, ins, del) +} + pub async fn delete_remote_branch(repo_root: &Path, branch: &str) -> Result<(), std::io::Error> { run_git(&["push", "origin", "--delete", branch], repo_root).await?; Ok(()) @@ -142,6 +263,135 @@ mod tests { assert!(branches.trim().is_empty(), "branch should be deleted"); } + #[test] + fn given_stat_summary_should_parse_counts() { + let stat = " src/main.rs | 10 ++++------\n src/lib.rs | 5 ++---\n 2 files changed, 6 insertions(+), 9 deletions(-)\n"; + let (files, ins, del) = super::parse_diff_stat(stat); + assert_eq!(files, 2); + assert_eq!(ins, 6); + assert_eq!(del, 9); + } + + #[test] + fn given_empty_stat_should_return_zeros() { + let (files, ins, del) = super::parse_diff_stat(""); + assert_eq!(files, 0); + assert_eq!(ins, 0); + assert_eq!(del, 0); + } + + #[test] + fn given_insertions_only_should_parse() { + let stat = " 1 file changed, 3 insertions(+)\n"; + let (files, ins, del) = super::parse_diff_stat(stat); + assert_eq!(files, 1); + assert_eq!(ins, 3); + assert_eq!(del, 0); + } + + #[test] + fn given_deletions_only_should_parse() { + let stat = " 1 file changed, 2 deletions(-)\n"; + let (files, ins, del) = super::parse_diff_stat(stat); + assert_eq!(files, 1); + assert_eq!(ins, 0); + assert_eq!(del, 2); + } + + #[tokio::test(flavor = "current_thread")] + async fn given_worktree_with_changes_should_diff() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + + let wt_path = tmp.path().join("wt-diff"); + create_worktree(tmp.path(), &wt_path, "pu/diff-test", "HEAD") + .await + .unwrap(); + + // Make a change in the worktree + std::fs::write(wt_path.join("test.txt"), "hello\n").unwrap(); + std::process::Command::new("git") + .args(["add", "test.txt"]) + .current_dir(&wt_path) + .output() + .unwrap(); + std::process::Command::new("git") + .args([ + "-c", + "user.name=Test", + "-c", + "user.email=test@test.com", + "commit", + "-m", + "add test file", + ]) + .current_dir(&wt_path) + .output() + .unwrap(); + + // Diff against base (HEAD of main) + let result = diff_worktree(&wt_path, Some("HEAD~1"), false).await; + assert!(result.is_ok(), "diff_worktree failed: {result:?}"); + let output = result.unwrap(); + assert!(output.diff.contains("hello")); + assert_eq!(output.files_changed, 1); + assert!(output.insertions > 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn given_worktree_with_no_changes_should_return_empty_diff() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + + let wt_path = tmp.path().join("wt-nodiff"); + create_worktree(tmp.path(), &wt_path, "pu/nodiff-test", "HEAD") + .await + .unwrap(); + + let result = diff_worktree(&wt_path, None, false).await; + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.diff.trim().is_empty()); + assert_eq!(output.files_changed, 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn given_stat_flag_should_return_stat_output() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + + let wt_path = tmp.path().join("wt-stat"); + create_worktree(tmp.path(), &wt_path, "pu/stat-test", "HEAD") + .await + .unwrap(); + + std::fs::write(wt_path.join("file.txt"), "content\n").unwrap(); + std::process::Command::new("git") + .args(["add", "file.txt"]) + .current_dir(&wt_path) + .output() + .unwrap(); + std::process::Command::new("git") + .args([ + "-c", + "user.name=Test", + "-c", + "user.email=test@test.com", + "commit", + "-m", + "add file", + ]) + .current_dir(&wt_path) + .output() + .unwrap(); + + let result = diff_worktree(&wt_path, Some("HEAD~1"), true).await; + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.diff.contains("file.txt")); + assert!(output.diff.contains("file changed") || output.diff.contains("files changed")); + } + #[tokio::test(flavor = "current_thread")] async fn given_no_remote_should_fail_delete_remote_branch() { let tmp = TempDir::new().unwrap();