diff --git a/server/src/matrix/commands.rs b/server/src/matrix/commands.rs index 3350c4d..e83501c 100644 --- a/server/src/matrix/commands.rs +++ b/server/src/matrix/commands.rs @@ -88,6 +88,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Toggle ambient mode for this room: `ambient on` or `ambient off`", handler: handle_ambient, }, + BotCommand { + name: "git", + description: "Show git status: branch, uncommitted changes, and ahead/behind remote", + handler: handle_git, + }, ] } @@ -364,6 +369,79 @@ fn handle_ambient(ctx: &CommandContext) -> Option { Some(msg.to_string()) } +/// Show compact git status: branch, uncommitted files, ahead/behind remote. +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) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -776,4 +854,119 @@ mod tests { ); } } + + // -- git command -------------------------------------------------------- + + #[test] + fn git_command_is_registered() { + 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 = 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 = make_room_id("!test:example.com"); + 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, + is_addressed: true, + }; + 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 = make_room_id("!test:example.com"); + 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, + is_addressed: true, + }; + 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 = make_room_id("!test:example.com"); + 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, + is_addressed: true, + }; + 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 = make_room_id("!test:example.com"); + 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, + is_addressed: true, + }; + 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 = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy GIT"); + assert!(result.is_some(), "GIT should match case-insensitively"); + } }