//! 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"); } }