Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,13 @@ gx workspace remove [query] # Remove a workspace (asks for confirmation).
gx workspace remove <name> --force # Remove even with uncommitted changes
gx workspace remove <name> --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 <query> <new-path> # Move a workspace to a new path (refuses the
# main worktree and existing destinations)
gx workspace lock <query> [--reason <reason>] # Lock a workspace so cleanup and
# 'git worktree prune' skip it
gx workspace unlock <query> # 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.
Expand Down
50 changes: 50 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
},

/// 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<String>,
},
}

impl Commands {
Expand Down Expand Up @@ -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(),
Expand Down
212 changes: 201 additions & 11 deletions src/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<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::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<String>) -> Result<()> {
let worktrees = git::worktree::list().map_err(WorkspaceError::GitError)?;
let main_root = main_worktree_root(&worktrees)?;

let paths: Vec<PathBuf> = 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)?;
Expand Down Expand Up @@ -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
Expand All @@ -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<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
Expand Down Expand Up @@ -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());
Expand Down
Loading
Loading