From c70b53330073a4631f31e359294977384246504b Mon Sep 17 00:00:00 2001 From: shawn Date: Tue, 16 Jun 2026 10:47:11 +0800 Subject: [PATCH 1/3] feat: grove worktree absorb (inverse of convert) + worktree namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the worktree lifecycle verbs under `grove worktree` (list/new/convert/absorb/rm) while keeping hidden top-level aliases (`grove new`/`convert`/`absorb`/`rm`) for backward compatibility. Add `grove worktree absorb` — the inverse of `convert`. It removes a linked worktree and switches the main worktree onto its branch: stash -u (target) → worktree remove → switch main → stash pop --index Data safety, the whole point of the design: - committed work is never at risk (shared object database) - uncommitted work (staged/unstaged/untracked) is carried via a stash and stays recoverable from the stash reflog even if a later step fails - ignored files (node_modules/.env/build output) are intentionally not carried; absorb lists them and confirms before deleting The main-clean check uses has_tracked_modifications (tracked changes only), not is_dirty — untracked files are preserved by `git switch`, and nested worktree dirs under the default in-repo worktree root (`.claude`) would otherwise always make main look dirty. New git helpers: has_tracked_modifications, stash_push_including_untracked, stash_pop_restoring_index, list_ignored. Shell-init now auto-cds after `absorb` and the canonical `worktree convert`/`worktree absorb` forms. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- src-tauri/src/cli.rs | 414 +++++++++++++++++++++++++++++++------------ src-tauri/src/git.rs | 58 ++++++ 3 files changed, 358 insertions(+), 116 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e5cebc6..32e8a49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ cd src-tauri && cargo clippy # lint Rust code - `models.rs` — all serde structs shared between commands; camelCase-renamed for the JS bridge - `actions.rs` — create/remove/start/launch worktrees, run hooks, prune. Streams stdout/stderr as logs; stderr is logged as info unless the process exits non-zero. - `store.rs` — persists recent repos, PR cache, per-repo config and worktree root overrides, and default terminal as JSON to `~/.grove/store.json`. All functions (`persist`, `load_store`, `store_path`) are Tauri-independent — no `AppHandle` required. -- `cli.rs` — clap-based CLI (`grove open`, `grove hook run/list`, `grove worktree list`). The binary is dual-mode: CLI args → terminal mode, no args → GUI. Detected in `main.rs` before Tauri init. +- `cli.rs` — clap-based CLI. Worktree lifecycle verbs live under `grove worktree` (`list`, `new`, `convert`, `absorb`, `rm`), with hidden top-level aliases (`grove new`/`convert`/`absorb`/`rm`) kept for backward compatibility. Other groups: `grove open`, `grove hook run/list/edit`, `grove config …`, `grove cd`, `grove shell-init`. `absorb` is the inverse of `convert`: it removes a linked worktree and switches the main worktree onto its branch, carrying uncommitted changes via a stash. The binary is dual-mode: CLI args → terminal mode, no args → GUI. Detected in `main.rs` before Tauri init. - `platform/` — cross-platform terminal launch abstraction (`open_terminal_at`, `open_terminal_app`) with per-OS implementations (macOS/Windows/Linux) **IPC pattern**: Rust structs use `#[serde(rename_all = "camelCase")]`. The frontend calls `invoke("command_name", { input })` and receives camelCase JSON. When adding a new command: register in `lib.rs` `invoke_handler` macro → add TS wrapper in `api.ts` → add type in `types.ts`. diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index 6f3cf2b..036f691 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -48,43 +48,15 @@ enum Commands { #[command(subcommand)] command: ConfigCommands, }, - /// Create a worktree for a branch - New { - /// Branch name (created if it does not exist) - branch: String, - /// Base ref for new branches (default: configured default base) - #[arg(short = 'b', long, value_name = "REF")] - base: Option, - /// Custom worktree path (overrides the configured worktree root) - #[arg(short = 'p', long, value_name = "PATH")] - path: Option, - /// Track a remote branch (sets upstream); implies --remote-branch - #[arg(short = 'r', long, value_name = "REMOTE_REF", conflicts_with = "existing")] - remote: Option, - /// Use an existing local branch instead of creating a new one - #[arg(long, conflicts_with = "remote")] - existing: bool, - /// Skip pre-create / post-create hooks - #[arg(long)] - no_hooks: bool, - /// Only print the resulting worktree path on stdout (logs to stderr) - #[arg(short = 'q', long)] - quiet: bool, - }, - /// Convert the main worktree's current branch into a separate worktree - /// - /// Switches the main worktree onto the configured base branch and creates - /// a new worktree for the branch that was previously checked out there. - Convert { - /// Target worktree path (default: configured worktree root + sanitized branch name) - path: Option, - /// Base branch to switch the main worktree onto (default: configured default base) - #[arg(short = 'b', long, value_name = "REF")] - base: Option, - /// Skip pre-create / post-create hooks - #[arg(long)] - no_hooks: bool, - }, + /// Create a worktree for a branch (alias of `grove worktree new`) + #[command(hide = true)] + New(NewArgs), + /// Convert the main worktree's branch into a worktree (alias of `grove worktree convert`) + #[command(hide = true)] + Convert(ConvertArgs), + /// Absorb a worktree's branch back into the main worktree (alias of `grove worktree absorb`) + #[command(hide = true)] + Absorb(AbsorbArgs), /// Print the path of a worktree by branch (use with `grove shell-init` to actually cd) Cd { /// Branch name (defaults to the main worktree) @@ -96,27 +68,9 @@ enum Commands { #[arg(value_enum)] shell: ShellKind, }, - /// Remove a worktree - #[command(alias = "remove")] - Rm { - /// Branch name (defaults to the worktree containing the current directory) - branch: Option, - /// Skip the confirmation prompt - #[arg(short = 'y', long)] - yes: bool, - /// Force removal (allows dirty worktrees, unlocks locked ones) - #[arg(short = 'f', long)] - force: bool, - /// Print what would happen without making changes - #[arg(long)] - dry_run: bool, - /// Skip pre-remove / post-remove hooks - #[arg(long)] - no_hooks: bool, - /// Run `git worktree prune` after removal - #[arg(long)] - prune: bool, - }, + /// Remove a worktree (alias of `grove worktree rm`) + #[command(alias = "remove", hide = true)] + Rm(RmArgs), } #[derive(Subcommand)] @@ -139,6 +93,22 @@ enum HookCommands { enum WorktreeCommands { /// List worktrees for the current repository List, + /// Create a worktree for a branch + New(NewArgs), + /// Convert the main worktree's current branch into a separate worktree + /// + /// Switches the main worktree onto the configured base branch and creates + /// a new worktree for the branch that was previously checked out there. + Convert(ConvertArgs), + /// Absorb a worktree's branch back into the main worktree + /// + /// The inverse of `convert`: removes the linked worktree and switches the + /// main worktree onto its branch. Uncommitted changes are carried over via + /// a stash; committed work is never at risk. + Absorb(AbsorbArgs), + /// Remove a worktree + #[command(alias = "remove")] + Rm(RmArgs), } #[derive(Copy, Clone, ValueEnum)] @@ -220,6 +190,7 @@ const CLI_SUBCOMMANDS: &[&str] = &[ "config", "new", "convert", + "absorb", "rm", "remove", "cd", @@ -264,54 +235,22 @@ pub fn main() { }, Some(Commands::Worktree { command }) => match command { WorktreeCommands::List => cmd_worktree_list(), + WorktreeCommands::New(args) => cmd_new(args), + WorktreeCommands::Convert(args) => cmd_convert(args), + WorktreeCommands::Absorb(args) => cmd_absorb(args), + WorktreeCommands::Rm(args) => cmd_rm(args), }, Some(Commands::Config { command }) => cmd_config(command), - Some(Commands::New { - branch, - base, - path, - remote, - existing, - no_hooks, - quiet, - }) => cmd_new(NewArgs { - branch, - base, - path, - remote, - existing, - no_hooks, - quiet, - }), - Some(Commands::Convert { - path, - base, - no_hooks, - }) => cmd_convert(ConvertArgs { - path, - base, - no_hooks, - }), + // Top-level aliases for the worktree lifecycle verbs (hidden from help). + Some(Commands::New(args)) => cmd_new(args), + Some(Commands::Convert(args)) => cmd_convert(args), + Some(Commands::Absorb(args)) => cmd_absorb(args), Some(Commands::Cd { branch }) => cmd_cd(branch.as_deref()), Some(Commands::ShellInit { shell }) => { print!("{}", shell_init_snippet(shell)); Ok(()) } - Some(Commands::Rm { - branch, - yes, - force, - dry_run, - no_hooks, - prune, - }) => cmd_rm(RmArgs { - branch, - yes, - force, - dry_run, - no_hooks, - prune, - }), + Some(Commands::Rm(args)) => cmd_rm(args), None => { if let Some(path) = cli.path { cmd_open(&path) @@ -743,13 +682,27 @@ fn current_repo_root() -> Result { // ── new / rm subcommands ─────────────────────────────────────────────────── +#[derive(clap::Args)] struct NewArgs { + /// Branch name (created if it does not exist) branch: String, + /// Base ref for new branches (default: configured default base) + #[arg(short = 'b', long, value_name = "REF")] base: Option, + /// Custom worktree path (overrides the configured worktree root) + #[arg(short = 'p', long, value_name = "PATH")] path: Option, + /// Track a remote branch (sets upstream); implies --remote-branch + #[arg(short = 'r', long, value_name = "REMOTE_REF", conflicts_with = "existing")] remote: Option, + /// Use an existing local branch instead of creating a new one + #[arg(long, conflicts_with = "remote")] existing: bool, + /// Skip pre-create / post-create hooks + #[arg(long)] no_hooks: bool, + /// Only print the resulting worktree path on stdout (logs to stderr) + #[arg(short = 'q', long)] quiet: bool, } @@ -787,12 +740,24 @@ fn cmd_new(args: NewArgs) -> Result<(), String> { Ok(()) } +#[derive(clap::Args)] struct RmArgs { + /// Branch name (defaults to the worktree containing the current directory) branch: Option, + /// Skip the confirmation prompt + #[arg(short = 'y', long)] yes: bool, + /// Force removal (allows dirty worktrees, unlocks locked ones) + #[arg(short = 'f', long)] force: bool, + /// Print what would happen without making changes + #[arg(long)] dry_run: bool, + /// Skip pre-remove / post-remove hooks + #[arg(long)] no_hooks: bool, + /// Run `git worktree prune` after removal + #[arg(long)] prune: bool, } @@ -907,11 +872,17 @@ fn print_rm_plan(target: &crate::models::WorktreeRecord, prune: bool, force: boo } fn confirm_or_abort(target: &crate::models::WorktreeRecord) -> Result<(), String> { + let branch = target.branch.as_deref().unwrap_or("(detached)"); + prompt_confirm(&format!("delete worktree '{branch}' at {}?", target.path)) +} + +/// Ask the user to confirm a destructive `action` on stderr. Errors (rather than +/// silently proceeding) when not attached to a TTY, so scripts must pass `-y`. +fn prompt_confirm(action: &str) -> Result<(), String> { if !std::io::stderr().is_terminal() { return Err("not a TTY — pass -y to confirm".into()); } - let branch = target.branch.as_deref().unwrap_or("(detached)"); - eprint!("delete worktree '{branch}' at {}? [y/N] ", target.path); + eprint!("{action} [y/N] "); std::io::stderr().flush().ok(); let mut answer = String::new(); std::io::stdin() @@ -927,9 +898,15 @@ fn confirm_or_abort(target: &crate::models::WorktreeRecord) -> Result<(), String // ── convert subcommand ──────────────────────────────────────────────────── +#[derive(clap::Args)] struct ConvertArgs { + /// Target worktree path (default: configured worktree root + sanitized branch name) path: Option, + /// Base branch to switch the main worktree onto (default: configured default base) + #[arg(short = 'b', long, value_name = "REF")] base: Option, + /// Skip pre-create / post-create hooks + #[arg(long)] no_hooks: bool, } @@ -1041,6 +1018,174 @@ fn cmd_convert(args: ConvertArgs) -> Result<(), String> { Ok(()) } +// ── absorb subcommand ────────────────────────────────────────────────────── + +#[derive(clap::Args)] +struct AbsorbArgs { + /// Branch / worktree to absorb (defaults to the worktree containing the current directory) + branch: Option, + /// Skip the confirmation prompt + #[arg(short = 'y', long)] + yes: bool, + /// Force removal of a locked worktree + #[arg(short = 'f', long)] + force: bool, + /// Skip pre-remove / post-remove hooks + #[arg(long)] + no_hooks: bool, +} + +/// Validate an absorb request. Returns the branch to move into the main +/// worktree, or an error describing why the request can't proceed. +fn plan_absorb( + target_is_main: bool, + target_branch: Option<&str>, + main_dirty: bool, +) -> Result { + if target_is_main { + return Err("cannot absorb the main worktree into itself".into()); + } + let branch = target_branch.ok_or_else(|| { + "worktree is in detached HEAD state — no branch to absorb".to_string() + })?; + if main_dirty { + return Err( + "main worktree has uncommitted changes — commit or stash them before absorbing".into(), + ); + } + Ok(branch.to_string()) +} + +fn resolve_absorb_target<'a>( + worktrees: &'a [git::ParsedWorktree], + main_root: &Path, + branch: Option<&str>, + cwd: &Path, +) -> Result<&'a git::ParsedWorktree, String> { + if let Some(branch) = branch { + worktrees + .iter() + .find(|w| w.branch.as_deref() == Some(branch)) + .ok_or_else(|| format!("no worktree found for branch '{branch}'")) + } else { + let cwd_canonical = git::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()); + worktrees + .iter() + .filter(|w| git::canonicalize(&w.path).map(|p| p != *main_root).unwrap_or(true)) + .find(|w| { + git::canonicalize(&w.path) + .map(|p| cwd_canonical.starts_with(&p)) + .unwrap_or(false) + }) + .ok_or_else(|| { + "not inside a linked worktree — pass a branch name, or cd into the worktree to absorb" + .to_string() + }) + } +} + +fn cmd_absorb(args: AbsorbArgs) -> Result<(), String> { + let repo_root = current_repo_root()?; + let main_root = git::canonicalize(&repo_root)?; + let state = actions::build_cli_state()?; + let worktrees = git::list_worktrees(&repo_root)?; + + let cwd = std::env::current_dir() + .map_err(|e| format!("cannot determine current directory: {e}"))?; + let target = resolve_absorb_target(&worktrees, &main_root, args.branch.as_deref(), &cwd)?; + let target_path = git::canonicalize(&target.path)?; + + let target_is_main = target_path == main_root; + // Only tracked changes block the switch; untracked files (incl. nested + // worktree dirs under the default in-repo worktree root) are preserved. + let main_dirty = git::has_tracked_modifications(&repo_root)?; + let branch = plan_absorb(target_is_main, target.branch.as_deref(), main_dirty)?; + + let target_dirty = git::is_dirty(&target_path)?; + let ignored = git::list_ignored(&target_path).unwrap_or_default(); + print_absorb_plan(&branch, &target_path, target_dirty, &ignored); + + if !args.yes { + prompt_confirm(&format!( + "absorb '{branch}' into the main worktree (removes the worktree at {})?", + target_path.display() + ))?; + } + + // 1. Capture uncommitted work into a stash so removal is non-destructive and + // recoverable even if a later step fails. Committed work is never at risk. + let stashed = if target_dirty { + let created = git::stash_push_including_untracked( + &target_path, + &format!("grove absorb {branch}"), + )?; + if created { + eprintln!("Stashed uncommitted changes from {}", target_path.display()); + } + created + } else { + false + }; + + // 2. Remove the now-clean worktree, running pre/post-remove hooks. + let input = RemoveWorktreeInput { + repo_root: repo_root.to_string_lossy().to_string(), + worktree_path: target_path.to_string_lossy().to_string(), + force: args.force, + }; + let mut sink = StderrLogWriter; + if let Err(e) = actions::remove_worktree_cli(&state, input, args.no_hooks, false, &mut sink) { + if stashed { + return Err(format!( + "{e}\nnote: your uncommitted changes are safe in a stash — run `git stash pop` \ + in {} after resolving", + target_path.display() + )); + } + return Err(e); + } + + // 3. Switch the main worktree onto the absorbed branch. + git::run_git_text(&repo_root, ["switch", &branch]) + .map_err(|stderr| format!("failed to switch main worktree to '{branch}': {stderr}"))?; + eprintln!("Switched main worktree to {branch}"); + + // 4. Re-apply the stashed work onto the main worktree. + if stashed { + git::stash_pop_restoring_index(&repo_root).map_err(|e| { + format!( + "{e}\nnote: your changes are still stashed — run `git stash pop` in {}", + repo_root.display() + ) + })?; + eprintln!("Restored stashed changes onto {branch}"); + } + + println!("{}", repo_root.display()); + Ok(()) +} + +fn print_absorb_plan(branch: &str, target_path: &Path, dirty: bool, ignored: &[String]) { + eprintln!("absorb: {branch}"); + eprintln!("worktree: {}", target_path.display()); + if dirty { + eprintln!("changes: uncommitted changes will be carried over via a stash"); + } + if !ignored.is_empty() { + eprintln!( + "deletes: {} ignored path(s) will be removed with the worktree (e.g. {})", + ignored.len(), + ignored + .iter() + .take(3) + .cloned() + .collect::>() + .join(", ") + ); + } + eprintln!(); +} + // ── cd / shell-init subcommands ──────────────────────────────────────────── fn cmd_cd(branch: Option<&str>) -> Result<(), String> { @@ -1106,14 +1251,21 @@ fn shell_init_snippet(shell: ShellKind) -> &'static str { } } -const POSIX_SHELL_INIT: &str = r#"grove() { +const POSIX_SHELL_INIT: &str = r#"__grove_cd() { + local _grove_target + _grove_target=$(command grove "$@") || return $? + cd "$_grove_target" +} +grove() { case "$1" in - cd|convert) - local _grove_subcmd="$1" - shift - local _grove_target - _grove_target=$(command grove "$_grove_subcmd" "$@") || return $? - cd "$_grove_target" + cd|convert|absorb) + __grove_cd "$@" + ;; + worktree) + case "$2" in + convert|absorb) __grove_cd "$@" ;; + *) command grove "$@" ;; + esac ;; *) command grove "$@" @@ -1122,14 +1274,22 @@ const POSIX_SHELL_INIT: &str = r#"grove() { } "#; -const FISH_SHELL_INIT: &str = r#"function grove +const FISH_SHELL_INIT: &str = r#"function __grove_cd + set -l _grove_target (command grove $argv) + or return $status + cd $_grove_target +end +function grove switch $argv[1] - case cd convert - set -l _grove_subcmd $argv[1] - set -e argv[1] - set -l _grove_target (command grove $_grove_subcmd $argv) - or return $status - cd $_grove_target + case cd convert absorb + __grove_cd $argv + case worktree + switch $argv[2] + case convert absorb + __grove_cd $argv + case '*' + command grove $argv + end case '*' command grove $argv end @@ -1542,6 +1702,30 @@ mod tests { assert!(msg.contains("lives in its new worktree"), "{msg}"); } + #[test] + fn plan_absorb_rejects_main_worktree() { + let err = plan_absorb(true, Some("main"), false).unwrap_err(); + assert!(err.contains("into itself"), "{err}"); + } + + #[test] + fn plan_absorb_rejects_detached_head() { + let err = plan_absorb(false, None, false).unwrap_err(); + assert!(err.contains("detached HEAD"), "{err}"); + } + + #[test] + fn plan_absorb_rejects_dirty_main() { + let err = plan_absorb(false, Some("feat/foo"), true).unwrap_err(); + assert!(err.contains("uncommitted changes"), "{err}"); + } + + #[test] + fn plan_absorb_returns_branch_when_clean() { + let branch = plan_absorb(false, Some("feat/foo"), false).unwrap(); + assert_eq!(branch, "feat/foo"); + } + #[test] fn match_branch_none_when_no_match() { let worktrees = vec![parsed("/repo", Some("main"))]; diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 3dac41c..ed227b2 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -228,6 +228,64 @@ pub fn is_dirty(worktree_path: &Path) -> Result { Ok(!output.trim().is_empty()) } +/// Returns true if the worktree has uncommitted *tracked* changes (staged or +/// unstaged). Untracked files are ignored — `git switch` preserves them, and +/// nested worktree directories show up as untracked in the parent. Use this +/// (not `is_dirty`) when deciding whether a branch switch is safe. +pub fn has_tracked_modifications(worktree_path: &Path) -> Result { + let output = run_git_text(worktree_path, ["status", "--porcelain"])?; + Ok(output + .lines() + .any(|line| !line.trim().is_empty() && !line.starts_with("??"))) +} + +fn stash_count(worktree_path: &Path) -> Result { + let out = run_git_text(worktree_path, ["stash", "list"])?; + Ok(out.lines().filter(|line| !line.trim().is_empty()).count()) +} + +/// Stash all tracked changes plus untracked files (NOT ignored files) in +/// `worktree_path`. Returns true if a stash entry was actually created. +pub fn stash_push_including_untracked( + worktree_path: &Path, + message: &str, +) -> Result { + let before = stash_count(worktree_path)?; + run_git_text( + worktree_path, + ["stash", "push", "--include-untracked", "--message", message], + )?; + Ok(stash_count(worktree_path)? > before) +} + +/// Pop the most recent stash into `worktree_path`, trying to restore the index +/// (staged/unstaged distinction) first and falling back to a plain pop if the +/// index cannot be reinstated cleanly. The fallback only runs while the stash is +/// still present, so changes are never applied twice. +pub fn stash_pop_restoring_index(worktree_path: &Path) -> Result<(), String> { + match run_git_text(worktree_path, ["stash", "pop", "--index"]) { + Ok(_) => Ok(()), + Err(index_err) => { + if stash_count(worktree_path)? == 0 { + return Err(index_err); + } + run_git_text(worktree_path, ["stash", "pop"]).map(|_| ()) + } + } +} + +/// List ignored entries in `worktree_path`. These are deleted together with the +/// worktree directory, so callers can warn before removing. Paths are returned +/// as git reports them (directories may be collapsed, e.g. `node_modules/`). +pub fn list_ignored(worktree_path: &Path) -> Result, String> { + let out = run_git_text(worktree_path, ["status", "--porcelain", "--ignored"])?; + Ok(out + .lines() + .filter_map(|line| line.strip_prefix("!! ")) + .map(|path| path.trim().to_string()) + .collect()) +} + pub fn head_commit_date(worktree_path: &Path) -> Option { run_git_text(worktree_path, ["log", "-1", "--format=%aI", "HEAD"]) .ok() From 15f0006ac56f945e11ad1821411c807d0b97098e Mon Sep 17 00:00:00 2001 From: shawn Date: Tue, 16 Jun 2026 11:16:54 +0800 Subject: [PATCH 2/3] refactor: rename worktree verbs to detach/attach (was convert/absorb) `convert`/`absorb` described the mechanism, not the intent, and weren't memorable. `detach`/`attach` is a clearer inverse pair: detach the current branch into its own worktree; attach a worktree's branch back onto main. - `convert` is kept as a hidden legacy alias on `detach` (it shipped in #28); `absorb` was unreleased, so it's renamed outright with no alias. - help text is worded around worktrees (not detached HEAD) to avoid the one downside of the `detach` name. - shell-init auto-cd now covers detach/attach plus the convert alias. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- src-tauri/src/cli.rs | 148 ++++++++++++++++++++++--------------------- 2 files changed, 76 insertions(+), 74 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 32e8a49..f4073f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ cd src-tauri && cargo clippy # lint Rust code - `models.rs` — all serde structs shared between commands; camelCase-renamed for the JS bridge - `actions.rs` — create/remove/start/launch worktrees, run hooks, prune. Streams stdout/stderr as logs; stderr is logged as info unless the process exits non-zero. - `store.rs` — persists recent repos, PR cache, per-repo config and worktree root overrides, and default terminal as JSON to `~/.grove/store.json`. All functions (`persist`, `load_store`, `store_path`) are Tauri-independent — no `AppHandle` required. -- `cli.rs` — clap-based CLI. Worktree lifecycle verbs live under `grove worktree` (`list`, `new`, `convert`, `absorb`, `rm`), with hidden top-level aliases (`grove new`/`convert`/`absorb`/`rm`) kept for backward compatibility. Other groups: `grove open`, `grove hook run/list/edit`, `grove config …`, `grove cd`, `grove shell-init`. `absorb` is the inverse of `convert`: it removes a linked worktree and switches the main worktree onto its branch, carrying uncommitted changes via a stash. The binary is dual-mode: CLI args → terminal mode, no args → GUI. Detected in `main.rs` before Tauri init. +- `cli.rs` — clap-based CLI. Worktree lifecycle verbs live under `grove worktree` (`list`, `new`, `detach`, `attach`, `rm`), with hidden top-level aliases (`grove new`/`detach`/`attach`/`rm`) kept for ergonomics. `detach`/`grove worktree detach` also accepts the legacy alias `convert` (the command was first shipped as `convert`). Other groups: `grove open`, `grove hook run/list/edit`, `grove config …`, `grove cd`, `grove shell-init`. `attach` is the inverse of `detach`: it removes a linked worktree and switches the main worktree onto its branch, carrying uncommitted changes via a stash. The binary is dual-mode: CLI args → terminal mode, no args → GUI. Detected in `main.rs` before Tauri init. - `platform/` — cross-platform terminal launch abstraction (`open_terminal_at`, `open_terminal_app`) with per-OS implementations (macOS/Windows/Linux) **IPC pattern**: Rust structs use `#[serde(rename_all = "camelCase")]`. The frontend calls `invoke("command_name", { input })` and receives camelCase JSON. When adding a new command: register in `lib.rs` `invoke_handler` macro → add TS wrapper in `api.ts` → add type in `types.ts`. diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index 036f691..e9bdd35 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -51,12 +51,12 @@ enum Commands { /// Create a worktree for a branch (alias of `grove worktree new`) #[command(hide = true)] New(NewArgs), - /// Convert the main worktree's branch into a worktree (alias of `grove worktree convert`) + /// Detach the current branch into its own worktree (alias of `grove worktree detach`) + #[command(alias = "convert", hide = true)] + Detach(DetachArgs), + /// Attach a worktree's branch back onto the main worktree (alias of `grove worktree attach`) #[command(hide = true)] - Convert(ConvertArgs), - /// Absorb a worktree's branch back into the main worktree (alias of `grove worktree absorb`) - #[command(hide = true)] - Absorb(AbsorbArgs), + Attach(AttachArgs), /// Print the path of a worktree by branch (use with `grove shell-init` to actually cd) Cd { /// Branch name (defaults to the main worktree) @@ -95,17 +95,18 @@ enum WorktreeCommands { List, /// Create a worktree for a branch New(NewArgs), - /// Convert the main worktree's current branch into a separate worktree + /// Detach the current branch into its own worktree /// - /// Switches the main worktree onto the configured base branch and creates - /// a new worktree for the branch that was previously checked out there. - Convert(ConvertArgs), - /// Absorb a worktree's branch back into the main worktree + /// Moves the main worktree's current branch into a new linked worktree and + /// switches the main worktree back onto the configured base branch. + #[command(alias = "convert")] + Detach(DetachArgs), + /// Attach a worktree's branch back onto the main worktree /// - /// The inverse of `convert`: removes the linked worktree and switches the + /// The inverse of `detach`: removes the linked worktree and switches the /// main worktree onto its branch. Uncommitted changes are carried over via /// a stash; committed work is never at risk. - Absorb(AbsorbArgs), + Attach(AttachArgs), /// Remove a worktree #[command(alias = "remove")] Rm(RmArgs), @@ -189,8 +190,9 @@ const CLI_SUBCOMMANDS: &[&str] = &[ "worktree", "config", "new", + "detach", "convert", - "absorb", + "attach", "rm", "remove", "cd", @@ -236,15 +238,15 @@ pub fn main() { Some(Commands::Worktree { command }) => match command { WorktreeCommands::List => cmd_worktree_list(), WorktreeCommands::New(args) => cmd_new(args), - WorktreeCommands::Convert(args) => cmd_convert(args), - WorktreeCommands::Absorb(args) => cmd_absorb(args), + WorktreeCommands::Detach(args) => cmd_detach(args), + WorktreeCommands::Attach(args) => cmd_attach(args), WorktreeCommands::Rm(args) => cmd_rm(args), }, Some(Commands::Config { command }) => cmd_config(command), // Top-level aliases for the worktree lifecycle verbs (hidden from help). Some(Commands::New(args)) => cmd_new(args), - Some(Commands::Convert(args)) => cmd_convert(args), - Some(Commands::Absorb(args)) => cmd_absorb(args), + Some(Commands::Detach(args)) => cmd_detach(args), + Some(Commands::Attach(args)) => cmd_attach(args), Some(Commands::Cd { branch }) => cmd_cd(branch.as_deref()), Some(Commands::ShellInit { shell }) => { print!("{}", shell_init_snippet(shell)); @@ -896,10 +898,10 @@ fn prompt_confirm(action: &str) -> Result<(), String> { } } -// ── convert subcommand ──────────────────────────────────────────────────── +// ── detach subcommand ────────────────────────────────────────────────────── #[derive(clap::Args)] -struct ConvertArgs { +struct DetachArgs { /// Target worktree path (default: configured worktree root + sanitized branch name) path: Option, /// Base branch to switch the main worktree onto (default: configured default base) @@ -910,31 +912,31 @@ struct ConvertArgs { no_hooks: bool, } -fn plan_convert(branch: Option<&str>, dirty: bool, base: &str) -> Result { +fn plan_detach(branch: Option<&str>, dirty: bool, base: &str) -> Result { let branch = branch.ok_or_else(|| { "main worktree is in detached HEAD state — checkout a branch first".to_string() })?; if branch == base { return Err(format!( - "main worktree is already on '{base}' — nothing to convert" + "main worktree is already on '{base}' — nothing to detach" )); } if dirty { return Err( "main worktree is not clean — commit or stash your changes (untracked files \ - included) before converting" + included) before detaching" .into(), ); } Ok(branch.to_string()) } -/// Build the failure message for a `create_worktree_cli` error during convert. +/// Build the failure message for a `create_worktree_cli` error during detach. /// /// When the worktree was already created (the branch is now checked out in its /// new worktree), a `git switch {branch}` undo would fail — so the undo hint is /// only emitted while the branch is still free. -fn convert_failure_message(branch: &str, base: &str, err: &str, worktree_created: bool) -> String { +fn detach_failure_message(branch: &str, base: &str, err: &str, worktree_created: bool) -> String { if worktree_created { format!( "{err}\nnote: the worktree for '{branch}' was created but a post-create step failed; \ @@ -947,16 +949,16 @@ fn convert_failure_message(branch: &str, base: &str, err: &str, worktree_created } } -fn cmd_convert(args: ConvertArgs) -> Result<(), String> { +fn cmd_detach(args: DetachArgs) -> Result<(), String> { let repo_root = current_repo_root()?; - // `convert` always operates on the *main* worktree. Warn when invoked from a + // `detach` always operates on the *main* worktree. Warn when invoked from a // linked worktree so the user isn't surprised that main got switched. if let Ok(cwd) = std::env::current_dir() { if let Ok(local) = git::resolve_repo_root(&cwd.to_string_lossy()) { if local != repo_root { eprintln!( - "note: convert operates on the main worktree ({}), not the current worktree", + "note: detach operates on the main worktree ({}), not the current worktree", repo_root.display() ); } @@ -973,7 +975,7 @@ fn cmd_convert(args: ConvertArgs) -> Result<(), String> { let current = git::current_branch(&repo_root)?; let dirty = git::is_dirty(&repo_root)?; - let branch = plan_convert(current.as_deref(), dirty, &base)?; + let branch = plan_detach(current.as_deref(), dirty, &base)?; // Pre-check the target path so a collision fails before we switch main off // the branch we're about to extract. @@ -1010,7 +1012,7 @@ fn cmd_convert(args: ConvertArgs) -> Result<(), String> { let worktree_created = git::list_worktrees(&repo_root) .map(|wts| wts.iter().any(|w| w.branch.as_deref() == Some(branch.as_str()))) .unwrap_or(false); - return Err(convert_failure_message(&branch, &base, &e, worktree_created)); + return Err(detach_failure_message(&branch, &base, &e, worktree_created)); } }; @@ -1018,11 +1020,11 @@ fn cmd_convert(args: ConvertArgs) -> Result<(), String> { Ok(()) } -// ── absorb subcommand ────────────────────────────────────────────────────── +// ── attach subcommand ────────────────────────────────────────────────────── #[derive(clap::Args)] -struct AbsorbArgs { - /// Branch / worktree to absorb (defaults to the worktree containing the current directory) +struct AttachArgs { + /// Branch / worktree to attach (defaults to the worktree containing the current directory) branch: Option, /// Skip the confirmation prompt #[arg(short = 'y', long)] @@ -1035,28 +1037,28 @@ struct AbsorbArgs { no_hooks: bool, } -/// Validate an absorb request. Returns the branch to move into the main +/// Validate an attach request. Returns the branch to move into the main /// worktree, or an error describing why the request can't proceed. -fn plan_absorb( +fn plan_attach( target_is_main: bool, target_branch: Option<&str>, main_dirty: bool, ) -> Result { if target_is_main { - return Err("cannot absorb the main worktree into itself".into()); + return Err("cannot attach the main worktree to itself".into()); } let branch = target_branch.ok_or_else(|| { - "worktree is in detached HEAD state — no branch to absorb".to_string() + "worktree is in detached HEAD state — no branch to attach".to_string() })?; if main_dirty { return Err( - "main worktree has uncommitted changes — commit or stash them before absorbing".into(), + "main worktree has uncommitted changes — commit or stash them before attaching".into(), ); } Ok(branch.to_string()) } -fn resolve_absorb_target<'a>( +fn resolve_attach_target<'a>( worktrees: &'a [git::ParsedWorktree], main_root: &Path, branch: Option<&str>, @@ -1078,13 +1080,13 @@ fn resolve_absorb_target<'a>( .unwrap_or(false) }) .ok_or_else(|| { - "not inside a linked worktree — pass a branch name, or cd into the worktree to absorb" + "not inside a linked worktree — pass a branch name, or cd into the worktree to attach" .to_string() }) } } -fn cmd_absorb(args: AbsorbArgs) -> Result<(), String> { +fn cmd_attach(args: AttachArgs) -> Result<(), String> { let repo_root = current_repo_root()?; let main_root = git::canonicalize(&repo_root)?; let state = actions::build_cli_state()?; @@ -1092,22 +1094,22 @@ fn cmd_absorb(args: AbsorbArgs) -> Result<(), String> { let cwd = std::env::current_dir() .map_err(|e| format!("cannot determine current directory: {e}"))?; - let target = resolve_absorb_target(&worktrees, &main_root, args.branch.as_deref(), &cwd)?; + let target = resolve_attach_target(&worktrees, &main_root, args.branch.as_deref(), &cwd)?; let target_path = git::canonicalize(&target.path)?; let target_is_main = target_path == main_root; // Only tracked changes block the switch; untracked files (incl. nested // worktree dirs under the default in-repo worktree root) are preserved. let main_dirty = git::has_tracked_modifications(&repo_root)?; - let branch = plan_absorb(target_is_main, target.branch.as_deref(), main_dirty)?; + let branch = plan_attach(target_is_main, target.branch.as_deref(), main_dirty)?; let target_dirty = git::is_dirty(&target_path)?; let ignored = git::list_ignored(&target_path).unwrap_or_default(); - print_absorb_plan(&branch, &target_path, target_dirty, &ignored); + print_attach_plan(&branch, &target_path, target_dirty, &ignored); if !args.yes { prompt_confirm(&format!( - "absorb '{branch}' into the main worktree (removes the worktree at {})?", + "attach '{branch}' onto the main worktree (removes the worktree at {})?", target_path.display() ))?; } @@ -1117,7 +1119,7 @@ fn cmd_absorb(args: AbsorbArgs) -> Result<(), String> { let stashed = if target_dirty { let created = git::stash_push_including_untracked( &target_path, - &format!("grove absorb {branch}"), + &format!("grove attach {branch}"), )?; if created { eprintln!("Stashed uncommitted changes from {}", target_path.display()); @@ -1145,7 +1147,7 @@ fn cmd_absorb(args: AbsorbArgs) -> Result<(), String> { return Err(e); } - // 3. Switch the main worktree onto the absorbed branch. + // 3. Switch the main worktree onto the attached branch. git::run_git_text(&repo_root, ["switch", &branch]) .map_err(|stderr| format!("failed to switch main worktree to '{branch}': {stderr}"))?; eprintln!("Switched main worktree to {branch}"); @@ -1165,8 +1167,8 @@ fn cmd_absorb(args: AbsorbArgs) -> Result<(), String> { Ok(()) } -fn print_absorb_plan(branch: &str, target_path: &Path, dirty: bool, ignored: &[String]) { - eprintln!("absorb: {branch}"); +fn print_attach_plan(branch: &str, target_path: &Path, dirty: bool, ignored: &[String]) { + eprintln!("attach: {branch}"); eprintln!("worktree: {}", target_path.display()); if dirty { eprintln!("changes: uncommitted changes will be carried over via a stash"); @@ -1258,12 +1260,12 @@ const POSIX_SHELL_INIT: &str = r#"__grove_cd() { } grove() { case "$1" in - cd|convert|absorb) + cd|detach|attach|convert) __grove_cd "$@" ;; worktree) case "$2" in - convert|absorb) __grove_cd "$@" ;; + detach|attach|convert) __grove_cd "$@" ;; *) command grove "$@" ;; esac ;; @@ -1281,11 +1283,11 @@ const FISH_SHELL_INIT: &str = r#"function __grove_cd end function grove switch $argv[1] - case cd convert absorb + case cd detach attach convert __grove_cd $argv case worktree switch $argv[2] - case convert absorb + case detach attach convert __grove_cd $argv case '*' command grove $argv @@ -1663,66 +1665,66 @@ mod tests { } #[test] - fn plan_convert_rejects_default_branch() { - let err = plan_convert(Some("main"), false, "main").unwrap_err(); + fn plan_detach_rejects_default_branch() { + let err = plan_detach(Some("main"), false, "main").unwrap_err(); assert!(err.contains("already on 'main'"), "{err}"); } #[test] - fn plan_convert_rejects_detached_head() { - let err = plan_convert(None, false, "main").unwrap_err(); + fn plan_detach_rejects_detached_head() { + let err = plan_detach(None, false, "main").unwrap_err(); assert!(err.contains("detached HEAD"), "{err}"); } #[test] - fn plan_convert_rejects_dirty_main() { - let err = plan_convert(Some("feat/foo"), true, "main").unwrap_err(); + fn plan_detach_rejects_dirty_main() { + let err = plan_detach(Some("feat/foo"), true, "main").unwrap_err(); assert!(err.contains("not clean"), "{err}"); assert!(err.contains("untracked"), "{err}"); } #[test] - fn plan_convert_returns_branch_on_clean_main() { - let branch = plan_convert(Some("feat/foo"), false, "main").unwrap(); + fn plan_detach_returns_branch_on_clean_main() { + let branch = plan_detach(Some("feat/foo"), false, "main").unwrap(); assert_eq!(branch, "feat/foo"); } #[test] - fn convert_failure_suggests_switch_when_worktree_not_created() { - let msg = convert_failure_message("feat/foo", "main", "boom", false); + fn detach_failure_suggests_switch_when_worktree_not_created() { + let msg = detach_failure_message("feat/foo", "main", "boom", false); assert!(msg.contains("git switch feat/foo"), "{msg}"); assert!(msg.contains("to undo"), "{msg}"); } #[test] - fn convert_failure_omits_switch_when_worktree_created() { - let msg = convert_failure_message("feat/foo", "main", "boom", true); + fn detach_failure_omits_switch_when_worktree_created() { + let msg = detach_failure_message("feat/foo", "main", "boom", true); assert!(!msg.contains("git switch feat/foo"), "{msg}"); assert!(msg.contains("post-create step failed"), "{msg}"); assert!(msg.contains("lives in its new worktree"), "{msg}"); } #[test] - fn plan_absorb_rejects_main_worktree() { - let err = plan_absorb(true, Some("main"), false).unwrap_err(); - assert!(err.contains("into itself"), "{err}"); + fn plan_attach_rejects_main_worktree() { + let err = plan_attach(true, Some("main"), false).unwrap_err(); + assert!(err.contains("cannot attach"), "{err}"); } #[test] - fn plan_absorb_rejects_detached_head() { - let err = plan_absorb(false, None, false).unwrap_err(); + fn plan_attach_rejects_detached_head() { + let err = plan_attach(false, None, false).unwrap_err(); assert!(err.contains("detached HEAD"), "{err}"); } #[test] - fn plan_absorb_rejects_dirty_main() { - let err = plan_absorb(false, Some("feat/foo"), true).unwrap_err(); + fn plan_attach_rejects_dirty_main() { + let err = plan_attach(false, Some("feat/foo"), true).unwrap_err(); assert!(err.contains("uncommitted changes"), "{err}"); } #[test] - fn plan_absorb_returns_branch_when_clean() { - let branch = plan_absorb(false, Some("feat/foo"), false).unwrap(); + fn plan_attach_returns_branch_when_clean() { + let branch = plan_attach(false, Some("feat/foo"), false).unwrap(); assert_eq!(branch, "feat/foo"); } From 5ae0f38ade1698667b2bab3d400312a206a05745 Mon Sep 17 00:00:00 2001 From: shawn Date: Tue, 16 Jun 2026 11:55:11 +0800 Subject: [PATCH 3/3] fix: surface stash recovery hint when attach switch fails When `grove attach`'s `git switch` step fails, the linked worktree is already removed and the branch's uncommitted work sits in a stash. The error now points the user at `git switch && git stash pop`, matching the recovery guidance already given on the remove/pop failure paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/src/cli.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index e9bdd35..d3798cd 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -1147,9 +1147,20 @@ fn cmd_attach(args: AttachArgs) -> Result<(), String> { return Err(e); } - // 3. Switch the main worktree onto the attached branch. - git::run_git_text(&repo_root, ["switch", &branch]) - .map_err(|stderr| format!("failed to switch main worktree to '{branch}': {stderr}"))?; + // 3. Switch the main worktree onto the attached branch. The worktree is + // already gone, so a failure here strands the stashed work — point the + // user at the exact recovery steps instead of just the raw git error. + git::run_git_text(&repo_root, ["switch", &branch]).map_err(|stderr| { + let mut msg = format!("failed to switch main worktree to '{branch}': {stderr}"); + if stashed { + msg.push_str(&format!( + "\nnote: your changes are safe in a stash — run `git switch {branch} && \ + git stash pop` in {} to recover", + repo_root.display() + )); + } + msg + })?; eprintln!("Switched main worktree to {branch}"); // 4. Re-apply the stashed work onto the main worktree.