diff --git a/README.md b/README.md index 1c69ab9..1455151 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ The Rust port is a complete, working CLI (`claw`) with: - Session persistence and compaction - HTTP/SSE server (axum-based, direct connect sessions) - LSP client integration (diagnostics, go-to-definition, find-references) +- Vim keybinding mode (normal/insert/visual/command) +- Git slash commands (/branch, /commit, /commit-push-pr, /worktree) Every crate, every test, every commit was driven through oh-my-opencode's **Sisyphus** agent with `ultrawork` mode — from initial scaffolding to the final cleanroom pass. The cleanroom refactor, QA verification, git history rewrite, and CI setup were coordinated by **Jobdori** ([OpenClaw](https://github.com/openclaw/openclaw)), an AI assistant orchestrating the entire workflow. The Rust port passes all 274 tests across the workspace. diff --git a/rust/crates/claw-cli/src/app.rs b/rust/crates/claw-cli/src/app.rs index e012f27..85e754f 100644 --- a/rust/crates/claw-cli/src/app.rs +++ b/rust/crates/claw-cli/src/app.rs @@ -162,6 +162,10 @@ impl CliApp { writeln!(out, "Unknown slash command: /{name}")?; Ok(CommandResult::Continue) } + _ => { + writeln!(out, "Slash command unavailable in this mode")?; + Ok(CommandResult::Continue) + } } } @@ -172,7 +176,7 @@ impl CliApp { SlashCommand::Help => "/help", SlashCommand::Status => "/status", SlashCommand::Compact => "/compact", - SlashCommand::Unknown(_) => continue, + _ => continue, }; writeln!(out, " {name:<9} {}", handler.summary)?; } diff --git a/rust/crates/claw-cli/src/main.rs b/rust/crates/claw-cli/src/main.rs index 576a55e..13c3fcd 100644 --- a/rust/crates/claw-cli/src/main.rs +++ b/rust/crates/claw-cli/src/main.rs @@ -939,6 +939,9 @@ fn run_resume_command( }) } SlashCommand::Bughunter { .. } + | SlashCommand::Branch { .. } + | SlashCommand::Worktree { .. } + | SlashCommand::CommitPushPr { .. } | SlashCommand::Commit | SlashCommand::Pr { .. } | SlashCommand::Issue { .. } @@ -1242,6 +1245,18 @@ impl LiveCli { Self::print_skills(args.as_deref())?; false } + SlashCommand::Branch { .. } => { + eprintln!("git branch commands not yet wired to REPL"); + false + } + SlashCommand::Worktree { .. } => { + eprintln!("git worktree commands not yet wired to REPL"); + false + } + SlashCommand::CommitPushPr { .. } => { + eprintln!("commit-push-pr not yet wired to REPL"); + false + } SlashCommand::Unknown(name) => { eprintln!("unknown slash command: /{name}"); false diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 6e01f71..95ab316 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1,7 +1,10 @@ use std::collections::BTreeMap; use std::env; use std::fs; +use std::io; use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; use plugins::{PluginError, PluginManager, PluginSummary}; use runtime::{compact_session, CompactionConfig, Session}; @@ -144,6 +147,20 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: Some("[scope]"), resume_supported: false, }, + SlashCommandSpec { + name: "branch", + aliases: &[], + summary: "List, create, or switch git branches", + argument_hint: Some("[list|create |switch ]"), + resume_supported: false, + }, + SlashCommandSpec { + name: "worktree", + aliases: &[], + summary: "List, add, remove, or prune git worktrees", + argument_hint: Some("[list|add [branch]|remove |prune]"), + resume_supported: false, + }, SlashCommandSpec { name: "commit", aliases: &[], @@ -151,6 +168,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: None, resume_supported: false, }, + SlashCommandSpec { + name: "commit-push-pr", + aliases: &[], + summary: "Commit workspace changes, push the branch, and open a PR", + argument_hint: Some("[context]"), + resume_supported: false, + }, SlashCommandSpec { name: "pr", aliases: &[], @@ -230,10 +254,22 @@ pub enum SlashCommand { Help, Status, Compact, + Branch { + action: Option, + target: Option, + }, Bughunter { scope: Option, }, + Worktree { + action: Option, + path: Option, + branch: Option, + }, Commit, + CommitPushPr { + context: Option, + }, Pr { context: Option, }, @@ -301,10 +337,22 @@ impl SlashCommand { "help" => Self::Help, "status" => Self::Status, "compact" => Self::Compact, + "branch" => Self::Branch { + action: parts.next().map(ToOwned::to_owned), + target: parts.next().map(ToOwned::to_owned), + }, "bughunter" => Self::Bughunter { scope: remainder_after_command(trimmed, command), }, + "worktree" => Self::Worktree { + action: parts.next().map(ToOwned::to_owned), + path: parts.next().map(ToOwned::to_owned), + branch: parts.next().map(ToOwned::to_owned), + }, "commit" => Self::Commit, + "commit-push-pr" => Self::CommitPushPr { + context: remainder_after_command(trimmed, command), + }, "pr" => Self::Pr { context: remainder_after_command(trimmed, command), }, @@ -631,6 +679,392 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitPushPrRequest { + pub commit_message: Option, + pub pr_title: String, + pub pr_body: String, + pub branch_name_hint: String, +} + +pub fn handle_branch_slash_command( + action: Option<&str>, + target: Option<&str>, + cwd: &Path, +) -> io::Result { + match normalize_optional_args(action) { + None | Some("list") => { + let branches = git_stdout(cwd, &["branch", "--list", "--verbose"])?; + let trimmed = branches.trim(); + Ok(if trimmed.is_empty() { + "Branch\n Result no branches found".to_string() + } else { + format!("Branch\n Result listed\n\n{}", trimmed) + }) + } + Some("create") => { + let Some(target) = target.filter(|value| !value.trim().is_empty()) else { + return Ok("Usage: /branch create ".to_string()); + }; + git_status_ok(cwd, &["switch", "-c", target])?; + Ok(format!( + "Branch\n Result created and switched\n Branch {target}" + )) + } + Some("switch") => { + let Some(target) = target.filter(|value| !value.trim().is_empty()) else { + return Ok("Usage: /branch switch ".to_string()); + }; + git_status_ok(cwd, &["switch", target])?; + Ok(format!( + "Branch\n Result switched\n Branch {target}" + )) + } + Some(other) => Ok(format!( + "Unknown /branch action '{other}'. Use /branch list, /branch create , or /branch switch ." + )), + } +} + +pub fn handle_worktree_slash_command( + action: Option<&str>, + path: Option<&str>, + branch: Option<&str>, + cwd: &Path, +) -> io::Result { + match normalize_optional_args(action) { + None | Some("list") => { + let worktrees = git_stdout(cwd, &["worktree", "list"])?; + let trimmed = worktrees.trim(); + Ok(if trimmed.is_empty() { + "Worktree\n Result no worktrees found".to_string() + } else { + format!("Worktree\n Result listed\n\n{}", trimmed) + }) + } + Some("add") => { + let Some(path) = path.filter(|value| !value.trim().is_empty()) else { + return Ok("Usage: /worktree add [branch]".to_string()); + }; + if let Some(branch) = branch.filter(|value| !value.trim().is_empty()) { + if branch_exists(cwd, branch) { + git_status_ok(cwd, &["worktree", "add", path, branch])?; + } else { + git_status_ok(cwd, &["worktree", "add", path, "-b", branch])?; + } + Ok(format!( + "Worktree\n Result added\n Path {path}\n Branch {branch}" + )) + } else { + git_status_ok(cwd, &["worktree", "add", path])?; + Ok(format!( + "Worktree\n Result added\n Path {path}" + )) + } + } + Some("remove") => { + let Some(path) = path.filter(|value| !value.trim().is_empty()) else { + return Ok("Usage: /worktree remove ".to_string()); + }; + git_status_ok(cwd, &["worktree", "remove", path])?; + Ok(format!( + "Worktree\n Result removed\n Path {path}" + )) + } + Some("prune") => { + git_status_ok(cwd, &["worktree", "prune"])?; + Ok("Worktree\n Result pruned".to_string()) + } + Some(other) => Ok(format!( + "Unknown /worktree action '{other}'. Use /worktree list, /worktree add [branch], /worktree remove , or /worktree prune." + )), + } +} + +pub fn handle_commit_slash_command(message: &str, cwd: &Path) -> io::Result { + let status = git_stdout(cwd, &["status", "--short"])?; + if status.trim().is_empty() { + return Ok( + "Commit\n Result skipped\n Reason no workspace changes" + .to_string(), + ); + } + + let message = message.trim(); + if message.is_empty() { + return Err(io::Error::other("generated commit message was empty")); + } + + git_status_ok(cwd, &["add", "-A"])?; + let path = write_temp_text_file("claw-commit-message", "txt", message)?; + let path_string = path.to_string_lossy().into_owned(); + git_status_ok(cwd, &["commit", "--file", path_string.as_str()])?; + + Ok(format!( + "Commit\n Result created\n Message file {}\n\n{}", + path.display(), + message + )) +} + +pub fn handle_commit_push_pr_slash_command( + request: &CommitPushPrRequest, + cwd: &Path, +) -> io::Result { + if !command_exists("gh") { + return Err(io::Error::other("gh CLI is required for /commit-push-pr")); + } + + let default_branch = detect_default_branch(cwd)?; + let mut branch = current_branch(cwd)?; + let mut created_branch = false; + if branch == default_branch { + let hint = if request.branch_name_hint.trim().is_empty() { + request.pr_title.as_str() + } else { + request.branch_name_hint.as_str() + }; + let next_branch = build_branch_name(hint); + git_status_ok(cwd, &["switch", "-c", next_branch.as_str()])?; + branch = next_branch; + created_branch = true; + } + + let workspace_has_changes = !git_stdout(cwd, &["status", "--short"])?.trim().is_empty(); + let commit_report = if workspace_has_changes { + let Some(message) = request.commit_message.as_deref() else { + return Err(io::Error::other( + "commit message is required when workspace changes are present", + )); + }; + Some(handle_commit_slash_command(message, cwd)?) + } else { + None + }; + + let branch_diff = git_stdout( + cwd, + &["diff", "--stat", &format!("{default_branch}...HEAD")], + )?; + if branch_diff.trim().is_empty() { + return Ok( + "Commit/Push/PR\n Result skipped\n Reason no branch changes to push or open as a pull request" + .to_string(), + ); + } + + git_status_ok(cwd, &["push", "--set-upstream", "origin", branch.as_str()])?; + + let body_path = write_temp_text_file("claw-pr-body", "md", request.pr_body.trim())?; + let body_path_string = body_path.to_string_lossy().into_owned(); + let create = Command::new("gh") + .args([ + "pr", + "create", + "--title", + request.pr_title.as_str(), + "--body-file", + body_path_string.as_str(), + "--base", + default_branch.as_str(), + ]) + .current_dir(cwd) + .output()?; + + let (result, url) = if create.status.success() { + ( + "created", + parse_pr_url(&String::from_utf8_lossy(&create.stdout)) + .unwrap_or_else(|| "".to_string()), + ) + } else { + let view = Command::new("gh") + .args(["pr", "view", "--json", "url"]) + .current_dir(cwd) + .output()?; + if !view.status.success() { + return Err(io::Error::other(command_failure( + "gh", + &["pr", "create"], + &create, + ))); + } + ( + "existing", + parse_pr_json_url(&String::from_utf8_lossy(&view.stdout)) + .unwrap_or_else(|| "".to_string()), + ) + }; + + let mut lines = vec![ + "Commit/Push/PR".to_string(), + format!(" Result {result}"), + format!(" Branch {branch}"), + format!(" Base {default_branch}"), + format!(" Body file {}", body_path.display()), + format!(" URL {url}"), + ]; + if created_branch { + lines.insert(2, " Branch action created and switched".to_string()); + } + if let Some(report) = commit_report { + lines.push(String::new()); + lines.push(report); + } + Ok(lines.join("\n")) +} + +pub fn detect_default_branch(cwd: &Path) -> io::Result { + if let Ok(reference) = git_stdout(cwd, &["symbolic-ref", "refs/remotes/origin/HEAD"]) { + if let Some(branch) = reference + .trim() + .rsplit('/') + .next() + .filter(|value| !value.is_empty()) + { + return Ok(branch.to_string()); + } + } + + for branch in ["main", "master"] { + if branch_exists(cwd, branch) { + return Ok(branch.to_string()); + } + } + + current_branch(cwd) +} + +fn git_stdout(cwd: &Path, args: &[&str]) -> io::Result { + run_command_stdout("git", args, cwd) +} + +fn git_status_ok(cwd: &Path, args: &[&str]) -> io::Result<()> { + run_command_success("git", args, cwd) +} + +fn run_command_stdout(program: &str, args: &[&str], cwd: &Path) -> io::Result { + let output = Command::new(program).args(args).current_dir(cwd).output()?; + if !output.status.success() { + return Err(io::Error::other(command_failure(program, args, &output))); + } + String::from_utf8(output.stdout) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn run_command_success(program: &str, args: &[&str], cwd: &Path) -> io::Result<()> { + let output = Command::new(program).args(args).current_dir(cwd).output()?; + if !output.status.success() { + return Err(io::Error::other(command_failure(program, args, &output))); + } + Ok(()) +} + +fn command_failure(program: &str, args: &[&str], output: &std::process::Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if stderr.is_empty() { stdout } else { stderr }; + if detail.is_empty() { + format!("{program} {} failed", args.join(" ")) + } else { + format!("{program} {} failed: {detail}", args.join(" ")) + } +} + +fn branch_exists(cwd: &Path, branch: &str) -> bool { + Command::new("git") + .args([ + "show-ref", + "--verify", + "--quiet", + &format!("refs/heads/{branch}"), + ]) + .current_dir(cwd) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn current_branch(cwd: &Path) -> io::Result { + let branch = git_stdout(cwd, &["branch", "--show-current"])?; + let branch = branch.trim(); + if branch.is_empty() { + Err(io::Error::other("unable to determine current git branch")) + } else { + Ok(branch.to_string()) + } +} + +fn command_exists(name: &str) -> bool { + Command::new(name) + .arg("--version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn write_temp_text_file(prefix: &str, extension: &str, contents: &str) -> io::Result { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let path = env::temp_dir().join(format!("{prefix}-{nanos}.{extension}")); + fs::write(&path, contents)?; + Ok(path) +} + +fn build_branch_name(hint: &str) -> String { + let slug = slugify(hint); + let owner = env::var("SAFEUSER") + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + env::var("USER") + .ok() + .filter(|value| !value.trim().is_empty()) + }); + match owner { + Some(owner) => format!("{owner}/{slug}"), + None => slug, + } +} + +fn slugify(value: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + last_was_dash = false; + } else if !last_was_dash { + slug.push('-'); + last_was_dash = true; + } + } + let slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { + "change".to_string() + } else { + slug + } +} + +fn parse_pr_url(stdout: &str) -> Option { + stdout + .lines() + .map(str::trim) + .find(|line| line.starts_with("http://") || line.starts_with("https://")) + .map(ToOwned::to_owned) +} + +fn parse_pr_json_url(stdout: &str) -> Option { + serde_json::from_str::(stdout) + .ok()? + .get("url")? + .as_str() + .map(ToOwned::to_owned) +} + #[must_use] pub fn render_plugins_report(plugins: &[PluginSummary]) -> String { let mut lines = vec!["Plugins".to_string()]; @@ -1181,8 +1615,11 @@ pub fn handle_slash_command( session: session.clone(), }), SlashCommand::Status + | SlashCommand::Branch { .. } | SlashCommand::Bughunter { .. } + | SlashCommand::Worktree { .. } | SlashCommand::Commit + | SlashCommand::CommitPushPr { .. } | SlashCommand::Pr { .. } | SlashCommand::Issue { .. } | SlashCommand::Ultraplan { .. } @@ -1210,17 +1647,25 @@ pub fn handle_slash_command( #[cfg(test)] mod tests { use super::{ - handle_plugins_slash_command, handle_slash_command, load_agents_from_roots, - load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report, + handle_branch_slash_command, handle_commit_push_pr_slash_command, + handle_commit_slash_command, handle_plugins_slash_command, handle_slash_command, + handle_worktree_slash_command, load_agents_from_roots, load_skills_from_roots, + render_agents_report, render_plugins_report, render_skills_report, render_slash_command_help, resume_supported_slash_commands, slash_command_specs, - DefinitionSource, SkillOrigin, SkillRoot, SlashCommand, + CommitPushPrRequest, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand, }; use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary}; use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; + use std::env; use std::fs; use std::path::{Path, PathBuf}; + use std::process::Command; + use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + fn temp_dir(label: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1229,6 +1674,91 @@ mod tests { std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}")) } + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock") + } + + fn run_command(cwd: &Path, program: &str, args: &[&str]) -> String { + let output = Command::new(program) + .args(args) + .current_dir(cwd) + .output() + .expect("command should run"); + assert!( + output.status.success(), + "{} {} failed: {}", + program, + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("stdout should be utf8") + } + + fn init_git_repo(label: &str) -> PathBuf { + let root = temp_dir(label); + fs::create_dir_all(&root).expect("repo root"); + + let init = Command::new("git") + .args(["init", "-b", "main"]) + .current_dir(&root) + .output() + .expect("git init should run"); + if !init.status.success() { + let fallback = Command::new("git") + .arg("init") + .current_dir(&root) + .output() + .expect("fallback git init should run"); + assert!( + fallback.status.success(), + "fallback git init should succeed" + ); + let rename = Command::new("git") + .args(["branch", "-m", "main"]) + .current_dir(&root) + .output() + .expect("git branch -m should run"); + assert!(rename.status.success(), "git branch -m main should succeed"); + } + + run_command(&root, "git", &["config", "user.name", "Claw Tests"]); + run_command(&root, "git", &["config", "user.email", "claw@example.com"]); + fs::write(root.join("README.md"), "seed\n").expect("seed file"); + run_command(&root, "git", &["add", "README.md"]); + run_command(&root, "git", &["commit", "-m", "chore: seed repo"]); + root + } + + fn init_bare_repo(label: &str) -> PathBuf { + let root = temp_dir(label); + let output = Command::new("git") + .args(["init", "--bare"]) + .arg(&root) + .output() + .expect("bare repo should initialize"); + assert!(output.status.success(), "git init --bare should succeed"); + root + } + + #[cfg(unix)] + fn write_fake_gh(bin_dir: &Path, log_path: &Path, url: &str) { + fs::create_dir_all(bin_dir).expect("bin dir"); + let script = format!( + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'gh 1.0.0'\n exit 0\nfi\nprintf '%s\\n' \"$*\" >> \"{}\"\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"create\" ]; then\n echo '{}'\n exit 0\nfi\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"view\" ]; then\n echo '{{\"url\":\"{}\"}}'\n exit 0\nfi\nexit 0\n", + log_path.display(), + url, + url, + ); + let path = bin_dir.join("gh"); + fs::write(&path, script).expect("gh stub"); + let mut permissions = fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod"); + } + fn write_external_plugin(root: &Path, name: &str, version: &str) { fs::create_dir_all(root.join(".claw-plugin")).expect("manifest dir"); fs::write( @@ -1293,7 +1823,28 @@ mod tests { scope: Some("runtime".to_string()) }) ); + assert_eq!( + SlashCommand::parse("/branch create feature/demo"), + Some(SlashCommand::Branch { + action: Some("create".to_string()), + target: Some("feature/demo".to_string()), + }) + ); + assert_eq!( + SlashCommand::parse("/worktree add ../demo wt-demo"), + Some(SlashCommand::Worktree { + action: Some("add".to_string()), + path: Some("../demo".to_string()), + branch: Some("wt-demo".to_string()), + }) + ); assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit)); + assert_eq!( + SlashCommand::parse("/commit-push-pr ready for review"), + Some(SlashCommand::CommitPushPr { + context: Some("ready for review".to_string()) + }) + ); assert_eq!( SlashCommand::parse("/pr ready for review"), Some(SlashCommand::Pr { @@ -1418,7 +1969,10 @@ mod tests { assert!(help.contains("/status")); assert!(help.contains("/compact")); assert!(help.contains("/bughunter [scope]")); + assert!(help.contains("/branch [list|create |switch ]")); + assert!(help.contains("/worktree [list|add [branch]|remove |prune]")); assert!(help.contains("/commit")); + assert!(help.contains("/commit-push-pr [context]")); assert!(help.contains("/pr [context]")); assert!(help.contains("/issue [context]")); assert!(help.contains("/ultraplan [task]")); @@ -1442,7 +1996,7 @@ mod tests { assert!(help.contains("aliases: /plugins, /marketplace")); assert!(help.contains("/agents")); assert!(help.contains("/skills")); - assert_eq!(slash_command_specs().len(), 25); + assert_eq!(slash_command_specs().len(), 28); assert_eq!(resume_supported_slash_commands().len(), 13); } @@ -1490,10 +2044,22 @@ mod tests { let session = Session::new(); assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none()); + assert!( + handle_slash_command("/branch list", &session, CompactionConfig::default()).is_none() + ); assert!( handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none() ); + assert!( + handle_slash_command("/worktree list", &session, CompactionConfig::default()).is_none() + ); assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none()); + assert!(handle_slash_command( + "/commit-push-pr review notes", + &session, + CompactionConfig::default() + ) + .is_none()); assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none()); assert!( @@ -1805,4 +2371,141 @@ mod tests { let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(bundled_root); } + + #[test] + fn branch_and_worktree_commands_manage_git_state() { + // given + let repo = init_git_repo("branch-worktree"); + let worktree_path = repo + .parent() + .expect("repo should have parent") + .join("branch-worktree-linked"); + + // when + let branch_list = + handle_branch_slash_command(Some("list"), None, &repo).expect("branch list succeeds"); + let created = handle_branch_slash_command(Some("create"), Some("feature/demo"), &repo) + .expect("branch create succeeds"); + let switched = handle_branch_slash_command(Some("switch"), Some("main"), &repo) + .expect("branch switch succeeds"); + let added = handle_worktree_slash_command( + Some("add"), + Some(worktree_path.to_str().expect("utf8 path")), + Some("wt-demo"), + &repo, + ) + .expect("worktree add succeeds"); + let listed_worktrees = + handle_worktree_slash_command(Some("list"), None, None, &repo).expect("list succeeds"); + let removed = handle_worktree_slash_command( + Some("remove"), + Some(worktree_path.to_str().expect("utf8 path")), + None, + &repo, + ) + .expect("remove succeeds"); + + // then + assert!(branch_list.contains("main")); + assert!(created.contains("feature/demo")); + assert!(switched.contains("main")); + assert!(added.contains("wt-demo")); + assert!(listed_worktrees.contains(worktree_path.to_str().expect("utf8 path"))); + assert!(removed.contains("Result removed")); + + let _ = fs::remove_dir_all(repo); + let _ = fs::remove_dir_all(worktree_path); + } + + #[test] + fn commit_command_stages_and_commits_changes() { + // given + let repo = init_git_repo("commit-command"); + fs::write(repo.join("notes.txt"), "hello\n").expect("write notes"); + + // when + let report = + handle_commit_slash_command("feat: add notes", &repo).expect("commit succeeds"); + let status = run_command(&repo, "git", &["status", "--short"]); + let message = run_command(&repo, "git", &["log", "-1", "--pretty=%B"]); + + // then + assert!(report.contains("Result created")); + assert!(status.trim().is_empty()); + assert_eq!(message.trim(), "feat: add notes"); + + let _ = fs::remove_dir_all(repo); + } + + #[cfg(unix)] + #[test] + fn commit_push_pr_command_commits_pushes_and_creates_pr() { + // given + let _guard = env_lock(); + let repo = init_git_repo("commit-push-pr"); + let remote = init_bare_repo("commit-push-pr-remote"); + run_command( + &repo, + "git", + &[ + "remote", + "add", + "origin", + remote.to_str().expect("utf8 remote"), + ], + ); + run_command(&repo, "git", &["push", "-u", "origin", "main"]); + fs::write(repo.join("feature.txt"), "feature\n").expect("write feature file"); + + let fake_bin = temp_dir("fake-gh-bin"); + let gh_log = fake_bin.join("gh.log"); + write_fake_gh(&fake_bin, &gh_log, "https://example.com/pr/123"); + + let previous_path = env::var_os("PATH"); + let mut new_path = fake_bin.display().to_string(); + if let Some(path) = &previous_path { + new_path.push(':'); + new_path.push_str(&path.to_string_lossy()); + } + env::set_var("PATH", &new_path); + let previous_safeuser = env::var_os("SAFEUSER"); + env::set_var("SAFEUSER", "tester"); + + let request = CommitPushPrRequest { + commit_message: Some("feat: add feature file".to_string()), + pr_title: "Add feature file".to_string(), + pr_body: "## Summary\n- add feature file".to_string(), + branch_name_hint: "Add feature file".to_string(), + }; + + // when + let report = + handle_commit_push_pr_slash_command(&request, &repo).expect("commit-push-pr succeeds"); + let branch = run_command(&repo, "git", &["branch", "--show-current"]); + let message = run_command(&repo, "git", &["log", "-1", "--pretty=%B"]); + let gh_invocations = fs::read_to_string(&gh_log).expect("gh log should exist"); + + // then + assert!(report.contains("Result created")); + assert!(report.contains("URL https://example.com/pr/123")); + assert_eq!(branch.trim(), "tester/add-feature-file"); + assert_eq!(message.trim(), "feat: add feature file"); + assert!(gh_invocations.contains("pr create")); + assert!(gh_invocations.contains("--base main")); + + if let Some(path) = previous_path { + env::set_var("PATH", path); + } else { + env::remove_var("PATH"); + } + if let Some(safeuser) = previous_safeuser { + env::set_var("SAFEUSER", safeuser); + } else { + env::remove_var("SAFEUSER"); + } + + let _ = fs::remove_dir_all(repo); + let _ = fs::remove_dir_all(remote); + let _ = fs::remove_dir_all(fake_bin); + } }