diff --git a/Cargo.lock b/Cargo.lock index 1041d08..337b516 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,9 +225,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[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 = "log" @@ -237,7 +237,7 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "loki-cli" -version = "2.0.0" +version = "2.1.0" dependencies = [ "chrono", "clap", @@ -262,9 +262,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[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", ] @@ -298,9 +298,9 @@ 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", 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..8d301cc 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,88 @@ 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. The current worktree is highlighted in green, and other +worktrees show a copy-paste command to switch to them: + +``` +❯ lk w l +* my-project [main] + fix-auth [users/dan/fix-auth] lk w s fix-auth | iex +``` + +#### `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 | iex + +# 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 | iex +``` + +```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/main.rs b/src/main.rs index 2fea155..d98fb61 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, LOKI_REBASE_TARGET, LOKI_WORKTREE_BASE, 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 { @@ -102,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. @@ -138,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. @@ -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..0402d79 --- /dev/null +++ b/src/vars.rs @@ -0,0 +1,11 @@ +/// 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"; + +/// 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"; diff --git a/src/worktree.rs b/src/worktree.rs new file mode 100644 index 0000000..5c49480 --- /dev/null +++ b/src/worktree.rs @@ -0,0 +1,357 @@ +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; + +// --------------------------------------------------------------------------- +// 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 { + 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) -> Result { + let root = Path::new(repo_root); + let repo_name = root + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .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 suffix = format!("_{name}"); + + 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() + .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('\\', "/") +} + +/// 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; + + 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()); + } 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() { + flush(path, current_branch.take()); + } + current_branch = None; + } + } + if let Some(path) = current_path.take() { + flush(path, current_branch.take()); + } + + Ok(entries) +} + +// --------------------------------------------------------------------------- +// 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 = wt_path + .parent() + .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!( + "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}"); + + 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() + .ok() + .map(|p| normalize_path(&p.to_string_lossy())); + + let entries = list_worktree_entries()?; + + 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!("* {label}").green().bold()); + } else { + let hint = switch_hint(&entry.name).dimmed(); + println!(" {label} {hint}"); + } + } + + 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 +// --------------------------------------------------------------------------- + +#[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() { + 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); + } + + #[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] + 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"); + } +}