From cc41ae33b794c539aa66c44b9acc53d332753379 Mon Sep 17 00:00:00 2001 From: "Gonzo Gonzalez Cunningham (Daniel)" Date: Fri, 13 Mar 2026 20:04:29 -0700 Subject: [PATCH 1/8] feat: add git worktree management commands Add lk worktree subcommand group (alias: lk w) with four subcommands: - add: create worktree + branch with upstream tracking - remove: remove worktree + local branch - list: list worktrees with current highlighted - switch: output cd command for eval-based directory switching All commands output cd to stdout for piping: lk w a fix-auth | Invoke-Expression (PowerShell) eval "$(lk w a fix-auth)" (bash/zsh) Also adds src/vars.rs for shared constants and src/git.rs helper git_command_stdout for capturing command output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 98 +++++++++++++++-- src/git.rs | 21 ++++ src/main.rs | 72 +++++++++++- src/vars.rs | 8 ++ src/worktree.rs | 284 ++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 473 insertions(+), 14 deletions(-) create mode 100644 src/vars.rs create mode 100644 src/worktree.rs diff --git a/Cargo.lock b/Cargo.lock index 1041d08..1971016 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,7 +237,7 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "loki-cli" -version = "2.0.0" +version = "2.1.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 637783d..2594dfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "loki-cli" -version = "2.0.0" +version = "2.1.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 346afba..7ac3493 100644 --- a/README.md +++ b/README.md @@ -22,15 +22,16 @@ Loki: 🚀 A Git productivity tool Usage: lk Commands: - new Create a new branch from HEAD and push it to origin. Set a prefix with --prefix or the LOKI_NEW_PREFIX env var [aliases: n] - push Push the current branch to origin with --set-upstream [aliases: p] - pull Pull with --prune deleting local branches pruned from the remote - fetch Fetch with --prune deleting local branches pruned from the remote - save Add, commit, and push using a timestamp based commit message [aliases: s] - commit Commit local changes [aliases: c] - rebase Rebase the current branch onto the target branch after fetching - no-hooks Run any command without triggering any hooks [aliases: x] - help Print this message or the help of the given subcommand(s) + new Create a new branch from HEAD and push it to origin. Set a prefix with --prefix or the LOKI_NEW_PREFIX env var [aliases: n] + push Push the current branch to origin with --set-upstream [aliases: p] + pull Pull with --prune deleting local branches pruned from the remote + fetch Fetch with --prune deleting local branches pruned from the remote + save Add, commit, and push using a timestamp based commit message [aliases: s] + commit Commit local changes [aliases: c] + rebase Rebase the current branch onto the target branch after fetching + worktree Manage git worktrees [aliases: w] + no-hooks Run any command without triggering any hooks [aliases: x] + help Print this message or the help of the given subcommand(s) Options: -h, --help Print help @@ -90,6 +91,85 @@ Execute a git commit without running any hooks lk x -- commit -m "Update Readme without running hooks" ``` +### `worktree` +Alias: `w` + +Manage git worktrees for parallel development workflows. Subcommands: + +#### `worktree add ` (alias: `a`) +Create a new worktree as a sibling directory and set up a branch with upstream tracking. + +```sh +# Creates worktree at ../my-project_fix-auth, creates and pushes branch +❯ lk w a fix-auth + +# With a custom base ref +❯ lk w a fix-auth --base origin/develop + +# With a branch prefix (via flag or LOKI_NEW_PREFIX env var) +❯ lk w a fix-auth --prefix users/danigon/ +``` + +The worktree is created at `/_` (e.g., `~/repos/my-project_fix-auth`). + +**Flags:** +- `--base` / `-b` — Base ref (default: `origin/main`, env: `LOKI_WORKTREE_BASE`) +- `--prefix` — Branch name prefix (env: `LOKI_NEW_PREFIX`) + +#### `worktree remove [name]` (alias: `r`) +Remove a worktree and delete its local branch. + +```sh +# From inside the worktree — name is inferred from the directory +~/repos/my-project_fix-auth ❯ lk w r + +# Explicit name from anywhere +~/repos/my-project ❯ lk w r fix-auth + +# Force remove a dirty worktree +❯ lk w r fix-auth --force +``` + +**Flags:** +- `--force` / `-f` — Force removal of dirty worktrees +- `--prefix` — Branch name prefix used during creation (env: `LOKI_NEW_PREFIX`) + +#### `worktree list` (alias: `l`) +List all worktrees. Highlights the current worktree in green. + +```sh +❯ lk w l +``` + +#### `worktree switch [name]` (alias: `s`) +Print a `cd` command for switching to a worktree. Designed for use with `eval`: + +```bash +# bash/zsh — switch to a named worktree +eval "$(lk w s fix-auth)" + +# PowerShell +lk w s fix-auth | Invoke-Expression + +# Switch to the main worktree (no name) +eval "$(lk w s)" +``` + +#### Shell Wrappers + +All worktree commands output `cd ` to stdout (info goes to stderr), so you can +pipe to your shell for automatic directory switching: + +```powershell +# PowerShell +lk w a fix-auth | Invoke-Expression +``` + +```bash +# bash/zsh +eval "$(lk w a fix-auth)" +``` + ### `repo stats` Analyze commits reachable from HEAD to see who has been landing work in a repository. All of the filtering flags operate on commit dates. diff --git a/src/git.rs b/src/git.rs index 34a4026..299a176 100644 --- a/src/git.rs +++ b/src/git.rs @@ -138,3 +138,24 @@ where { Ok(git_command_iter(name, args)?.collect()) } + +/// Execute a git command and return its stdout as a trimmed string. +/// Returns an error if the command fails (non-zero exit) or produces no stdout. +pub fn git_command_stdout(name: &str, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let output = Command::new(GIT) + .args(args) + .output() + .map_err(|err| format!("{name} failed: {err}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("{name} failed: {}", stderr.trim())); + } + + let stdout = String::from_utf8(output.stdout).map_err(|e| format!("{e}"))?; + Ok(stdout.trim().to_string()) +} diff --git a/src/main.rs b/src/main.rs index 2fea155..cd5d456 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ pub mod git; pub mod pruning; +pub mod vars; +pub mod worktree; use std::{ collections::HashMap, @@ -31,7 +33,7 @@ fn styles() -> clap::builder::Styles { .placeholder(AnsiColor::Cyan.on_default()) } -const NO_HOOKS: &str = "core.hooksPath=/dev/null"; +use vars::{LOKI_NEW_PREFIX, NO_HOOKS}; #[derive(Debug, Parser)] struct CommitOptions { @@ -94,6 +96,53 @@ enum RepoSubcommand { Stats(RepoStatsOptions), } +#[derive(Debug, Subcommand)] +enum WorktreeSubcommand { + /// Create a new worktree and branch. + #[clap(visible_alias = "a")] + Add { + /// Optional prefix to prepend to the branch name. + #[clap(long, env = "LOKI_NEW_PREFIX")] + prefix: Option, + + /// Base ref to create the worktree from. + #[clap(short, long, default_value = "origin/main", env = "LOKI_WORKTREE_BASE")] + base: String, + + /// Name parts joined with dashes to form the worktree and branch name. + name: Vec, + }, + + /// 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, + + /// Worktree name. If omitted, inferred from the current directory. + name: Vec, + }, + + /// List all worktrees. + #[clap(visible_alias = "l")] + List, + + /// Print a cd command for switching to a worktree (use with eval). + /// + /// bash/zsh: eval "$(lk w s )" + /// PowerShell: lk w s | Invoke-Expression + #[clap(visible_alias = "s")] + Switch { + /// Worktree name. If omitted, switches to the main worktree. + name: Vec, + }, +} + #[derive(Parser)] #[clap(version, about, author, color = clap::ColorChoice::Auto, styles = styles())] enum Cli { @@ -159,13 +208,18 @@ enum Cli { command: RepoSubcommand, }, + /// Manage git worktrees. + #[clap(visible_alias = "w")] + Worktree { + #[clap(subcommand)] + command: WorktreeSubcommand, + }, + /// Push the main branch to the release branch. #[clap(visible_alias = "r")] Release, } -const LOKI_NEW_PREFIX: &str = "LOKI_NEW_PREFIX"; - fn main() -> Result<(), String> { let cli = Cli::parse(); @@ -181,6 +235,18 @@ fn main() -> Result<(), String> { Cli::Repo { command: RepoSubcommand::Stats(options), } => repo_stats(options), + Cli::Worktree { command } => match command { + 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::List => worktree::worktree_list(), + WorktreeSubcommand::Switch { name } => worktree::worktree_switch(name), + }, Cli::Release => release(), } } diff --git a/src/vars.rs b/src/vars.rs new file mode 100644 index 0000000..91d1c14 --- /dev/null +++ b/src/vars.rs @@ -0,0 +1,8 @@ +/// Environment variable for the branch name prefix used by `new` and `worktree add`. +pub const LOKI_NEW_PREFIX: &str = "LOKI_NEW_PREFIX"; + +/// Environment variable for the base ref used by `worktree add`. +pub const LOKI_WORKTREE_BASE: &str = "LOKI_WORKTREE_BASE"; + +/// Git config override that disables all hooks. +pub const NO_HOOKS: &str = "core.hooksPath=/dev/null"; diff --git a/src/worktree.rs b/src/worktree.rs new file mode 100644 index 0000000..6b1e120 --- /dev/null +++ b/src/worktree.rs @@ -0,0 +1,284 @@ +use std::path::{Path, PathBuf}; + +use colored::Colorize; + +use crate::git::{git_command_status, git_command_stdout, git_commands_status}; +use crate::vars::LOKI_NEW_PREFIX; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Extracts the worktree name from a `_` directory name. +/// Returns everything after the first `_`, or the full string if none. +pub fn infer_worktree_name(dir_name: &str) -> &str { + match dir_name.find('_') { + Some(ix) => &dir_name[ix + 1..], + None => dir_name, + } +} + +/// Returns the main (first) worktree path via `git worktree list --porcelain`. +fn resolve_main_worktree() -> Result { + let output = git_command_stdout("list worktrees", vec!["worktree", "list", "--porcelain"])?; + output + .lines() + .next() + .and_then(|line| line.strip_prefix("worktree ")) + .map(|s| s.to_string()) + .ok_or_else(|| String::from("Could not determine main worktree from git worktree list")) +} + +/// Builds a sibling worktree path: `/_`. +fn worktree_path(repo_root: &str, name: &str) -> PathBuf { + let root = Path::new(repo_root); + let repo_name = root + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let parent = root.parent().unwrap_or(root); + parent.join(format!("{repo_name}_{name}")) +} + +/// Finds a worktree whose directory ends with `_` or equals ``. +fn resolve_worktree_by_name(name: &str) -> Result { + let output = git_command_stdout("list worktrees", vec!["worktree", "list", "--porcelain"])?; + let suffix = format!("_{name}"); + + for line in output.lines() { + if let Some(path) = line.strip_prefix("worktree ") { + let dir = Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if dir.ends_with(&suffix) || dir == name { + return Ok(path.to_string()); + } + } + } + + Err(format!("No worktree found matching '{name}'")) +} + +/// Normalizes path separators to forward slashes for cross-platform comparison. +fn normalize_path(path: &str) -> String { + path.replace('\\', "/") +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/// Creates a worktree at `/_`, then creates and pushes a +/// branch with optional prefix. 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.")); + } + + let mut name = name.join("-"); + let main_root = resolve_main_worktree()?; + let wt_path = worktree_path(&main_root, &name); + let wt_path_str = wt_path.to_string_lossy(); + + if wt_path.exists() { + return Err(format!("Worktree path already exists: {wt_path_str}")); + } + + eprintln!("Creating worktree at {}", wt_path_str.green()); + git_command_status( + "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}"))?; + + if let Some(prefix) = prefix { + eprintln!("Using branch prefix `{prefix}` (set via --prefix or {LOKI_NEW_PREFIX})."); + 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()], + ), + ])?; + + eprintln!("\n{}", "Worktree ready!".green().bold()); + println!("cd {wt_path_str}"); + + Ok(()) +} + +/// 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> { + let main_root = resolve_main_worktree()?; + + let name = if name.is_empty() { + let cwd = std::env::current_dir() + .map_err(|e| format!("Failed to get current directory: {e}"))?; + let dir_name = cwd + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let inferred = infer_worktree_name(&dir_name).to_string(); + eprintln!("Inferred worktree name: {}", inferred.cyan()); + inferred + } else { + name.join("-") + }; + + let wt_path = worktree_path(&main_root, &name); + + // Fall back to plain name if the _ path doesn't exist + let wt_path = if wt_path.exists() { + wt_path + } else { + let parent = Path::new(&main_root) + .parent() + .unwrap_or(Path::new(&main_root)); + let fallback = parent.join(&name); + if !fallback.exists() { + return Err(format!( + "Worktree directory not found at {} or {}", + wt_path.to_string_lossy(), + fallback.to_string_lossy() + )); + } + fallback + }; + let wt_path_str = wt_path.to_string_lossy(); + + 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(), + ); + } + } + + let mut remove_args = vec!["worktree", "remove"]; + if force { + remove_args.push("--force"); + } + remove_args.push(wt_path_str.as_ref()); + git_command_status("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, + }; + + match git_command_status("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}"); + Ok(()) +} + +/// Outputs `cd ` for the named worktree, or the main worktree if no +/// name is given. Designed for `eval` / `Invoke-Expression` piping. +pub fn worktree_switch(name: &[String]) -> Result<(), String> { + let target = if name.is_empty() { + resolve_main_worktree()? + } else { + resolve_worktree_by_name(&name.join("-"))? + }; + + println!("cd {target}"); + Ok(()) +} + +/// Lists all worktrees, highlighting the current one. +pub fn worktree_list() -> Result<(), String> { + let output = git_command_stdout("worktree list", vec!["worktree", "list"])?; + + let cwd = std::env::current_dir() + .ok() + .map(|p| normalize_path(&p.to_string_lossy())); + + for line in output.lines() { + let is_current = cwd + .as_ref() + .is_some_and(|c| normalize_path(line).starts_with(c.as_str())); + + if is_current { + println!("{}", line.green().bold()); + } else { + println!("{line}"); + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn infer_name_with_underscore() { + assert_eq!(infer_worktree_name("my-project_fix-auth"), "fix-auth"); + } + + #[test] + fn infer_name_with_multiple_underscores() { + assert_eq!( + infer_worktree_name("my_project_fix-auth"), + "project_fix-auth" + ); + } + + #[test] + fn infer_name_without_underscore() { + assert_eq!(infer_worktree_name("standalone"), "standalone"); + } + + #[test] + fn infer_name_empty() { + assert_eq!(infer_worktree_name(""), ""); + } + + #[test] + fn worktree_path_basic() { + #[cfg(windows)] + let root = r"C:\repos\my-project"; + #[cfg(not(windows))] + let root = "/home/user/repos/my-project"; + + let path = worktree_path(root, "fix-auth"); + + #[cfg(windows)] + assert_eq!(path, PathBuf::from(r"C:\repos\my-project_fix-auth")); + #[cfg(not(windows))] + assert_eq!(path, PathBuf::from("/home/user/repos/my-project_fix-auth")); + } + + #[test] + fn normalize_path_converts_backslashes() { + assert_eq!(normalize_path(r"C:\repos\my-project"), "C:/repos/my-project"); + } + + #[test] + fn normalize_path_preserves_forward_slashes() { + assert_eq!(normalize_path("/home/user/repos"), "/home/user/repos"); + } +} From a58f6f3ba013b873ece3e64669ba86c42c096451 Mon Sep 17 00:00:00 2001 From: "Gonzo Gonzalez Cunningham (Daniel)" Date: Sat, 14 Mar 2026 17:00:43 -0700 Subject: [PATCH 2/8] Address PR review feedback - Remove git_command_stdout; use git_command_iter instead - Use vars constants in clap env attributes - Make worktree_path return Result instead of silent unwrap_or_default - Simplify test to use Path::join, eliminating cfg(windows) conditionals - Add worktree_path_errors_on_bare_root test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/git.rs | 21 ------------------ src/main.rs | 8 +++---- src/worktree.rs | 57 ++++++++++++++++++++++--------------------------- 3 files changed, 30 insertions(+), 56 deletions(-) diff --git a/src/git.rs b/src/git.rs index 299a176..34a4026 100644 --- a/src/git.rs +++ b/src/git.rs @@ -138,24 +138,3 @@ where { Ok(git_command_iter(name, args)?.collect()) } - -/// Execute a git command and return its stdout as a trimmed string. -/// Returns an error if the command fails (non-zero exit) or produces no stdout. -pub fn git_command_stdout(name: &str, args: I) -> Result -where - I: IntoIterator, - S: AsRef, -{ - let output = Command::new(GIT) - .args(args) - .output() - .map_err(|err| format!("{name} failed: {err}"))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("{name} failed: {}", stderr.trim())); - } - - let stdout = String::from_utf8(output.stdout).map_err(|e| format!("{e}"))?; - Ok(stdout.trim().to_string()) -} diff --git a/src/main.rs b/src/main.rs index cd5d456..7d4eed8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,7 +33,7 @@ fn styles() -> clap::builder::Styles { .placeholder(AnsiColor::Cyan.on_default()) } -use vars::{LOKI_NEW_PREFIX, NO_HOOKS}; +use vars::{LOKI_NEW_PREFIX, LOKI_WORKTREE_BASE, NO_HOOKS}; #[derive(Debug, Parser)] struct CommitOptions { @@ -102,11 +102,11 @@ enum WorktreeSubcommand { #[clap(visible_alias = "a")] Add { /// Optional prefix to prepend to the branch name. - #[clap(long, env = "LOKI_NEW_PREFIX")] + #[clap(long, env = LOKI_NEW_PREFIX)] prefix: Option, /// Base ref to create the worktree from. - #[clap(short, long, default_value = "origin/main", env = "LOKI_WORKTREE_BASE")] + #[clap(short, long, default_value = "origin/main", env = LOKI_WORKTREE_BASE)] base: String, /// Name parts joined with dashes to form the worktree and branch name. @@ -117,7 +117,7 @@ enum WorktreeSubcommand { #[clap(visible_alias = "r")] Remove { /// Optional prefix used when the branch was created. - #[clap(long, env = "LOKI_NEW_PREFIX")] + #[clap(long, env = LOKI_NEW_PREFIX)] prefix: Option, /// Force removal of a dirty worktree. diff --git a/src/worktree.rs b/src/worktree.rs index 6b1e120..9a7b810 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use colored::Colorize; -use crate::git::{git_command_status, git_command_stdout, git_commands_status}; +use crate::git::{git_command_iter, git_command_status, git_commands_status}; use crate::vars::LOKI_NEW_PREFIX; // --------------------------------------------------------------------------- @@ -20,32 +20,29 @@ pub fn infer_worktree_name(dir_name: &str) -> &str { /// Returns the main (first) worktree path via `git worktree list --porcelain`. fn resolve_main_worktree() -> Result { - let output = git_command_stdout("list worktrees", vec!["worktree", "list", "--porcelain"])?; - output - .lines() - .next() - .and_then(|line| line.strip_prefix("worktree ")) - .map(|s| s.to_string()) + git_command_iter("list worktrees", vec!["worktree", "list", "--porcelain"])? + .find_map(|line| line.strip_prefix("worktree ").map(|s| s.to_string())) .ok_or_else(|| String::from("Could not determine main worktree from git worktree list")) } /// Builds a sibling worktree path: `/_`. -fn worktree_path(repo_root: &str, name: &str) -> PathBuf { +fn worktree_path(repo_root: &str, name: &str) -> Result { let root = Path::new(repo_root); let repo_name = root .file_name() .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - let parent = root.parent().unwrap_or(root); - parent.join(format!("{repo_name}_{name}")) + .ok_or_else(|| format!("Could not determine repo name from path: {repo_root}"))?; + let parent = root + .parent() + .ok_or_else(|| format!("Could not determine parent directory of: {repo_root}"))?; + Ok(parent.join(format!("{repo_name}_{name}"))) } /// Finds a worktree whose directory ends with `_` or equals ``. fn resolve_worktree_by_name(name: &str) -> Result { - let output = git_command_stdout("list worktrees", vec!["worktree", "list", "--porcelain"])?; let suffix = format!("_{name}"); - for line in output.lines() { + for line in git_command_iter("list worktrees", vec!["worktree", "list", "--porcelain"])? { if let Some(path) = line.strip_prefix("worktree ") { let dir = Path::new(path) .file_name() @@ -78,7 +75,7 @@ pub fn worktree_add(name: &[String], base: &str, prefix: Option<&str>) -> Result let mut name = name.join("-"); let main_root = resolve_main_worktree()?; - let wt_path = worktree_path(&main_root, &name); + let wt_path = worktree_path(&main_root, &name)?; let wt_path_str = wt_path.to_string_lossy(); if wt_path.exists() { @@ -134,15 +131,15 @@ pub fn worktree_remove(name: &[String], force: bool, prefix: Option<&str>) -> Re name.join("-") }; - let wt_path = worktree_path(&main_root, &name); + let wt_path = worktree_path(&main_root, &name)?; // Fall back to plain name if the _ path doesn't exist let wt_path = if wt_path.exists() { wt_path } else { - let parent = Path::new(&main_root) + let parent = wt_path .parent() - .unwrap_or(Path::new(&main_root)); + .ok_or_else(|| format!("Could not determine parent of: {}", wt_path.to_string_lossy()))?; let fallback = parent.join(&name); if !fallback.exists() { return Err(format!( @@ -205,16 +202,14 @@ pub fn worktree_switch(name: &[String]) -> Result<(), String> { /// Lists all worktrees, highlighting the current one. pub fn worktree_list() -> Result<(), String> { - let output = git_command_stdout("worktree list", vec!["worktree", "list"])?; - let cwd = std::env::current_dir() .ok() .map(|p| normalize_path(&p.to_string_lossy())); - for line in output.lines() { + for line in git_command_iter("worktree list", vec!["worktree", "list"])? { let is_current = cwd .as_ref() - .is_some_and(|c| normalize_path(line).starts_with(c.as_str())); + .is_some_and(|c| normalize_path(&line).starts_with(c.as_str())); if is_current { println!("{}", line.green().bold()); @@ -259,17 +254,17 @@ mod tests { #[test] fn worktree_path_basic() { - #[cfg(windows)] - let root = r"C:\repos\my-project"; - #[cfg(not(windows))] - let root = "/home/user/repos/my-project"; - - let path = worktree_path(root, "fix-auth"); + let root = Path::new("repos").join("my-project"); + let path = worktree_path(root.to_str().unwrap(), "fix-auth").unwrap(); + let expected = Path::new("repos").join("my-project_fix-auth"); + assert_eq!(path, expected); + } - #[cfg(windows)] - assert_eq!(path, PathBuf::from(r"C:\repos\my-project_fix-auth")); - #[cfg(not(windows))] - assert_eq!(path, PathBuf::from("/home/user/repos/my-project_fix-auth")); + #[test] + fn worktree_path_errors_on_bare_root() { + // A bare root like "/" or "C:\" has no file_name component + let result = worktree_path("/", "fix-auth"); + assert!(result.is_err()); } #[test] From 292faeb1be82dc23c8ad28f9bfa7b26b39e193e0 Mon Sep 17 00:00:00 2001 From: "Gonzo Gonzalez Cunningham (Daniel)" Date: Sat, 14 Mar 2026 17:11:05 -0700 Subject: [PATCH 3/8] Improve worktree list: show names, switch hints, and active marker - Parse porcelain output for structured worktree data - Mark current worktree with green * prefix - Show inferred name and branch instead of raw paths - Display 'lk w s ' hint for inactive worktrees Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/worktree.rs | 46 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/src/worktree.rs b/src/worktree.rs index 9a7b810..91a678e 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -200,21 +200,57 @@ pub fn worktree_switch(name: &[String]) -> Result<(), String> { Ok(()) } -/// Lists all worktrees, highlighting the current one. +/// Lists all worktrees, highlighting the current one and showing switch hints. pub fn worktree_list() -> Result<(), String> { let cwd = std::env::current_dir() .ok() .map(|p| normalize_path(&p.to_string_lossy())); - for line in git_command_iter("worktree list", vec!["worktree", "list"])? { + // Parse porcelain output into (path, branch) pairs + let mut entries: Vec<(String, Option)> = Vec::new(); + let mut current_path: Option = None; + let mut current_branch: Option = None; + + for line in git_command_iter("worktree list", vec!["worktree", "list", "--porcelain"])? { + if let Some(path) = line.strip_prefix("worktree ") { + current_path = Some(path.to_string()); + } else if let Some(branch) = line.strip_prefix("branch refs/heads/") { + current_branch = Some(branch.to_string()); + } else if line.is_empty() { + if let Some(path) = current_path.take() { + entries.push((path, current_branch.take())); + } + current_branch = None; + } + } + // Flush last entry (porcelain may not end with a blank line) + if let Some(path) = current_path.take() { + entries.push((path, current_branch.take())); + } + + for (path, branch) in &entries { + let dir_name = Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let name = infer_worktree_name(&dir_name); + let branch_label = branch + .as_deref() + .map(|b| format!(" [{b}]")) + .unwrap_or_default(); + let is_current = cwd .as_ref() - .is_some_and(|c| normalize_path(&line).starts_with(c.as_str())); + .is_some_and(|c| { + let normalized = normalize_path(path); + *c == normalized || c.starts_with(&format!("{normalized}/")) + }); if is_current { - println!("{}", line.green().bold()); + println!("{}", format!("* {name}{branch_label}").green().bold()); } else { - println!("{line}"); + let hint = format!("lk w s {name}").dimmed(); + println!(" {name}{branch_label} {hint}"); } } From e5e8b0cb83789c01cabb2b09c4da0155cd7f0226 Mon Sep 17 00:00:00 2001 From: "Gonzo Gonzalez Cunningham (Daniel)" Date: Sat, 14 Mar 2026 17:15:52 -0700 Subject: [PATCH 4/8] Add interactive fuzzy picker for worktree switch When 'lk w s' is called with no name, shows a fuzzy-searchable picker (via dialoguer) to select a worktree. The picker renders to the terminal, keeping stdout clean for 'cd ' piping. Usage: lk w s | iex (PowerShell) eval \"\\" (bash) Also refactors worktree parsing into shared list_worktree_entries() helper used by both list and switch commands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 449 ++++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 1 + src/worktree.rs | 130 +++++++++----- 3 files changed, 526 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1971016..198d705 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5" dependencies = [ - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -57,15 +57,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" dependencies = [ "anstyle", - "windows-sys", + "windows-sys 0.52.0", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + [[package]] name = "bumpalo" version = "3.19.0" @@ -122,7 +134,7 @@ dependencies = [ "clap_lex", "strsim", "unicase", - "unicode-width", + "unicode-width 0.1.12", ] [[package]] @@ -156,7 +168,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", ] [[package]] @@ -165,12 +189,96 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "fuzzy-matcher", + "shell-words", + "tempfile", + "zeroize", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "heck" version = "0.5.0" @@ -201,12 +309,36 @@ dependencies = [ "cc", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "js-sys" version = "0.3.82" @@ -223,11 +355,23 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.177" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "log" @@ -242,9 +386,16 @@ dependencies = [ "chrono", "clap", "colored", + "dialoguer", "test-case", ] +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + [[package]] name = "num-traits" version = "0.2.19" @@ -260,11 +411,21 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" -version = "1.0.84" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec96c6a92621310b51366f1e28d05ef11489516e93be030060e5fc12024a49d6" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -278,12 +439,85 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -298,15 +532,28 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.66" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "test-case" version = "3.3.1" @@ -340,6 +587,15 @@ dependencies = [ "test-case-core", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "unicase" version = "2.7.0" @@ -361,6 +617,18 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.1" @@ -373,6 +641,24 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.105" @@ -418,6 +704,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -486,6 +806,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.5" @@ -549,3 +878,103 @@ name = "windows_x86_64_msvc" version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 2594dfd..6647be9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ path = "src/main.rs" clap = { version = "4.5.4", features = ["derive", "unicode", "env"] } chrono = { version = "0.4", features = ["clock"] } colored = "2.1" +dialoguer = { version = "0.12.0", features = ["fuzzy-select"] } [dev-dependencies] test-case = "3.3.1" diff --git a/src/worktree.rs b/src/worktree.rs index 91a678e..42bcee0 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -4,6 +4,7 @@ use colored::Colorize; use crate::git::{git_command_iter, git_command_status, git_commands_status}; use crate::vars::LOKI_NEW_PREFIX; +use dialoguer::FuzzySelect; // --------------------------------------------------------------------------- // Helpers @@ -62,6 +63,66 @@ fn normalize_path(path: &str) -> String { path.replace('\\', "/") } +/// A parsed worktree entry from porcelain output. +struct WorktreeEntry { + path: String, + name: String, + branch: Option, +} + +impl WorktreeEntry { + fn display_label(&self) -> String { + match &self.branch { + Some(b) => format!("{} [{b}]", self.name), + None => self.name.clone(), + } + } +} + +/// Parses `git worktree list --porcelain` into structured entries. +fn list_worktree_entries() -> Result, String> { + let mut entries = Vec::new(); + let mut current_path: Option = None; + let mut current_branch: Option = None; + + for line in git_command_iter("worktree list", vec!["worktree", "list", "--porcelain"])? { + if let Some(path) = line.strip_prefix("worktree ") { + current_path = Some(path.to_string()); + } else if let Some(branch) = line.strip_prefix("branch refs/heads/") { + current_branch = Some(branch.to_string()); + } else if line.is_empty() { + if let Some(path) = current_path.take() { + let dir = Path::new(&path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let name = infer_worktree_name(&dir).to_string(); + entries.push(WorktreeEntry { + path, + name, + branch: current_branch.take(), + }); + } + current_branch = None; + } + } + // Flush last entry (porcelain may not end with a blank line) + if let Some(path) = current_path.take() { + let dir = Path::new(&path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let name = infer_worktree_name(&dir).to_string(); + entries.push(WorktreeEntry { + path, + name, + branch: current_branch.take(), + }); + } + + Ok(entries) +} + // --------------------------------------------------------------------------- // Commands // --------------------------------------------------------------------------- @@ -187,11 +248,23 @@ pub fn worktree_remove(name: &[String], force: bool, prefix: Option<&str>) -> Re Ok(()) } -/// Outputs `cd ` for the named worktree, or the main worktree if no -/// name is given. Designed for `eval` / `Invoke-Expression` piping. +/// Outputs `cd ` for the named worktree. If no name is given, shows an +/// interactive fuzzy picker. Designed for `eval` / `Invoke-Expression` piping. pub fn worktree_switch(name: &[String]) -> Result<(), String> { let target = if name.is_empty() { - resolve_main_worktree()? + let entries = list_worktree_entries()?; + if entries.len() <= 1 { + return Err(String::from("No other worktrees to switch to.")); + } + + let labels: Vec = entries.iter().map(|e| e.display_label()).collect(); + let selection = FuzzySelect::new() + .with_prompt("Switch to worktree") + .items(&labels) + .interact() + .map_err(|e| format!("Selection cancelled: {e}"))?; + + entries[selection].path.clone() } else { resolve_worktree_by_name(&name.join("-"))? }; @@ -206,51 +279,20 @@ pub fn worktree_list() -> Result<(), String> { .ok() .map(|p| normalize_path(&p.to_string_lossy())); - // Parse porcelain output into (path, branch) pairs - let mut entries: Vec<(String, Option)> = Vec::new(); - let mut current_path: Option = None; - let mut current_branch: Option = None; - - for line in git_command_iter("worktree list", vec!["worktree", "list", "--porcelain"])? { - if let Some(path) = line.strip_prefix("worktree ") { - current_path = Some(path.to_string()); - } else if let Some(branch) = line.strip_prefix("branch refs/heads/") { - current_branch = Some(branch.to_string()); - } else if line.is_empty() { - if let Some(path) = current_path.take() { - entries.push((path, current_branch.take())); - } - current_branch = None; - } - } - // Flush last entry (porcelain may not end with a blank line) - if let Some(path) = current_path.take() { - entries.push((path, current_branch.take())); - } - - for (path, branch) in &entries { - let dir_name = Path::new(path) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - let name = infer_worktree_name(&dir_name); - let branch_label = branch - .as_deref() - .map(|b| format!(" [{b}]")) - .unwrap_or_default(); + let entries = list_worktree_entries()?; - let is_current = cwd - .as_ref() - .is_some_and(|c| { - let normalized = normalize_path(path); - *c == normalized || c.starts_with(&format!("{normalized}/")) - }); + for entry in &entries { + let is_current = cwd.as_ref().is_some_and(|c| { + let normalized = normalize_path(&entry.path); + *c == normalized || c.starts_with(&format!("{normalized}/")) + }); + let label = entry.display_label(); if is_current { - println!("{}", format!("* {name}{branch_label}").green().bold()); + println!("{}", format!("* {label}").green().bold()); } else { - let hint = format!("lk w s {name}").dimmed(); - println!(" {name}{branch_label} {hint}"); + let hint = format!("lk w s {}", entry.name).dimmed(); + println!(" {label} {hint}"); } } From e06162056a152a14e3f65bc14d82bed0ba1b439e Mon Sep 17 00:00:00 2001 From: "Gonzo Gonzalez Cunningham (Daniel)" Date: Sat, 14 Mar 2026 17:21:23 -0700 Subject: [PATCH 5/8] Improve worktree list and switch UX - List shows platform-appropriate switch command (| iex / eval) - Switch shows a tip when stdout is a terminal (not piped) - Remove dialoguer dependency (keep it lightweight) - DRY up porcelain parsing with shared flush closure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 437 +----------------------------------------------- Cargo.toml | 1 - src/worktree.rs | 78 ++++----- 3 files changed, 43 insertions(+), 473 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 198d705..337b516 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5" dependencies = [ - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -57,27 +57,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" dependencies = [ "anstyle", - "windows-sys 0.52.0", + "windows-sys", ] -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" - [[package]] name = "bumpalo" version = "3.19.0" @@ -134,7 +122,7 @@ dependencies = [ "clap_lex", "strsim", "unicase", - "unicode-width 0.1.12", + "unicode-width", ] [[package]] @@ -168,19 +156,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.52.0", -] - -[[package]] -name = "console" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" -dependencies = [ - "encode_unicode", - "libc", - "unicode-width 0.2.2", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -189,96 +165,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "dialoguer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" -dependencies = [ - "console", - "fuzzy-matcher", - "shell-words", - "tempfile", - "zeroize", -] - -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - [[package]] name = "find-msvc-tools" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "fuzzy-matcher" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" -dependencies = [ - "thread_local", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - [[package]] name = "heck" version = "0.5.0" @@ -309,36 +201,12 @@ dependencies = [ "cc", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - [[package]] name = "js-sys" version = "0.3.82" @@ -355,24 +223,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "log" version = "0.4.28" @@ -386,16 +242,9 @@ dependencies = [ "chrono", "clap", "colored", - "dialoguer", "test-case", ] -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - [[package]] name = "num-traits" version = "0.2.19" @@ -411,16 +260,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -439,85 +278,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.52.0", -] - [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "1.3.0" @@ -541,19 +307,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys 0.52.0", -] - [[package]] name = "test-case" version = "3.3.1" @@ -587,15 +340,6 @@ dependencies = [ "test-case-core", ] -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - [[package]] name = "unicase" version = "2.7.0" @@ -617,18 +361,6 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "utf8parse" version = "0.2.1" @@ -641,24 +373,6 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.105" @@ -704,40 +418,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -806,15 +486,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-targets" version = "0.52.5" @@ -878,103 +549,3 @@ name = "windows_x86_64_msvc" version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 6647be9..2594dfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,6 @@ path = "src/main.rs" clap = { version = "4.5.4", features = ["derive", "unicode", "env"] } chrono = { version = "0.4", features = ["clock"] } colored = "2.1" -dialoguer = { version = "0.12.0", features = ["fuzzy-select"] } [dev-dependencies] test-case = "3.3.1" diff --git a/src/worktree.rs b/src/worktree.rs index 42bcee0..5c49480 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -1,10 +1,10 @@ +use std::io::IsTerminal; use std::path::{Path, PathBuf}; use colored::Colorize; use crate::git::{git_command_iter, git_command_status, git_commands_status}; use crate::vars::LOKI_NEW_PREFIX; -use dialoguer::FuzzySelect; // --------------------------------------------------------------------------- // Helpers @@ -85,6 +85,19 @@ fn list_worktree_entries() -> Result, String> { let mut current_path: Option = None; let mut current_branch: Option = None; + let mut flush = |path: String, branch: Option| { + let dir = Path::new(&path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let name = infer_worktree_name(&dir).to_string(); + entries.push(WorktreeEntry { + path, + name, + branch, + }); + }; + for line in git_command_iter("worktree list", vec!["worktree", "list", "--porcelain"])? { if let Some(path) = line.strip_prefix("worktree ") { current_path = Some(path.to_string()); @@ -92,32 +105,13 @@ fn list_worktree_entries() -> Result, String> { current_branch = Some(branch.to_string()); } else if line.is_empty() { if let Some(path) = current_path.take() { - let dir = Path::new(&path) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - let name = infer_worktree_name(&dir).to_string(); - entries.push(WorktreeEntry { - path, - name, - branch: current_branch.take(), - }); + flush(path, current_branch.take()); } current_branch = None; } } - // Flush last entry (porcelain may not end with a blank line) if let Some(path) = current_path.take() { - let dir = Path::new(&path) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - let name = infer_worktree_name(&dir).to_string(); - entries.push(WorktreeEntry { - path, - name, - branch: current_branch.take(), - }); + flush(path, current_branch.take()); } Ok(entries) @@ -248,31 +242,28 @@ pub fn worktree_remove(name: &[String], force: bool, prefix: Option<&str>) -> Re Ok(()) } -/// Outputs `cd ` for the named worktree. If no name is given, shows an -/// interactive fuzzy picker. Designed for `eval` / `Invoke-Expression` piping. +/// Outputs `cd ` for the named worktree, or the main worktree if no +/// name is given. Designed for `eval` / `Invoke-Expression` piping. pub fn worktree_switch(name: &[String]) -> Result<(), String> { let target = if name.is_empty() { - let entries = list_worktree_entries()?; - if entries.len() <= 1 { - return Err(String::from("No other worktrees to switch to.")); - } - - let labels: Vec = entries.iter().map(|e| e.display_label()).collect(); - let selection = FuzzySelect::new() - .with_prompt("Switch to worktree") - .items(&labels) - .interact() - .map_err(|e| format!("Selection cancelled: {e}"))?; - - entries[selection].path.clone() + resolve_main_worktree()? } else { 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()); + } + Ok(()) } - /// Lists all worktrees, highlighting the current one and showing switch hints. pub fn worktree_list() -> Result<(), String> { let cwd = std::env::current_dir() @@ -291,7 +282,7 @@ pub fn worktree_list() -> Result<(), String> { if is_current { println!("{}", format!("* {label}").green().bold()); } else { - let hint = format!("lk w s {}", entry.name).dimmed(); + let hint = switch_hint(&entry.name).dimmed(); println!(" {label} {hint}"); } } @@ -299,6 +290,15 @@ pub fn worktree_list() -> Result<(), String> { Ok(()) } +/// Returns the platform-appropriate command to switch to a worktree. +fn switch_hint(name: &str) -> String { + if cfg!(windows) { + format!("lk w s {name} | iex") + } else { + format!("eval \"$(lk w s {name})\"") + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- From 1a5b37e3a7aab75967ad379878ed29ca1276c7ac Mon Sep 17 00:00:00 2001 From: "Gonzo Gonzalez Cunningham (Daniel)" Date: Sat, 14 Mar 2026 17:26:58 -0700 Subject: [PATCH 6/8] Move all env attribute strings to vars constants - Add LOKI_REBASE_TARGET to vars.rs - Replace remaining string literals in New and Rebase clap attributes - All env attributes now reference shared constants Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main.rs | 6 +++--- src/vars.rs | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 7d4eed8..d98fb61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,7 +33,7 @@ fn styles() -> clap::builder::Styles { .placeholder(AnsiColor::Cyan.on_default()) } -use vars::{LOKI_NEW_PREFIX, LOKI_WORKTREE_BASE, NO_HOOKS}; +use vars::{LOKI_NEW_PREFIX, LOKI_REBASE_TARGET, LOKI_WORKTREE_BASE, NO_HOOKS}; #[derive(Debug, Parser)] struct CommitOptions { @@ -151,7 +151,7 @@ enum Cli { #[clap(visible_alias = "n")] New { /// Optional prefix to prepend to the generated branch name. - #[clap(long, env = "LOKI_NEW_PREFIX")] + #[clap(long, env = LOKI_NEW_PREFIX)] prefix: Option, /// List of names to join with dashes to form a valid branch name. @@ -187,7 +187,7 @@ enum Cli { /// Rebase the current branch onto the target branch after fetching. Rebase { /// The branch to rebase onto. - #[clap(default_value = "main", env = "LOKI_REBASE_TARGET")] + #[clap(default_value = "main", env = LOKI_REBASE_TARGET)] target: String, /// Start an interactive rebase. diff --git a/src/vars.rs b/src/vars.rs index 91d1c14..0402d79 100644 --- a/src/vars.rs +++ b/src/vars.rs @@ -4,5 +4,8 @@ pub const LOKI_NEW_PREFIX: &str = "LOKI_NEW_PREFIX"; /// Environment variable for the base ref used by `worktree add`. pub const LOKI_WORKTREE_BASE: &str = "LOKI_WORKTREE_BASE"; +/// Environment variable for the rebase target branch. +pub const LOKI_REBASE_TARGET: &str = "LOKI_REBASE_TARGET"; + /// Git config override that disables all hooks. pub const NO_HOOKS: &str = "core.hooksPath=/dev/null"; From cc9f6ea4c5524616b3043fc202b74be9339903ca Mon Sep 17 00:00:00 2001 From: "Gonzo Gonzalez Cunningham (Daniel)" Date: Sat, 14 Mar 2026 17:28:44 -0700 Subject: [PATCH 7/8] Update README worktree list section with new output format Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7ac3493..82de062 100644 --- a/README.md +++ b/README.md @@ -135,10 +135,13 @@ Remove a worktree and delete its local branch. - `--prefix` — Branch name prefix used during creation (env: `LOKI_NEW_PREFIX`) #### `worktree list` (alias: `l`) -List all worktrees. Highlights the current worktree in green. +List all worktrees. The current worktree is highlighted in green, and other +worktrees show a copy-paste command to switch to them: -```sh +``` ❯ lk w l +* my-project [main] + fix-auth [users/dan/fix-auth] lk w s fix-auth | iex ``` #### `worktree switch [name]` (alias: `s`) From ab0208426c3ddbeac0fd7e431b6061b8ba9f6f0b Mon Sep 17 00:00:00 2001 From: "Gonzo Gonzalez Cunningham (Daniel)" Date: Sat, 14 Mar 2026 17:29:30 -0700 Subject: [PATCH 8/8] Use PowerShell shorthand 'iex' in README examples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 82de062..8d301cc 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Print a `cd` command for switching to a worktree. Designed for use with `eval`: eval "$(lk w s fix-auth)" # PowerShell -lk w s fix-auth | Invoke-Expression +lk w s fix-auth | iex # Switch to the main worktree (no name) eval "$(lk w s)" @@ -165,7 +165,7 @@ pipe to your shell for automatic directory switching: ```powershell # PowerShell -lk w a fix-auth | Invoke-Expression +lk w a fix-auth | iex ``` ```bash