Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions src/cmds/git/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -23,6 +24,7 @@ pub enum GitCommand {
Show,
Add,
Commit,
Checkout,
Push,
Pull,
Branch,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<i32> {
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<usize> {
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:",
Expand Down
8 changes: 8 additions & 0 deletions src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
2 changes: 1 addition & 1 deletion src/discover/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,12 @@ enum GitCommands {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// 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<String>,
},
/// Push → "ok \<branch\>"
Push {
/// Git push arguments (supports -u, remote, branch, etc.)
Expand Down Expand Up @@ -1685,6 +1691,13 @@ fn run_cli() -> Result<i32> {
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,
Expand Down
Loading
Loading