diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 3bd479127e..9936a061d8 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -2,6 +2,7 @@ use crate::core::args_utils; use crate::core::guard::never_worse; +use crate::core::runner::{self, RunOptions}; use crate::core::stream::{ self, exec_capture, CaptureResult, FilterMode, LineHandler, LineStreamFilter, StdinMode, }; @@ -23,6 +24,7 @@ pub enum GitCommand { Show, Add, Commit, + Checkout, Push, Pull, Branch, @@ -95,6 +97,7 @@ pub fn run( GitCommand::Show => run_show(args, max_lines, verbose, global_args), GitCommand::Add => run_add(args, verbose, global_args), GitCommand::Commit => run_commit(args, verbose, global_args), + GitCommand::Checkout => run_checkout(args, verbose, global_args), GitCommand::Push => run_push(args, verbose, global_args), GitCommand::Pull => run_pull(args, verbose, global_args), GitCommand::Branch => run_branch(args, verbose, global_args), @@ -1080,6 +1083,183 @@ fn classify_commit_outcome(success: bool, stdout: &str, exit_code: i32) -> Commi } } +fn run_checkout(args: &[String], verbose: u8, global_args: &[String]) -> Result { + let args = args_utils::restore_double_dash(args); + + if verbose > 0 { + eprintln!("git checkout"); + } + + let mut cmd = git_cmd(global_args); + cmd.arg("checkout"); + for arg in &args { + cmd.arg(arg); + } + + let args_display = args.join(" "); + let args_for_filter = args.clone(); + runner::run_filtered_with_exit( + cmd, + "git checkout", + &args_display, + move |raw, exit_code| format_checkout_output(&args_for_filter, raw, exit_code), + RunOptions::with_tee("git_checkout"), + ) +} + +fn format_checkout_output(args: &[String], raw: &str, exit_code: i32) -> String { + if exit_code == 0 { + format_checkout_success(args, raw) + } else { + filter_checkout_failure(raw) + } +} + +fn format_checkout_success(args: &[String], raw: &str) -> String { + if let Some(restored) = checkout_restored_count(args) { + return format!("ok {} {}", restored, pluralize(restored, "file restored", "files restored")); + } + if let Some(branch) = checkout_reset_branch_arg(args) { + return format!("ok {}", branch); + } + + for line in raw.lines().map(str::trim) { + if let Some(branch) = quoted_suffix(line, "Switched to a new branch ") { + return format!("ok {} (new)", branch); + } + if let Some(branch) = quoted_suffix(line, "Switched to branch ") { + return format!("ok {}", branch); + } + if let Some(branch) = quoted_suffix(line, "Already on ") { + return format!("ok {}", branch); + } + if let Some(rest) = line.strip_prefix("HEAD is now at ") { + let hash = rest.split_whitespace().next().unwrap_or("HEAD"); + return format!("ok HEAD {}", hash); + } + if line.starts_with("Updated ") && line.contains(" path") { + return format!("ok {}", line.to_ascii_lowercase()); + } + } + + if let Some(branch) = checkout_new_branch_arg(args) { + return format!("ok {} (new)", branch); + } + if let Some(branch) = checkout_branch_arg(args) { + return format!("ok {}", branch); + } + + "ok".to_string() +} + +fn checkout_restored_count(args: &[String]) -> Option { + let separator = args.iter().position(|arg| arg == "--")?; + let count = args[separator + 1..] + .iter() + .filter(|arg| !arg.is_empty()) + .count(); + (count > 0).then_some(count) +} + +fn checkout_new_branch_arg(args: &[String]) -> Option<&str> { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "-b" | "--orphan" => return iter.next().map(String::as_str), + "-B" => { + iter.next(); + } + _ => { + if let Some(branch) = arg.strip_prefix("--orphan=") { + return Some(branch); + } + } + } + } + None +} + +fn checkout_reset_branch_arg(args: &[String]) -> Option<&str> { + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == "-B" { + return iter.next().map(String::as_str); + } + } + None +} + +fn checkout_branch_arg(args: &[String]) -> Option<&str> { + if args.iter().any(|arg| arg == "--") { + return None; + } + + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "-b" | "-B" | "--orphan" => { + iter.next(); + } + "-t" | "--track" | "--detach" => {} + _ if arg.starts_with('-') => {} + _ => return Some(arg), + } + } + None +} + +fn quoted_suffix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { + line.strip_prefix(prefix) + .and_then(|rest| rest.strip_prefix('\'')) + .and_then(|rest| rest.strip_suffix('\'')) +} + +fn pluralize<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str { + if count == 1 { singular } else { plural } +} + +fn filter_checkout_failure(raw: &str) -> String { + let mut important = Vec::new(); + let mut in_file_list = false; + + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let is_header = trimmed.starts_with("error:") + || trimmed.starts_with("fatal:") + || trimmed.starts_with("CONFLICT"); + + if is_header { + in_file_list = + trimmed.contains("following") && trimmed.contains("files") && trimmed.ends_with(':'); + important.push(trimmed.to_string()); + continue; + } + + if in_file_list { + if trimmed.starts_with("Please ") || trimmed.starts_with("Aborting") { + in_file_list = false; + } else if line.starts_with(char::is_whitespace) { + important.push(line.to_string()); + continue; + } + } + + if trimmed.starts_with("Aborting") { + important.push(trimmed.to_string()); + } + } + + if important.is_empty() { + raw.trim().to_string() + } else { + important.join("\n") + } +} + // Git push progress prefixes (stderr) — dropped from the stream. const GIT_PUSH_NOISE_PREFIXES: &[&str] = &[ "Enumerating objects:", diff --git a/src/discover/registry.rs b/src/discover/registry.rs index c150ef9e4d..6a7e9e89ff 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1345,6 +1345,14 @@ mod tests { ); } + #[test] + fn test_rewrite_git_checkout() { + assert_eq!( + rewrite_command_no_prefixes("git checkout main", &[]), + Some("rtk git checkout main".into()) + ); + } + #[test] fn test_rewrite_git_log() { assert_eq!( diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 76d71cf9f6..9de6e45b01 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -12,7 +12,7 @@ pub struct RtkRule { pub const RULES: &[RtkRule] = &[ RtkRule { - pattern: r"^(?:git|yadm)\s+(?:-[Cc]\s+\S+\s+)*(status|log|diff|show|add|commit|push|pull|branch|fetch|stash|worktree)", + pattern: r"^(?:git|yadm)\s+(?:-[Cc]\s+\S+\s+)*(status|log|diff|show|add|commit|checkout|push|pull|branch|fetch|stash|worktree)", rtk_cmd: "rtk git", rewrite_prefixes: &["git", "yadm"], category: "Git", diff --git a/src/main.rs b/src/main.rs index 0cb5beb509..f1e28abd82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -902,6 +902,12 @@ enum GitCommands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + /// Checkout branch or restore paths → "ok" + Checkout { + /// Git checkout arguments (supports -b, branch names, refs, -- paths) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, /// Push → "ok \" Push { /// Git push arguments (supports -u, remote, branch, etc.) @@ -1685,6 +1691,13 @@ fn run_cli() -> Result { cli.verbose, &global_args, )?, + GitCommands::Checkout { args } => git::run( + git::GitCommand::Checkout, + &args, + None, + cli.verbose, + &global_args, + )?, GitCommands::Push { args } => git::run( git::GitCommand::Push, &args, diff --git a/tests/guard_integration_test.rs b/tests/guard_integration_test.rs index b776918003..6618e13437 100644 --- a/tests/guard_integration_test.rs +++ b/tests/guard_integration_test.rs @@ -56,19 +56,24 @@ fn guard_does_not_block_real_compression() { ); } -fn rtk_in_dir(dir: &std::path::Path, args: &[&str]) -> (String, Option) { +fn rtk_output_in_dir(dir: &std::path::Path, args: &[&str]) -> (String, String, Option) { let out = Command::new(env!("CARGO_BIN_EXE_rtk")) .args(args) .current_dir(dir) - .stderr(Stdio::null()) .output() .expect("spawn rtk"); ( String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), out.status.code(), ) } +fn rtk_in_dir(dir: &std::path::Path, args: &[&str]) -> (String, Option) { + let (stdout, _, code) = rtk_output_in_dir(dir, args); + (stdout, code) +} + fn rg_available() -> bool { Command::new("rg") .arg("--version") @@ -80,7 +85,7 @@ fn rg_available() -> bool { fn init_git_repo() -> tempfile::TempDir { let dir = tempfile::tempdir().expect("tempdir"); for args in [ - &["init", "-q"][..], + &["init", "-q", "-b", "main"][..], &["config", "user.email", "t@t.t"][..], &["config", "user.name", "t"][..], &["commit", "-q", "--allow-empty", "-m", "init"][..], @@ -96,6 +101,24 @@ fn init_git_repo() -> tempfile::TempDir { dir } +fn git_in_dir(dir: &std::path::Path, args: &[&str]) { + let out = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("spawn git"); + assert!( + out.status.success(), + "git command failed: {args:?}\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} + +fn read_text_normalized(path: &std::path::Path) -> String { + std::fs::read_to_string(path).unwrap().replace("\r\n", "\n") +} + #[test] fn grep_no_match_emits_empty_not_a_message() { if !rg_available() { @@ -151,3 +174,96 @@ fn git_stash_show_no_stash_emits_empty_and_propagates_failure() { "a real git stash show failure must not be masked as exit 0" ); } + +#[test] +fn git_checkout_branch_switch_emits_compact_ok() { + let dir = init_git_repo(); + git_in_dir(dir.path(), &["checkout", "-q", "-b", "feature/test"]); + + let (out, code) = rtk_in_dir(dir.path(), &["git", "checkout", "main"]); + + assert_eq!(code, Some(0)); + assert_eq!(out.trim(), "ok main"); +} + +#[test] +fn git_checkout_new_branch_emits_compact_ok() { + let dir = init_git_repo(); + + let (out, code) = rtk_in_dir(dir.path(), &["git", "checkout", "-b", "feature/test"]); + + assert_eq!(code, Some(0)); + assert_eq!(out.trim(), "ok feature/test (new)"); +} + +#[test] +fn git_checkout_reset_branch_does_not_claim_new_branch() { + let dir = init_git_repo(); + + let (out, code) = rtk_in_dir(dir.path(), &["git", "checkout", "-B", "feature/test"]); + + assert_eq!(code, Some(0)); + assert_eq!(out.trim(), "ok feature/test"); +} + +#[test] +fn git_checkout_file_restore_emits_restored_count() { + let dir = init_git_repo(); + std::fs::write(dir.path().join("a.txt"), "original\n").expect("write a"); + std::fs::write(dir.path().join("b.txt"), "original\n").expect("write b"); + git_in_dir(dir.path(), &["add", "a.txt", "b.txt"]); + git_in_dir(dir.path(), &["commit", "-q", "-m", "add files"]); + + std::fs::write(dir.path().join("a.txt"), "changed\n").expect("write a"); + std::fs::write(dir.path().join("b.txt"), "changed\n").expect("write b"); + + let (out, code) = rtk_in_dir( + dir.path(), + &["git", "checkout", "HEAD", "--", "a.txt", "b.txt"], + ); + + assert_eq!(code, Some(0)); + assert!( + out.trim().is_empty() || out.trim() == "ok 2 files restored", + "guarded output may stay empty when native git emits no success text: {out:?}" + ); + assert_eq!( + read_text_normalized(&dir.path().join("a.txt")), + "original\n" + ); + assert_eq!( + read_text_normalized(&dir.path().join("b.txt")), + "original\n" + ); +} + +#[test] +fn git_checkout_dirty_tree_error_keeps_file_list() { + let dir = init_git_repo(); + std::fs::write(dir.path().join("a.txt"), "main\n").expect("write a"); + git_in_dir(dir.path(), &["add", "a.txt"]); + git_in_dir(dir.path(), &["commit", "-q", "-m", "add a"]); + git_in_dir(dir.path(), &["checkout", "-q", "-b", "feature/test"]); + std::fs::write(dir.path().join("a.txt"), "feature\n").expect("write feature"); + git_in_dir(dir.path(), &["commit", "-am", "feature change"]); + git_in_dir(dir.path(), &["checkout", "-q", "main"]); + std::fs::write(dir.path().join("a.txt"), "dirty\n").expect("write dirty"); + + let (stdout, stderr, code) = + rtk_output_in_dir(dir.path(), &["git", "checkout", "feature/test"]); + let combined = format!("{stdout}{stderr}"); + + assert_ne!(code, Some(0)); + assert!( + combined.contains("error:"), + "dirty checkout failure should keep error header: {combined:?}" + ); + assert!( + combined.contains("a.txt"), + "dirty checkout failure should keep conflicting filename: {combined:?}" + ); + assert!( + combined.contains("Aborting"), + "dirty checkout failure should keep abort line: {combined:?}" + ); +}