diff --git a/Cargo.lock b/Cargo.lock index 337b516..8d88b16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,7 +237,7 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "loki-cli" -version = "2.1.0" +version = "2.2.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 2594dfd..37e55f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loki-cli" -version = "2.1.0" +version = "2.2.0" authors = ["Kyle W. Rader"] description = "Loki: 🚀 A Git productivity tool" homepage = "https://github.com/kyle-rader/loki-cli" diff --git a/README.md b/README.md index 8d301cc..9905d66 100644 --- a/README.md +++ b/README.md @@ -108,9 +108,13 @@ Create a new worktree as a sibling directory and set up a branch with upstream t # With a branch prefix (via flag or LOKI_NEW_PREFIX env var) ❯ lk w a fix-auth --prefix users/danigon/ + +# Check out an existing remote branch into a worktree +❯ lk w a review-feature -b users/teammate/cool-feature ``` -The worktree is created at `/_` (e.g., `~/repos/my-project_fix-auth`). +If `-b` points to an existing remote branch, the branch is checked out directly +instead of creating a new one. **Flags:** - `--base` / `-b` — Base ref (default: `origin/main`, env: `LOKI_WORKTREE_BASE`) diff --git a/src/git.rs b/src/git.rs index 34a4026..e8bc14d 100644 --- a/src/git.rs +++ b/src/git.rs @@ -20,6 +20,34 @@ where Ok(()) } +/// Execute the git command with stdout suppressed. Stderr is captured and +/// included in error messages. Use when stdout must stay clean for piping. +pub fn git_command_status_quiet(name: &str, args: I) -> Result<(), String> +where + I: IntoIterator, + S: AsRef, +{ + let args: Vec<_> = args.into_iter().collect(); + let args_display: Vec<_> = args.iter().map(|a| a.as_ref().to_string_lossy()).collect(); + + let output = Command::new(GIT) + .args(&args) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| format!("{name} failed to run: git {}\n{e}", args_display.join(" ")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "{name} failed: git {}\n{}", + args_display.join(" "), + stderr.trim() + )); + } + Ok(()) +} + /// Execute the git command and return an iterator over its output lines (both stdout and stderr) as they arrive. pub fn git_command_stream(name: &str, args: I) -> Result, String> where diff --git a/src/worktree.rs b/src/worktree.rs index 5c49480..fae4861 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use colored::Colorize; -use crate::git::{git_command_iter, git_command_status, git_commands_status}; +use crate::git::{git_command_iter, git_command_lines, git_command_status_quiet}; use crate::vars::LOKI_NEW_PREFIX; // --------------------------------------------------------------------------- @@ -63,6 +63,31 @@ fn normalize_path(path: &str) -> String { path.replace('\\', "/") } +/// Checks if a ref matches an existing remote branch on origin. +/// Returns the full remote ref (e.g. `origin/branch-name`) if found. +fn find_remote_branch(name: &str) -> Option { + // Check common forms: bare name, origin/name, or full ref + let candidates = if name.starts_with("origin/") { + vec![name.to_string()] + } else if name.starts_with("refs/") { + vec![name.strip_prefix("refs/heads/").unwrap_or(name).to_string()] + } else { + vec![name.to_string()] + }; + + for candidate in &candidates { + let lines = git_command_lines( + "ls-remote", + vec!["ls-remote", "--heads", "origin", candidate.as_str()], + ) + .unwrap_or_default(); + if !lines.is_empty() { + return Some(format!("origin/{candidate}")); + } + } + None +} + /// A parsed worktree entry from porcelain output. struct WorktreeEntry { path: String, @@ -122,7 +147,8 @@ fn list_worktree_entries() -> Result, String> { // --------------------------------------------------------------------------- /// Creates a worktree at `/_`, then creates and pushes a -/// branch with optional prefix. Outputs `cd ` to stdout for piping. +/// branch with optional prefix. If the base ref is an existing remote branch, +/// checks it out directly instead. Outputs `cd ` to stdout for piping. pub fn worktree_add(name: &[String], base: &str, prefix: Option<&str>) -> Result<(), String> { if name.is_empty() { return Err(String::from("name cannot be empty.")); @@ -137,13 +163,38 @@ pub fn worktree_add(name: &[String], base: &str, prefix: Option<&str>) -> Result return Err(format!("Worktree path already exists: {wt_path_str}")); } + // Check if the base is an existing remote branch to check out directly + if let Some(remote_ref) = find_remote_branch(base) { + eprintln!( + "Found existing branch {} — checking out into worktree", + base.cyan() + ); + git_command_status_quiet("fetch", vec!["fetch", "origin"])?; + git_command_status_quiet( + "worktree add", + vec![ + "worktree", + "add", + "--track", + "-b", + base, + wt_path_str.as_ref(), + remote_ref.as_str(), + ], + )?; + + eprintln!("\n{}", "Worktree ready!".green().bold()); + println!("cd {wt_path_str}"); + return Ok(()); + } + + // New branch flow eprintln!("Creating worktree at {}", wt_path_str.green()); - git_command_status( + git_command_status_quiet( "worktree add", vec!["worktree", "add", wt_path_str.as_ref(), base], )?; - // Set process cwd so branch creation runs inside the new worktree std::env::set_current_dir(&wt_path) .map_err(|e| format!("Failed to enter worktree directory: {e}"))?; @@ -152,13 +203,11 @@ pub fn worktree_add(name: &[String], base: &str, prefix: Option<&str>) -> Result name = format!("{prefix}{name}"); } - git_commands_status(vec![ - ("create branch", vec!["switch", "--create", name.as_str()]), - ( - "push to origin", - vec!["push", "--set-upstream", "origin", name.as_str()], - ), - ])?; + git_command_status_quiet("create branch", vec!["switch", "--create", name.as_str()])?; + git_command_status_quiet( + "push to origin", + vec!["push", "--set-upstream", "origin", name.as_str()], + )?; eprintln!("\n{}", "Worktree ready!".green().bold()); println!("cd {wt_path_str}"); @@ -221,7 +270,7 @@ pub fn worktree_remove(name: &[String], force: bool, prefix: Option<&str>) -> Re remove_args.push("--force"); } remove_args.push(wt_path_str.as_ref()); - git_command_status("worktree remove", remove_args)?; + git_command_status_quiet("worktree remove", remove_args)?; eprintln!("Removed worktree {}", wt_path_str.red()); // Best-effort branch cleanup — may already be gone @@ -230,7 +279,7 @@ pub fn worktree_remove(name: &[String], force: bool, prefix: Option<&str>) -> Re None => name, }; - match git_command_status("delete branch", vec!["branch", "-D", branch.as_str()]) { + match git_command_status_quiet("delete branch", vec!["branch", "-D", branch.as_str()]) { Ok(()) => eprintln!("Deleted branch {}", branch.red()), Err(_) => eprintln!( "Branch {} not found locally (may already be deleted)",