diff --git a/server/src/chat/commands/diff.rs b/server/src/chat/commands/diff.rs new file mode 100644 index 00000000..751393ca --- /dev/null +++ b/server/src/chat/commands/diff.rs @@ -0,0 +1,259 @@ +//! Handler for the `diff` command. +//! +//! Shows the git diff from the configured main branch to the story's worktree +//! HEAD, formatted for readability in chat. + +use super::CommandContext; +use std::path::Path; +use std::process::Command; + +/// Display the git diff from the configured main branch to a story's worktree HEAD. +/// +/// Usage: `diff ` +pub(super) fn handle_diff(ctx: &CommandContext) -> Option { + let num_str = ctx.args.trim(); + if num_str.is_empty() { + return Some(format!( + "Usage: `{} diff `\n\nShows the git diff from the main branch to the story's worktree HEAD.", + ctx.bot_name + )); + } + if !num_str.chars().all(|c| c.is_ascii_digit()) { + return Some(format!( + "Invalid story number: `{num_str}`. Usage: `{} diff `", + ctx.bot_name + )); + } + + let story_id = match find_story_id(num_str) { + Some(id) => id, + None => { + return Some(format!( + "No story with number **{num_str}** found in the pipeline." + )); + } + }; + + let wt_path = crate::worktree::worktree_path(ctx.project_root, &story_id); + if !wt_path.is_dir() { + return Some(format!( + "Story **{num_str}** has no worktree. The diff is only available once a coder has started working on it." + )); + } + + let base_branch = resolve_base_branch(ctx.project_root); + let range = format!("{base_branch}...HEAD"); + + let stat = run_git(&wt_path, &["diff", "--stat", &range]); + let diff = run_git(&wt_path, &["diff", &range]); + + let mut out = format!("## Diff — story {num_str} vs `{base_branch}`\n\n"); + + if stat.is_empty() && diff.is_empty() { + out.push_str("*(no changes relative to main branch)*\n"); + return Some(out); + } + + if !stat.is_empty() { + out.push_str("**Changed files:**\n```\n"); + out.push_str(&stat); + out.push_str("\n```\n\n"); + } + + if !diff.is_empty() { + const MAX_DIFF_BYTES: usize = 8_000; + if diff.len() > MAX_DIFF_BYTES { + let truncated = truncate_at_char_boundary(&diff, MAX_DIFF_BYTES); + out.push_str("**Diff** *(truncated — showing first 8 KB)*:\n```diff\n"); + out.push_str(truncated); + out.push_str("\n... (truncated)\n```\n"); + } else { + out.push_str("**Diff:**\n```diff\n"); + out.push_str(&diff); + out.push_str("\n```\n"); + } + } + + Some(out) +} + +/// Find the story_id in the pipeline whose numeric prefix matches `num_str`. +fn find_story_id(num_str: &str) -> Option { + let items = crate::pipeline_state::read_all_typed(); + items.into_iter().find_map(|item| { + let file_num = item + .story_id + .0 + .split('_') + .next() + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) + .unwrap_or(""); + if file_num == num_str { + Some(item.story_id.0.clone()) + } else { + None + } + }) +} + +/// Return the configured base branch, or auto-detect it from the project root HEAD. +fn resolve_base_branch(project_root: &Path) -> String { + crate::config::ProjectConfig::load(project_root) + .ok() + .and_then(|c| c.base_branch) + .unwrap_or_else(|| { + Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(project_root) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| "master".to_string()) + }) +} + +/// Run a git command in `dir`, returning trimmed stdout (empty string on failure). +fn run_git(dir: &Path, args: &[&str]) -> String { + Command::new("git") + .args(args) + .current_dir(dir) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default() +} + +/// Truncate `s` to at most `max_bytes` bytes without splitting a UTF-8 character. +fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str { + if s.len() <= max_bytes { + return s; + } + let mut boundary = max_bytes; + while !s.is_char_boundary(boundary) { + boundary -= 1; + } + &s[..boundary] +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::AgentPool; + use std::collections::HashSet; + use std::sync::{Arc, Mutex}; + + use super::super::{CommandDispatch, try_handle_command}; + + fn diff_cmd(root: &std::path::Path, args: &str) -> Option { + let agents = Arc::new(AgentPool::new_test(3000)); + let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); + let room_id = "!test:example.com".to_string(); + let dispatch = CommandDispatch { + bot_name: "Timmy", + bot_user_id: "@timmy:homeserver.local", + project_root: root, + agents: &agents, + ambient_rooms: &ambient_rooms, + room_id: &room_id, + }; + try_handle_command(&dispatch, &format!("@timmy diff {args}")) + } + + #[test] + fn diff_command_is_registered() { + let found = super::super::commands().iter().any(|c| c.name == "diff"); + assert!(found, "diff command must be in the registry"); + } + + #[test] + fn diff_command_appears_in_help() { + let result = super::super::tests::try_cmd_addressed( + "Timmy", + "@timmy:homeserver.local", + "@timmy help", + ); + let output = result.unwrap(); + assert!( + output.contains("diff"), + "help should list diff command: {output}" + ); + } + + #[test] + fn diff_command_no_args_returns_usage() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = diff_cmd(tmp.path(), "").unwrap(); + assert!( + output.contains("Usage"), + "no args should show usage: {output}" + ); + } + + #[test] + fn diff_command_non_numeric_returns_error() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = diff_cmd(tmp.path(), "abc").unwrap(); + assert!( + output.contains("Invalid"), + "non-numeric arg should return error: {output}" + ); + } + + #[test] + fn diff_command_story_not_found_returns_friendly_message() { + crate::db::ensure_content_store(); + let tmp = tempfile::TempDir::new().unwrap(); + let output = diff_cmd(tmp.path(), "99993").unwrap(); + assert!( + output.contains("99993"), + "message should include story number: {output}" + ); + assert!( + output.contains("found") || output.contains("pipeline"), + "message should explain not found: {output}" + ); + } + + #[test] + fn diff_command_no_worktree_returns_clear_error() { + use crate::chat::test_helpers::write_story_file; + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "55551_story_no_worktree.md", + "---\nname: No Worktree\n---\n", + ); + let output = diff_cmd(tmp.path(), "55551").unwrap(); + assert!( + output.contains("worktree") + || output.contains("no worktree") + || output.contains("Worktree"), + "should report missing worktree: {output}" + ); + } + + #[test] + fn truncate_at_char_boundary_short_string() { + let s = "hello"; + assert_eq!(truncate_at_char_boundary(s, 100), "hello"); + } + + #[test] + fn truncate_at_char_boundary_exact_limit() { + let s = "hello"; + assert_eq!(truncate_at_char_boundary(s, 5), "hello"); + } + + #[test] + fn truncate_at_char_boundary_over_limit() { + let s = "hello world"; + assert_eq!(truncate_at_char_boundary(s, 5), "hello"); + } +} diff --git a/server/src/chat/commands/mod.rs b/server/src/chat/commands/mod.rs index ddf23766..81311094 100644 --- a/server/src/chat/commands/mod.rs +++ b/server/src/chat/commands/mod.rs @@ -11,6 +11,7 @@ mod backlog; mod cost; mod coverage; mod depends; +mod diff; mod freeze; mod git; mod help; @@ -164,6 +165,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Display the full text of a work item: `show `", handler: show::handle_show, }, + BotCommand { + name: "diff", + description: "Show git diff from main branch to story worktree HEAD: `diff `", + handler: diff::handle_diff, + }, BotCommand { name: "overview", description: "Show implementation summary for a merged story: `overview `",