From 452af2a7fe46f880390ab23bc9b118662e7cb310 Mon Sep 17 00:00:00 2001 From: "Gonzo Gonzalez Cunningham (Daniel)" Date: Wed, 18 Mar 2026 21:38:32 -0700 Subject: [PATCH 1/3] Improve worktree remove robustness and pipe safety - cd out of worktree before removal to release OS directory lock - Look up actual checked-out branch from porcelain output for cleanup - Remove --prefix flag from remove (no longer needed) - Run git worktree prune after removal for clean branch deletion - Detect dirty worktrees and suggest --force with exact command - Guard against removing the main worktree with friendly error - Add git_command_status_quiet to suppress stdout in pipe contexts - Shared emit_cd helper with terminal-aware pipe hints Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main.rs | 12 ++----- src/worktree.rs | 94 +++++++++++++++++++++++++++++++++---------------- 2 files changed, 67 insertions(+), 39 deletions(-) diff --git a/src/main.rs b/src/main.rs index d98fb61..2fd6e49 100644 --- a/src/main.rs +++ b/src/main.rs @@ -116,10 +116,6 @@ enum WorktreeSubcommand { /// Remove a worktree and its associated branch. #[clap(visible_alias = "r")] Remove { - /// Optional prefix used when the branch was created. - #[clap(long, env = LOKI_NEW_PREFIX)] - prefix: Option, - /// Force removal of a dirty worktree. #[clap(short, long)] force: bool, @@ -239,11 +235,9 @@ fn main() -> Result<(), String> { WorktreeSubcommand::Add { name, base, prefix } => { worktree::worktree_add(name, base, prefix.as_deref()) } - WorktreeSubcommand::Remove { - name, - force, - prefix, - } => worktree::worktree_remove(name, *force, prefix.as_deref()), + WorktreeSubcommand::Remove { name, force } => { + worktree::worktree_remove(name, *force) + } WorktreeSubcommand::List => worktree::worktree_list(), WorktreeSubcommand::Switch { name } => worktree::worktree_switch(name), }, diff --git a/src/worktree.rs b/src/worktree.rs index fae4861..29fd613 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -63,6 +63,19 @@ fn normalize_path(path: &str) -> String { path.replace('\\', "/") } +/// Outputs `cd ` to stdout. When stdout is a terminal (not piped), +/// prints a platform-appropriate tip for piping. +fn emit_cd(path: &str) { + println!("cd {path}"); + if std::io::stdout().is_terminal() { + let hint = if cfg!(windows) { "| iex" } else { "through eval" }; + eprintln!( + "\n{}", + format!("Tip: pipe this command {hint} to switch automatically.").dimmed() + ); + } +} + /// 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 { @@ -184,7 +197,7 @@ pub fn worktree_add(name: &[String], base: &str, prefix: Option<&str>) -> Result )?; eprintln!("\n{}", "Worktree ready!".green().bold()); - println!("cd {wt_path_str}"); + emit_cd(&wt_path_str); return Ok(()); } @@ -210,7 +223,7 @@ pub fn worktree_add(name: &[String], base: &str, prefix: Option<&str>) -> Result )?; eprintln!("\n{}", "Worktree ready!".green().bold()); - println!("cd {wt_path_str}"); + emit_cd(&wt_path_str); Ok(()) } @@ -218,7 +231,7 @@ pub fn worktree_add(name: &[String], base: &str, prefix: Option<&str>) -> Result /// Removes a worktree and deletes its local branch. If `name` is empty the /// worktree name is inferred from the current directory. Outputs `cd
` /// to stdout for piping. -pub fn worktree_remove(name: &[String], force: bool, prefix: Option<&str>) -> Result<(), String> { +pub fn worktree_remove(name: &[String], force: bool) -> Result<(), String> { let main_root = resolve_main_worktree()?; let name = if name.is_empty() { @@ -256,38 +269,69 @@ pub fn worktree_remove(name: &[String], force: bool, prefix: Option<&str>) -> Re }; let wt_path_str = wt_path.to_string_lossy(); + // Don't allow removing the main worktree + if normalize_path(&wt_path_str) == normalize_path(&main_root) { + return Err(String::from( + "You're in the main repo - only secondary worktrees can be removed.", + )); + } + + // Look up the actual branch checked out in this worktree before removing + let actual_branch = list_worktree_entries() + .ok() + .and_then(|entries| { + let normalized_target = normalize_path(&wt_path_str); + entries + .into_iter() + .find(|e| normalize_path(&e.path) == normalized_target) + .and_then(|e| e.branch) + }); + + // Move out of the worktree so the OS can delete it if let Ok(cwd) = std::env::current_dir() { if cwd.starts_with(&wt_path) { eprintln!( - "{} You are inside the worktree being removed.", - "Warning:".yellow().bold(), + "Leaving worktree directory, moving to {}", + main_root.green() ); + std::env::set_current_dir(&main_root) + .map_err(|e| format!("Failed to change to main worktree: {e}"))?; } } + // Attempt worktree removal β€” retry with --force on dirty worktree errors let mut remove_args = vec!["worktree", "remove"]; if force { remove_args.push("--force"); } remove_args.push(wt_path_str.as_ref()); - git_command_status_quiet("worktree remove", remove_args)?; - eprintln!("Removed worktree {}", wt_path_str.red()); - // Best-effort branch cleanup β€” may already be gone - let branch = match prefix { - Some(p) => format!("{p}{name}"), - None => name, - }; + if let Err(err) = git_command_status_quiet("worktree remove", remove_args) { + if !force && (err.contains("modified or untracked") || err.contains("contains modified")) { + return Err(format!( + "Worktree has uncommitted changes. Run with --force to remove anyway:\n lk w r --force {}", + name + )); + } + return Err(err); + } + eprintln!("Removed worktree {}", wt_path_str.red()); - 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)", - branch.yellow() - ), + // Prune stale worktree refs so branch deletion succeeds + let _ = git_command_status_quiet("worktree prune", vec!["worktree", "prune"]); + + // Best-effort branch cleanup using the actual checked-out branch + if let Some(branch) = actual_branch { + 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)", + branch.yellow() + ), + } } - println!("cd {main_root}"); + emit_cd(&main_root); Ok(()) } @@ -300,17 +344,7 @@ pub fn worktree_switch(name: &[String]) -> Result<(), String> { resolve_worktree_by_name(&name.join("-"))? }; - println!("cd {target}"); - - if std::io::stdout().is_terminal() { - let example = if cfg!(windows) { - "lk w s | iex" - } else { - "eval \"$(lk w s)\"" - }; - eprintln!("\n{}", format!("Tip: pipe to switch automatically: {example}").dimmed()); - } - + emit_cd(&target); Ok(()) } /// Lists all worktrees, highlighting the current one and showing switch hints. From fb179aa49f5a6463241610894b81046759cf5555 Mon Sep 17 00:00:00 2001 From: "Gonzo Gonzalez Cunningham (Daniel)" Date: Wed, 18 Mar 2026 21:44:39 -0700 Subject: [PATCH 2/3] Bump version to 2.3.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d88b16..6ecce1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,7 +237,7 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "loki-cli" -version = "2.2.0" +version = "2.3.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 37e55f1..6757fa6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loki-cli" -version = "2.2.0" +version = "2.3.0" authors = ["Kyle W. Rader"] description = "Loki: πŸš€ A Git productivity tool" homepage = "https://github.com/kyle-rader/loki-cli" From 6538cec0d6e71a3d52d4d8f496d10c882132f2d5 Mon Sep 17 00:00:00 2001 From: "Gonzo Gonzalez Cunningham (Daniel)" Date: Fri, 20 Mar 2026 12:40:29 -0700 Subject: [PATCH 3/3] feat: fetch origin before creating new worktree branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensures the base ref (default origin/main) is up-to-date before creating the worktree, so the new branch always starts from the latest remote state. Bump version to 2.4.0 (minor β€” new backward-compatible behavior). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/worktree.rs | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ecce1f..bec7889 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,7 +237,7 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "loki-cli" -version = "2.3.0" +version = "2.4.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 6757fa6..158bc52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loki-cli" -version = "2.3.0" +version = "2.4.0" authors = ["Kyle W. Rader"] description = "Loki: πŸš€ A Git productivity tool" homepage = "https://github.com/kyle-rader/loki-cli" diff --git a/src/worktree.rs b/src/worktree.rs index 29fd613..b0336f0 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -201,7 +201,10 @@ pub fn worktree_add(name: &[String], base: &str, prefix: Option<&str>) -> Result return Ok(()); } - // New branch flow + // New branch flow β€” fetch first so the base ref is up-to-date + eprintln!("Fetching latest from origin…"); + git_command_status_quiet("fetch", vec!["fetch", "origin"])?; + eprintln!("Creating worktree at {}", wt_path_str.green()); git_command_status_quiet( "worktree add",