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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,15 @@ 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 sync # Copy configured setup files from main into the
# current workspace (manual copy tool)
gx workspace sync <target> # Copy into <target> (workspace name, branch, fuzzy
# query, or absolute path) instead of the current one
gx workspace sync <target> .env config/local.toml # Copy explicit paths
gx workspace sync <target> --from staging .env.local # Copy from another source
gx workspace sync <target> --dry-run # Print what would be copied

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)
Expand All @@ -272,6 +281,8 @@ gx workspace unlock <query> # Clear a workspace lock
gx workspace repair [query] # Repair worktree admin files after a move (all if omitted)
```

**`setup` vs `sync`:** `gx workspace setup` applies the configured policy (copy files plus the setup script) for the current workspace. `gx workspace sync` is the manual copy tool: it copies arbitrary paths (defaulting to the configured `copy_files`) from a source workspace (defaulting to the main worktree) into a target workspace (defaulting to the current one). Directories are copied recursively, parent directories are created as needed, and missing source paths are reported without aborting the rest of the sync.

**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.

**Changing directories:** a child process can't change your shell's directory, so `cd`-on-switch is handled by the shell wrapper emitted by `gx setup`. With `eval "$(gx setup)"` in your shell config, `gx workspace go`, `gx workspace new`, and the TUI will land you directly in the workspace. Without it, the workspace path is printed so you can `cd "$(gx workspace go <query>)"` yourself.
Expand Down
29 changes: 28 additions & 1 deletion src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ pub enum WorkspaceCommands {

/// Update a workspace: fetch origin and rebase its branch onto
/// origin's default branch (e.g. origin/main)
#[command(alias = "up", alias = "sync")]
#[command(alias = "up")]
Update {
/// Workspace to update (defaults to the current one)
query: Option<String>,
Expand All @@ -255,6 +255,27 @@ pub enum WorkspaceCommands {
/// Copy setup files (e.g. .env) from the main worktree into this workspace
Setup,

/// Copy files/directories between two workspaces (manual copy tool).
/// Defaults: target = current workspace, source = main worktree,
/// paths = configured setup copy files.
Sync {
/// Target workspace: workspace name, branch, fuzzy query, or absolute
/// path. Defaults to the current workspace.
target: Option<String>,

/// Paths to copy (repo-relative). Defaults to configured copy_files.
paths: Vec<String>,

/// Source workspace (same resolution as target). Defaults to the main
/// worktree.
#[arg(long)]
from: Option<String>,

/// Print what would be copied without writing any files.
#[arg(long)]
dry_run: bool,
},

/// Print the main worktree root (for use in scripts, e.g. cd "$(gx workspace root)")
Root,

Expand Down Expand Up @@ -367,6 +388,12 @@ impl Commands {
delete_branch,
}) => commands::workspace::run_remove(query, force, delete_branch),
Some(WorkspaceCommands::Setup) => commands::workspace::run_setup(),
Some(WorkspaceCommands::Sync {
target,
paths,
from,
dry_run,
}) => commands::workspace::run_sync(target, from, paths, dry_run),
Some(WorkspaceCommands::Root) => commands::workspace::run_root(),
Some(WorkspaceCommands::Move {
workspace,
Expand Down
213 changes: 213 additions & 0 deletions src/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ pub enum WorkspaceError {
)]
AlreadyExists(String, PathBuf),

#[error("Cannot sync a workspace to itself")]
#[diagnostic(
code(gx::workspace::sync_same),
help("Pass a different --from source or target")
)]
SameSourceAndTarget,

#[error("Cannot remove the main worktree")]
#[diagnostic(
code(gx::workspace::remove_main),
Expand Down Expand Up @@ -848,6 +855,92 @@ pub fn run_setup() -> Result<()> {
setup_worktrees(&[current], &worktrees)
}

/// Manually copy paths from one workspace to another. Defaults: target =
/// current workspace, source = main worktree, paths = configured copy files.
/// All output goes to stderr; nothing is printed to stdout (this command does
/// not participate in shell navigation).
pub fn run_sync(
target: Option<String>,
from: Option<String>,
paths: Vec<String>,
dry_run: bool,
) -> Result<()> {
let worktrees = git::worktree::list().map_err(WorkspaceError::GitError)?;
let main_root = main_worktree_root(&worktrees)?;

// Resolve target: explicit query, else the current workspace.
let target_root = match &target {
Some(query) => resolve_worktree_root(query, &worktrees)?,
None => git::worktree::current_worktree_root().map_err(WorkspaceError::GitError)?,
};

// Resolve source: explicit query, else the main worktree.
let source_root = match &from {
Some(query) => resolve_worktree_root(query, &worktrees)?,
None => main_root.clone(),
};

if paths_equal(&source_root, &target_root) {
return Err(WorkspaceError::SameSourceAndTarget.into());
}

// Determine paths: explicit list, else the configured default copy files
// (global config + the repo profile's copy files, deduped) — the same set
// workspace creation/setup uses.
let patterns = if !paths.is_empty() {
paths
} else {
let cfg = config::load()?;
let mut patterns = cfg.workspace.copy_files.clone();
patterns.extend(
repo_setup::profile_for_repo(&main_root)?
.config
.copy_files
.iter()
.cloned(),
);
let mut seen = HashSet::new();
patterns.retain(|p| seen.insert(p.clone()));
patterns
};

let outcome = repo_setup::sync_paths(&source_root, &target_root, &patterns, dry_run)?;

let src_name = worktree_display_name(&source_root, &worktrees);
let dst_name = worktree_display_name(&target_root, &worktrees);
let suffix = if dry_run { " (dry run)" } else { "" };
eprintln!(
"Syncing {} path(s) from '{}' to '{}'{}",
patterns.len(),
src_name,
dst_name,
suffix
);

for path in &outcome.copied {
if dry_run {
eprintln!(" would copy {}", path);
} else {
eprintln!(" copied {}", path);
}
}

if outcome.copied.is_empty() {
eprintln!(" nothing to copy");
}

// Missing sources are reported but never abort the sync.
if !outcome.missing.is_empty() {
eprintln!(
"warning: {} path(s) not found in source: {}",
outcome.missing.len(),
outcome.missing.join(", ")
);
}

Ok(())
}

/// 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.
Expand Down Expand Up @@ -895,6 +988,21 @@ pub fn run_move(workspace: String, new_path: String) -> Result<()> {
Ok(())
}

/// Best-effort human-readable name for a worktree root, used only in messages.
/// Falls back to the final path component when the root is not a registered
/// worktree (e.g. a bare absolute path).
fn worktree_display_name(root: &Path, worktrees: &[Worktree]) -> String {
worktrees
.iter()
.find(|w| paths_equal(&w.path, root))
.map(|w| w.name.clone())
.unwrap_or_else(|| {
root.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| root.display().to_string())
})
}

/// 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<()> {
Expand Down Expand Up @@ -1514,6 +1622,30 @@ fn paths_equal(a: &Path, b: &Path) -> bool {
}
}

/// Resolve a sync source/target query to a worktree root. Accepts an absolute
/// path to any worktree root, or to any existing directory; otherwise
/// fuzzy-matches workspace name/branch using the same matcher as
/// `gx workspace go`.
fn resolve_worktree_root(query: &str, worktrees: &[Worktree]) -> Result<PathBuf> {
let p = Path::new(query);
if p.is_absolute() {
// An absolute path that equals a known worktree root resolves to it.
if let Some(w) = worktrees.iter().find(|w| paths_equal(&w.path, p)) {
return Ok(w.path.clone());
}
// Otherwise accept the path directly if it exists as a directory; the
// spec lists absolute paths as a valid source/target form.
if p.is_dir() {
return Ok(p.to_path_buf());
}
return Err(WorkspaceError::NoMatch(query.to_string()).into());
}

fuzzy_match_worktree(query, worktrees)
.map(|w| w.path)
.ok_or_else(|| WorkspaceError::NoMatch(query.to_string()).into())
}

fn fuzzy_match_worktree(query: &str, worktrees: &[Worktree]) -> Option<Worktree> {
let matcher = SkimMatcherV2::default();

Expand Down Expand Up @@ -1684,6 +1816,87 @@ mod tests {
assert!(fuzzy_match_worktree("nonexistent-xyz", &worktrees).is_none());
}

#[test]
fn test_resolve_worktree_root_fuzzy_matches_name_or_branch() {
let worktrees = vec![
worktree("repo", Some("main")),
worktree("feat-expose-rationale", Some("feat/expose-rationale")),
];

// Branch-form query resolves to the workspace root.
let root = resolve_worktree_root("feat/expose-rationale", &worktrees).unwrap();
assert_eq!(root, PathBuf::from("/ws/feat-expose-rationale"));

// Name-form query resolves the same.
let root = resolve_worktree_root("feat-expose-rationale", &worktrees).unwrap();
assert_eq!(root, PathBuf::from("/ws/feat-expose-rationale"));
}

#[test]
fn test_resolve_worktree_root_rejects_unknown_query() {
let worktrees = vec![worktree("repo", Some("main"))];
let err = resolve_worktree_root("nonexistent-xyz", &worktrees).unwrap_err();
assert!(err.to_string().contains("nonexistent-xyz"));
}

#[test]
fn test_resolve_worktree_root_accepts_absolute_worktree_path() {
let tmp = std::env::temp_dir().join(format!(
"gx-resolve-abs-known-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(&tmp).unwrap();

let mut wt = worktree("feature", Some("feature"));
wt.path = tmp.clone();
let worktrees = vec![wt];

let root = resolve_worktree_root(tmp.to_str().unwrap(), &worktrees).unwrap();
assert!(paths_equal(&root, &tmp));

std::fs::remove_dir_all(&tmp).ok();
}

#[test]
fn test_resolve_worktree_root_accepts_absolute_dir_not_registered() {
let tmp = std::env::temp_dir().join(format!(
"gx-resolve-abs-unknown-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(&tmp).unwrap();

// No worktree registered at this path, but it is a real directory.
let worktrees = vec![worktree("repo", Some("main"))];
let root = resolve_worktree_root(tmp.to_str().unwrap(), &worktrees).unwrap();
assert_eq!(root, tmp);

std::fs::remove_dir_all(&tmp).ok();
}

#[test]
fn test_resolve_worktree_root_rejects_absolute_nonexistent_path() {
let worktrees = vec![worktree("repo", Some("main"))];
let missing = "/this/path/should/not/exist/gx-test-xyz";
let err = resolve_worktree_root(missing, &worktrees).unwrap_err();
assert!(err.to_string().contains(missing));
}

#[test]
fn test_self_sync_guard_via_paths_equal() {
// run_sync refuses when resolved source and target are the same root;
// this verifies the comparison primitive it relies on.
let tmp = std::env::temp_dir().join(format!(
"gx-self-sync-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(&tmp).unwrap();
assert!(paths_equal(&tmp, &tmp));
std::fs::remove_dir_all(&tmp).ok();
}

#[test]
fn test_remove_prompt_lists_multiple_workspaces() {
let feature = worktree("feature", Some("feature"));
Expand Down
Loading
Loading