story-kit: merge 299_story_bot_git_status_command_shows_working_tree_and_branch_info
This commit is contained in:
@@ -88,6 +88,11 @@ pub fn commands() -> &'static [BotCommand] {
|
|||||||
description: "Toggle ambient mode for this room: `ambient on` or `ambient off`",
|
description: "Toggle ambient mode for this room: `ambient on` or `ambient off`",
|
||||||
handler: handle_ambient,
|
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<String> {
|
|||||||
Some(msg.to_string())
|
Some(msg.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show compact git status: branch, uncommitted files, ahead/behind remote.
|
||||||
|
fn handle_git(ctx: &CommandContext) -> Option<String> {
|
||||||
|
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
|
// 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user