Skip to content
Merged
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
29 changes: 29 additions & 0 deletions crates/pu-cli/src/commands/diff.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
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(())
}
1 change: 1 addition & 0 deletions crates/pu-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
17 changes: 17 additions & 0 deletions crates/pu-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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
Expand Down Expand Up @@ -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,
Expand Down
77 changes: 77 additions & 0 deletions crates/pu-cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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]
Expand Down
109 changes: 106 additions & 3 deletions crates/pu-core/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> as hex in JSON for binary PTY data.
mod hex_bytes {
Expand Down Expand Up @@ -235,6 +235,13 @@ pub enum Request {
name: String,
},
Shutdown,
Diff {
project_root: String,
#[serde(default)]
worktree_id: Option<String>,
#[serde(default)]
stat: bool,
},
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -494,6 +501,9 @@ pub enum Response {
scope: String,
created_at: DateTime<Utc>,
},
DiffResult {
diffs: Vec<WorktreeDiffEntry>,
},
Ok,
ShuttingDown,
Error {
Expand Down Expand Up @@ -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<String>,
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<String>,
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 ---
Expand Down
Loading
Loading