From 24a71bb450ab12d3b5d0d730f510d4116ea1c680 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 6 Mar 2026 22:41:56 -0600 Subject: [PATCH 1/3] feat: Add `pu diff` command to show changes across agent worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After agents finish work, you need to see what code they changed. This adds `pu diff` which computes git diffs for each worktree against its base branch — the missing step between `pu status` and reviewing/merging agent work. Usage: pu diff # diffs for all active worktrees pu diff --worktree X # diff a specific worktree pu diff --stat # file summary instead of full diff pu diff --json # machine-readable output Co-Authored-By: Claude Opus 4.6 --- crates/pu-cli/src/commands/diff.rs | 29 ++++ crates/pu-cli/src/commands/mod.rs | 1 + crates/pu-cli/src/main.rs | 17 +++ crates/pu-cli/src/output.rs | 73 ++++++++++ crates/pu-core/src/protocol.rs | 100 +++++++++++++ crates/pu-engine/src/engine.rs | 82 ++++++++++- crates/pu-engine/src/git.rs | 224 +++++++++++++++++++++++++++++ 7 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 crates/pu-cli/src/commands/diff.rs 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..8dc5476 100644 --- a/crates/pu-cli/src/output.rs +++ b/crates/pu-cli/src/output.rs @@ -388,6 +388,37 @@ 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 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 +827,48 @@ 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, + }], + }; + 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, + }], + }; + 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..14688ac 100644 --- a/crates/pu-core/src/protocol.rs +++ b/crates/pu-core/src/protocol.rs @@ -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,19 @@ 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, +} + #[cfg(test)] mod tests { use super::*; @@ -661,6 +684,83 @@ 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, + }], + }; + 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] diff --git a/crates/pu-engine/src/engine.rs b/crates/pu-engine/src/engine.rs index d5c3197..a521e55 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 + } } } @@ -2928,6 +2937,77 @@ 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 mut diffs = Vec::new(); + for wt in &worktrees { + let wt_path = std::path::PathBuf::from(&wt.path); + if !wt_path.exists() { + 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, + }); + } + 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: format!("error: {e}"), + files_changed: 0, + insertions: 0, + deletions: 0, + }); + } + } + } + + 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..6cc5c95 100644 --- a/crates/pu-engine/src/git.rs +++ b/crates/pu-engine/src/git.rs @@ -45,6 +45,101 @@ pub async fn delete_local_branch(repo_root: &Path, branch: &str) -> Result<(), s 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. +/// If `base` is provided, diffs against that branch. Otherwise diffs uncommitted changes. +/// 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 { + // Get the stat summary (always needed for counts) + let stat_args: Vec<&str> = match base { + Some(b) => vec!["diff", "--stat", b], + None => vec!["diff", "--stat", "HEAD"], + }; + let stat_output = run_git_allow_empty(&stat_args, worktree_path).await?; + + let (files_changed, insertions, deletions) = parse_diff_stat(&stat_output); + + let diff = if stat { + stat_output + } else { + let diff_args: Vec<&str> = match base { + Some(b) => vec!["diff", b], + None => vec!["diff", "HEAD"], + }; + run_git_allow_empty(&diff_args, 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 +237,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(); From 3940e29bf0e5d3baf14ce7746c99daf52372cda5 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sat, 7 Mar 2026 09:00:01 -0600 Subject: [PATCH 2/3] fix: Address all CodeRabbit review feedback on pu diff - Bump PROTOCOL_VERSION to 2 for the new Diff wire-format variants - Add `error: Option` to WorktreeDiffEntry so JSON clients can distinguish git failures from legitimate empty diffs - Use `git merge-base HEAD ` instead of diffing against the tip of the base branch, so diffs show only the worktree's own changes - Return an error entry (not silent skip) when `--worktree ` targets a missing directory; keep best-effort skipping for bulk queries - Display error entries in human-readable output Co-Authored-By: Claude Opus 4.6 --- crates/pu-cli/src/output.rs | 6 +++++- crates/pu-core/src/protocol.rs | 9 ++++++--- crates/pu-engine/src/engine.rs | 21 ++++++++++++++++++++- crates/pu-engine/src/git.rs | 31 ++++++++++++++++++++----------- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/crates/pu-cli/src/output.rs b/crates/pu-cli/src/output.rs index 8dc5476..f542bab 100644 --- a/crates/pu-cli/src/output.rs +++ b/crates/pu-cli/src/output.rs @@ -405,7 +405,9 @@ pub fn print_response(response: &Response, json_mode: bool) { base.dimmed(), d.branch.green() ); - if d.files_changed == 0 && d.diff_output.trim().is_empty() { + 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!( @@ -841,6 +843,7 @@ mod tests { files_changed: 1, insertions: 1, deletions: 0, + error: None, }], }; print_response(&resp, false); @@ -864,6 +867,7 @@ mod tests { files_changed: 0, insertions: 0, deletions: 0, + error: None, }], }; print_response(&resp, false); diff --git a/crates/pu-core/src/protocol.rs b/crates/pu-core/src/protocol.rs index 14688ac..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 { @@ -543,6 +543,8 @@ pub struct WorktreeDiffEntry { pub files_changed: usize, pub insertions: usize, pub deletions: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, } #[cfg(test)] @@ -734,6 +736,7 @@ mod tests { files_changed: 2, insertions: 5, deletions: 3, + error: None, }], }; let json = serde_json::to_string(&resp).unwrap(); @@ -918,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 a521e55..13e9f7a 100644 --- a/crates/pu-engine/src/engine.rs +++ b/crates/pu-engine/src/engine.rs @@ -2970,10 +2970,27 @@ impl Engine { 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(); @@ -2988,6 +3005,7 @@ impl Engine { files_changed: output.files_changed, insertions: output.insertions, deletions: output.deletions, + error: None, }); } Err(e) => { @@ -2997,10 +3015,11 @@ impl Engine { worktree_name: wt.name.clone(), branch: wt.branch.clone(), base_branch: wt.base_branch.clone(), - diff_output: format!("error: {e}"), + diff_output: String::new(), files_changed: 0, insertions: 0, deletions: 0, + error: Some(format!("{e}")), }); } } diff --git a/crates/pu-engine/src/git.rs b/crates/pu-engine/src/git.rs index 6cc5c95..7a51206 100644 --- a/crates/pu-engine/src/git.rs +++ b/crates/pu-engine/src/git.rs @@ -55,30 +55,39 @@ pub struct DiffOutput { } /// Compute the diff for a worktree against its base branch. -/// If `base` is provided, diffs against that branch. Otherwise diffs uncommitted changes. +/// +/// 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 { - // Get the stat summary (always needed for counts) - let stat_args: Vec<&str> = match base { - Some(b) => vec!["diff", "--stat", b], - None => vec!["diff", "--stat", "HEAD"], + // 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 stat_output = run_git_allow_empty(&stat_args, worktree_path).await?; + 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 { - let diff_args: Vec<&str> = match base { - Some(b) => vec!["diff", b], - None => vec!["diff", "HEAD"], - }; - run_git_allow_empty(&diff_args, worktree_path).await? + run_git_allow_empty(&["diff", target], worktree_path).await? }; Ok(DiffOutput { From e84b6f43d28e55b7dd3e293bebc21468898a1e49 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sat, 7 Mar 2026 09:11:38 -0600 Subject: [PATCH 3/3] fix: Resolve HEAD to branch name at worktree creation time When no base is supplied, worktrees were storing the literal "HEAD" as base_branch. After introducing merge-base semantics in diff_worktree, this caused `git merge-base HEAD HEAD` to return the worktree's own tip, hiding all committed changes. Now resolves HEAD to the actual branch name (e.g. "main") at worktree creation time via `git symbolic-ref --short HEAD`, falling back to a SHA for detached HEAD. This ensures diff_worktree correctly computes the fork point and shows only the worktree's own changes. Co-Authored-By: Claude Opus 4.6 --- crates/pu-engine/src/engine.rs | 14 ++++++++++++-- crates/pu-engine/src/git.rs | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/pu-engine/src/engine.rs b/crates/pu-engine/src/engine.rs index 13e9f7a..9872e97 100644 --- a/crates/pu-engine/src/engine.rs +++ b/crates/pu-engine/src/engine.rs @@ -641,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) { @@ -945,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}"); diff --git a/crates/pu-engine/src/git.rs b/crates/pu-engine/src/git.rs index 7a51206..c1514b2 100644 --- a/crates/pu-engine/src/git.rs +++ b/crates/pu-engine/src/git.rs @@ -40,6 +40,23 @@ 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(())