diff --git a/README.md b/README.md index cd90593..8eca4b2 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,13 @@ gx workspace remove [query] # Remove a workspace (asks for confirmation). gx workspace remove --force # Remove even with uncommitted changes gx workspace remove --delete-branch # Also delete the local branch gx workspace setup # Re-run setup: copy files, then run setup script +gx workspace root # Print the main worktree root, e.g. cd "$(gx workspace root)" +gx workspace move # Move a workspace to a new path (refuses the + # main worktree and existing destinations) +gx workspace lock [--reason ] # Lock a workspace so cleanup and + # 'git worktree prune' skip it +gx workspace unlock # Clear a workspace lock +gx workspace repair [query] # Repair worktree admin files after a move (all if omitted) ``` **Interactive TUI** (`gx workspace`): fuzzy search across workspace names and branches, with `enter` to switch and `ctrl+n` to create a workspace named after the current query. The workspace list supports multi-select: `space` toggles a workspace, `ctrl+a` toggles all visible workspaces, and `ctrl+u` clears selections. When GitHub CLI (`gh`) is available, workspace rows show PR badges for open, draft, merged, and closed pull requests. Use `ctrl+d` to remove selected workspaces, or `ctrl+b` to remove them and delete their local branches after an inline confirmation. Bulk actions include `ctrl+r` to update/rebase selected workspaces and `ctrl+t` to re-copy setup files. Press `?` for the full help screen. diff --git a/src/args.rs b/src/args.rs index f281385..90570d0 100644 --- a/src/args.rs +++ b/src/args.rs @@ -229,6 +229,42 @@ pub enum WorkspaceCommands { /// Copy setup files (e.g. .env) from the main worktree into this workspace Setup, + + /// Print the main worktree root (for use in scripts, e.g. cd "$(gx workspace root)") + Root, + + /// Move a workspace to a new path + #[command(alias = "mv")] + Move { + /// Workspace to move (supports fuzzy matching like 'gx workspace go') + workspace: String, + + /// New path for the workspace (relative paths resolve against the + /// current directory; '~' is expanded) + new_path: String, + }, + + /// Lock a workspace so cleanup and 'git worktree prune' skip it + Lock { + /// Workspace to lock (supports fuzzy matching like 'gx workspace go') + workspace: String, + + /// Optional reason recorded by git and shown in 'git worktree list --verbose' + #[arg(long)] + reason: Option, + }, + + /// Unlock a previously locked workspace + Unlock { + /// Workspace to unlock (supports fuzzy matching like 'gx workspace go') + workspace: String, + }, + + /// Repair worktree administrative files (recovery for moved or damaged worktrees) + Repair { + /// Workspace to repair (repairs all worktrees when omitted) + workspace: Option, + }, } impl Commands { @@ -287,6 +323,20 @@ impl Commands { delete_branch, }) => commands::workspace::run_remove(query, force, delete_branch), Some(WorkspaceCommands::Setup) => commands::workspace::run_setup(), + Some(WorkspaceCommands::Root) => commands::workspace::run_root(), + Some(WorkspaceCommands::Move { + workspace, + new_path, + }) => commands::workspace::run_move(workspace, new_path), + Some(WorkspaceCommands::Lock { workspace, reason }) => { + commands::workspace::run_lock(workspace, reason) + } + Some(WorkspaceCommands::Unlock { workspace }) => { + commands::workspace::run_unlock(workspace) + } + Some(WorkspaceCommands::Repair { workspace }) => { + commands::workspace::run_repair(workspace) + } }, Commands::Pr { action } => match action { None => commands::pr::run_interactive(), diff --git a/src/commands/workspace.rs b/src/commands/workspace.rs index 1a8c052..d5e8ca7 100644 --- a/src/commands/workspace.rs +++ b/src/commands/workspace.rs @@ -42,6 +42,29 @@ pub enum WorkspaceError { )] RemoveMain, + #[error("Cannot move the main worktree")] + #[diagnostic( + code(gx::workspace::move_main), + help( + "The main worktree is the original repository checkout; move it with 'git worktree move' manually if you must" + ) + )] + MoveMain, + + #[error("Destination '{0}' already exists")] + #[diagnostic( + code(gx::workspace::destination_exists), + help("Choose a path that does not exist yet") + )] + DestinationExists(PathBuf), + + #[error("Cannot lock the main worktree")] + #[diagnostic( + code(gx::workspace::lock_main), + help("The main worktree is the original repository checkout and cannot be locked") + )] + LockMain, + #[error("Invalid workspace name: {0}")] #[diagnostic(code(gx::workspace::invalid_name))] InvalidName(String), @@ -460,6 +483,118 @@ pub fn run_setup() -> Result<()> { setup_worktrees(&[current], &worktrees) } +/// Print the main worktree root to STDOUT, for use in scripts and aliases: +/// `cd "$(gx workspace root)"`. Unlike the navigation commands this is a plain +/// value command, so it emits the canonical path on stdout with no stderr hint. +pub fn run_root() -> Result<()> { + let worktrees = git::worktree::list().map_err(WorkspaceError::GitError)?; + let main_root = main_worktree_root(&worktrees)?; + let canonical = main_root.canonicalize().unwrap_or(main_root); + println!("{}", canonical.display()); + Ok(()) +} + +/// Move a workspace to a new path via `git worktree move`. Refuses the main +/// worktree and refuses an existing destination. Human-readable output goes to +/// stderr; nothing is written to stdout unless the CURRENT workspace is moved, +/// in which case the new path is emitted via [`print_go_path`] so the shell +/// wrapper follows the user into it (mirroring the current-workspace handling +/// in [`remove_worktrees`]). +pub fn run_move(workspace: String, new_path: String) -> Result<()> { + let worktrees = git::worktree::list().map_err(WorkspaceError::GitError)?; + let target = fuzzy_match_worktree(&workspace, &worktrees) + .ok_or_else(|| WorkspaceError::NoMatch(workspace.clone()))?; + + if target.is_main { + return Err(WorkspaceError::MoveMain.into()); + } + + let cwd = std::env::current_dir().map_err(WorkspaceError::CopyFailed)?; + let dest = resolve_dest_path(&new_path, home_dir().as_deref(), &cwd); + + if dest.exists() { + return Err(WorkspaceError::DestinationExists(dest).into()); + } + + let main_root = main_worktree_root(&worktrees)?; + git::worktree::move_worktree(&main_root, &target.path, &dest) + .map_err(WorkspaceError::GitError)?; + + eprintln!("Moved workspace '{}' to {}", target.name, dest.display()); + + if target.is_current { + eprintln!("Switching to moved workspace"); + print_go_path(&dest); + } + + Ok(()) +} + +/// Lock a workspace so cleanup and `git worktree prune` skip it. Refuses the +/// main worktree (git itself errors on locking it). Output goes to stderr only. +pub fn run_lock(workspace: String, reason: Option) -> Result<()> { + let worktrees = git::worktree::list().map_err(WorkspaceError::GitError)?; + let target = fuzzy_match_worktree(&workspace, &worktrees) + .ok_or_else(|| WorkspaceError::NoMatch(workspace.clone()))?; + + if target.is_main { + return Err(WorkspaceError::LockMain.into()); + } + + let main_root = main_worktree_root(&worktrees)?; + git::worktree::lock(&main_root, &target.path, reason.as_deref()) + .map_err(WorkspaceError::GitError)?; + + eprintln!( + "Locked workspace '{}'{}", + target.name, + reason.map(|r| format!(" ({})", r)).unwrap_or_default() + ); + Ok(()) +} + +/// Clear the lock on a previously locked workspace. Output goes to stderr only. +pub fn run_unlock(workspace: String) -> Result<()> { + let worktrees = git::worktree::list().map_err(WorkspaceError::GitError)?; + let target = fuzzy_match_worktree(&workspace, &worktrees) + .ok_or_else(|| WorkspaceError::NoMatch(workspace.clone()))?; + + let main_root = main_worktree_root(&worktrees)?; + git::worktree::unlock(&main_root, &target.path).map_err(WorkspaceError::GitError)?; + + eprintln!("Unlocked workspace '{}'", target.name); + Ok(()) +} + +/// Repair worktree administrative files. With a workspace query, repairs only +/// that worktree; with no query, repairs all of them. Recovery command, so +/// output goes to stderr only. Mostly useful after a worktree directory or the +/// main repository has been moved on disk. +pub fn run_repair(workspace: Option) -> Result<()> { + let worktrees = git::worktree::list().map_err(WorkspaceError::GitError)?; + let main_root = main_worktree_root(&worktrees)?; + + let paths: Vec = match &workspace { + Some(q) => { + let target = fuzzy_match_worktree(q, &worktrees) + .ok_or_else(|| WorkspaceError::NoMatch(q.clone()))?; + vec![target.path] + } + None => Vec::new(), + }; + + git::worktree::repair(&main_root, &paths).map_err(WorkspaceError::GitError)?; + + match paths.as_slice() { + [path] => eprintln!( + "Repaired worktree administrative files for {}", + path.display() + ), + _ => eprintln!("Repaired worktree administrative files"), + } + Ok(()) +} + /// Interactive workspace manager (default when no subcommand is given). pub fn run_interactive() -> Result<()> { let worktrees = git::worktree::list().map_err(WorkspaceError::GitError)?; @@ -950,17 +1085,7 @@ fn workspace_path( let root = root_template.replace("{repo}", &repo_name); - let root_path = if let Some(home) = home { - if root == "~" { - home.to_path_buf() - } else if let Some(rest) = root.strip_prefix("~/") { - home.join(rest) - } else { - PathBuf::from(&root) - } - } else { - PathBuf::from(&root) - }; + let root_path = expand_home(&root, home); let base = if root_path.is_absolute() { root_path @@ -971,6 +1096,31 @@ fn workspace_path( base.join(name) } +/// Expand a leading '~' (or '~/...') in `path` against `home`. When `home` is +/// unknown the '~' is left untouched and treated as a literal path component. +fn expand_home(path: &str, home: Option<&Path>) -> PathBuf { + match home { + Some(home) if path == "~" => home.to_path_buf(), + Some(home) => match path.strip_prefix("~/") { + Some(rest) => home.join(rest), + None => PathBuf::from(path), + }, + None => PathBuf::from(path), + } +} + +/// Resolve a user-supplied move destination to an absolute path: expand a +/// leading '~', then make relative paths absolute against the current working +/// directory (matching `git worktree move`'s own relative-path behavior). +fn resolve_dest_path(input: &str, home: Option<&Path>, cwd: &Path) -> PathBuf { + let expanded = expand_home(input, home); + if expanded.is_absolute() { + expanded + } else { + cwd.join(expanded) + } +} + fn home_dir() -> Option { std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) @@ -1072,6 +1222,46 @@ mod tests { assert_eq!(path, PathBuf::from("/repo/~/ws/fix")); } + #[test] + fn test_expand_home() { + let home = Path::new("/home/user"); + assert_eq!(expand_home("~", Some(home)), PathBuf::from("/home/user")); + assert_eq!( + expand_home("~/ws/feat", Some(home)), + PathBuf::from("/home/user/ws/feat") + ); + // A bare absolute path is untouched. + assert_eq!(expand_home("/srv/ws", Some(home)), PathBuf::from("/srv/ws")); + // Without a home dir, '~' stays literal. + assert_eq!(expand_home("~/ws", None), PathBuf::from("~/ws")); + } + + #[test] + fn test_resolve_dest_path() { + let home = Path::new("/home/user"); + let cwd = Path::new("/work/cwd"); + + // Absolute destination is used as-is. + assert_eq!( + resolve_dest_path("/srv/ws/moved", Some(home), cwd), + PathBuf::from("/srv/ws/moved") + ); + // '~' expands to home and stays absolute. + assert_eq!( + resolve_dest_path("~/ws/moved", Some(home), cwd), + PathBuf::from("/home/user/ws/moved") + ); + // A relative destination resolves against the current working dir. + assert_eq!( + resolve_dest_path("moved", Some(home), cwd), + PathBuf::from("/work/cwd/moved") + ); + assert_eq!( + resolve_dest_path("../sibling", Some(home), cwd), + PathBuf::from("/work/cwd/../sibling") + ); + } + #[test] fn test_validate_name() { assert!(validate_name("feature-x").is_ok()); diff --git a/src/git/worktree.rs b/src/git/worktree.rs index 95c2622..790ec11 100644 --- a/src/git/worktree.rs +++ b/src/git/worktree.rs @@ -298,6 +298,120 @@ pub fn delete_branch(from: &Path, branch_name: &str, force: bool) -> Result<(), Ok(()) } +/// Move the worktree at `path` to `new_path`. Runs git from `from` (the main +/// worktree) and prefers `git worktree move` over a manual filesystem move so +/// Git's administrative files (`.git` pointer, gitdir link) stay consistent. +pub fn move_worktree(from: &Path, path: &Path, new_path: &Path) -> Result<(), GitError> { + git_exec::exec( + move_worktree_args(from, path, new_path), + ExecOptions { + silent: true, + ..Default::default() + }, + )?; + Ok(()) +} + +/// Lock the worktree at `path` so cleanup and `git worktree prune` skip it. +/// Runs git from `from` (the main worktree). An optional `reason` is recorded +/// by git and shown in `git worktree list --verbose`. +pub fn lock(from: &Path, path: &Path, reason: Option<&str>) -> Result<(), GitError> { + git_exec::exec( + lock_args(from, path, reason), + ExecOptions { + silent: true, + ..Default::default() + }, + )?; + Ok(()) +} + +/// Clear the lock on the worktree at `path`. Runs git from `from` (the main +/// worktree). +pub fn unlock(from: &Path, path: &Path) -> Result<(), GitError> { + git_exec::exec( + unlock_args(from, path), + ExecOptions { + silent: true, + ..Default::default() + }, + )?; + Ok(()) +} + +/// Repair worktree administrative files (the two-way links between the main +/// repository and its linked worktrees). With no `paths`, git repairs every +/// worktree; otherwise it repairs only the listed ones. Mostly for recovery +/// after a worktree directory or the main repo has been moved. Runs git from +/// `from` (the main worktree). +pub fn repair(from: &Path, paths: &[PathBuf]) -> Result<(), GitError> { + git_exec::exec( + repair_args(from, paths), + ExecOptions { + silent: true, + ..Default::default() + }, + )?; + Ok(()) +} + +// The arg builders are split out as pure functions so they can be unit tested +// without spawning git; `--` always ends option parsing so paths beginning with +// '-' are treated as paths, never flags. + +fn move_worktree_args(from: &Path, path: &Path, new_path: &Path) -> Vec { + vec![ + "-C".to_string(), + from.display().to_string(), + "worktree".to_string(), + "move".to_string(), + "--".to_string(), + path.display().to_string(), + new_path.display().to_string(), + ] +} + +fn lock_args(from: &Path, path: &Path, reason: Option<&str>) -> Vec { + let mut args = vec![ + "-C".to_string(), + from.display().to_string(), + "worktree".to_string(), + "lock".to_string(), + ]; + if let Some(reason) = reason { + args.push("--reason".to_string()); + args.push(reason.to_string()); + } + args.push("--".to_string()); + args.push(path.display().to_string()); + args +} + +fn unlock_args(from: &Path, path: &Path) -> Vec { + vec![ + "-C".to_string(), + from.display().to_string(), + "worktree".to_string(), + "unlock".to_string(), + "--".to_string(), + path.display().to_string(), + ] +} + +fn repair_args(from: &Path, paths: &[PathBuf]) -> Vec { + let mut args = vec![ + "-C".to_string(), + from.display().to_string(), + "worktree".to_string(), + "repair".to_string(), + ]; + if !paths.is_empty() { + args.push("--".to_string()); + args.extend(paths.iter().map(|p| p.display().to_string())); + } + args +} + /// Start the local status lookup on a background thread so the workspace picker /// can render before expensive `git status` scans finish. pub fn spawn_summary_lookup(worktrees: &[Worktree]) -> SummaryLookup { @@ -834,4 +948,79 @@ mod tests { assert!(w.match_score(&matcher, "fix-null").is_some()); assert!(w.match_score(&matcher, "fix/null").is_some()); } + + #[test] + fn test_move_worktree_args() { + let args = move_worktree_args( + Path::new("/repo"), + Path::new("/ws/feature"), + Path::new("/ws/moved"), + ); + assert_eq!( + args, + vec![ + "-C", + "/repo", + "worktree", + "move", + "--", + "/ws/feature", + "/ws/moved" + ] + ); + } + + #[test] + fn test_lock_args_with_and_without_reason() { + let with_reason = lock_args( + Path::new("/repo"), + Path::new("/ws/feature"), + Some("long review"), + ); + assert_eq!( + with_reason, + vec![ + "-C", + "/repo", + "worktree", + "lock", + "--reason", + "long review", + "--", + "/ws/feature" + ] + ); + + let without_reason = lock_args(Path::new("/repo"), Path::new("/ws/feature"), None); + assert_eq!( + without_reason, + vec!["-C", "/repo", "worktree", "lock", "--", "/ws/feature"] + ); + } + + #[test] + fn test_unlock_args() { + let args = unlock_args(Path::new("/repo"), Path::new("/ws/feature")); + assert_eq!( + args, + vec!["-C", "/repo", "worktree", "unlock", "--", "/ws/feature"] + ); + } + + #[test] + fn test_repair_args_forwards_paths() { + // No paths: git repairs every worktree, so no `--` / path args. + let all = repair_args(Path::new("/repo"), &[]); + assert_eq!(all, vec!["-C", "/repo", "worktree", "repair"]); + + // Targeted: paths are forwarded after `--`. + let targeted = repair_args( + Path::new("/repo"), + &[PathBuf::from("/ws/a"), PathBuf::from("/ws/b")], + ); + assert_eq!( + targeted, + vec!["-C", "/repo", "worktree", "repair", "--", "/ws/a", "/ws/b"] + ); + } }