//! Handler for the `git` command. use super::CommandContext; /// Show compact git status: branch, uncommitted files, ahead/behind remote. pub(super) fn handle_git(ctx: &CommandContext) -> Option { use std::process::Command; // Current branch let branch = Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(ctx.project_root) .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_else(|| "unknown".to_string()); // Porcelain status for staged + unstaged changes let status_output = Command::new("git") .args(["status", "--porcelain"]) .current_dir(ctx.project_root) .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) .unwrap_or_default(); let changed_files: Vec<&str> = status_output.lines().filter(|l| !l.is_empty()).collect(); let change_count = changed_files.len(); // Ahead/behind: --left-right gives "N\tM" (ahead\tbehind) let ahead_behind = Command::new("git") .args(["rev-list", "--count", "--left-right", "HEAD...@{u}"]) .current_dir(ctx.project_root) .output() .ok() .filter(|o| o.status.success()) .and_then(|o| { let s = String::from_utf8_lossy(&o.stdout).trim().to_string(); let mut parts = s.split_whitespace(); let ahead: u32 = parts.next()?.parse().ok()?; let behind: u32 = parts.next()?.parse().ok()?; Some((ahead, behind)) }); let mut out = format!("**Branch:** `{branch}`\n"); if change_count == 0 { out.push_str("**Changes:** clean\n"); } else { out.push_str(&format!("**Changes:** {change_count} file(s)\n")); for line in &changed_files { // Porcelain format: "XY filename" (2-char status + space + path) if line.len() > 3 { let codes = &line[..2]; let name = line[3..].trim(); out.push_str(&format!(" • `{codes}` {name}\n")); } else { out.push_str(&format!(" • {line}\n")); } } } match ahead_behind { Some((0, 0)) => out.push_str("**Remote:** up to date\n"), Some((ahead, 0)) => out.push_str(&format!("**Remote:** ↑{ahead} ahead\n")), Some((0, behind)) => out.push_str(&format!("**Remote:** ↓{behind} behind\n")), Some((ahead, behind)) => { out.push_str(&format!("**Remote:** ↑{ahead} ahead, ↓{behind} behind\n")); } None => out.push_str("**Remote:** no tracking branch\n"), } Some(out) } #[cfg(test)] mod tests { use crate::agents::AgentPool; use std::collections::HashSet; use std::sync::{Arc, Mutex}; use super::super::{CommandDispatch, try_handle_command}; fn test_ambient_rooms() -> Arc>> { Arc::new(Mutex::new(HashSet::new())) } fn test_agents() -> Arc { Arc::new(AgentPool::new_test(3000)) } #[test] fn git_command_is_registered() { use super::super::commands; let found = commands().iter().any(|c| c.name == "git"); assert!(found, "git command must be in the registry"); } #[test] fn git_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("git"), "help should list git command: {output}"); } #[test] fn git_command_returns_some() { // Run from the actual repo root so git commands have a real repo to query. let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap_or(std::path::Path::new(".")); let agents = test_agents(); let ambient_rooms = test_ambient_rooms(); let room_id = "!test:example.com".to_string(); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: repo_root, agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, }; let result = try_handle_command(&dispatch, "@timmy git"); assert!(result.is_some(), "git command should always return Some"); } #[test] fn git_command_output_contains_branch() { let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap_or(std::path::Path::new(".")); let agents = test_agents(); let ambient_rooms = test_ambient_rooms(); let room_id = "!test:example.com".to_string(); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: repo_root, agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, }; let output = try_handle_command(&dispatch, "@timmy git").unwrap(); assert!( output.contains("**Branch:**"), "git output should contain branch info: {output}" ); } #[test] fn git_command_output_contains_changes() { let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap_or(std::path::Path::new(".")); let agents = test_agents(); let ambient_rooms = test_ambient_rooms(); let room_id = "!test:example.com".to_string(); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: repo_root, agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, }; let output = try_handle_command(&dispatch, "@timmy git").unwrap(); assert!( output.contains("**Changes:**"), "git output should contain changes section: {output}" ); } #[test] fn git_command_output_contains_remote() { let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap_or(std::path::Path::new(".")); let agents = test_agents(); let ambient_rooms = test_ambient_rooms(); let room_id = "!test:example.com".to_string(); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: repo_root, agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, }; let output = try_handle_command(&dispatch, "@timmy git").unwrap(); assert!( output.contains("**Remote:**"), "git output should contain remote section: {output}" ); } #[test] fn git_command_case_insensitive() { let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy GIT"); assert!(result.is_some(), "GIT should match case-insensitively"); } }