From b60f15423a3c458f33d442b149ba20cbf36ca20a Mon Sep 17 00:00:00 2001 From: reckerp Date: Mon, 29 Jun 2026 21:46:46 +0200 Subject: [PATCH 1/4] feat(repo_setup): add sync_paths copy primitive with dry-run and missing-path reporting Add a copy helper that, unlike copy_setup_files, records patterns that matched no source path instead of silently skipping them, and supports a no-write dry run. Directories are copied recursively, parent directories are created as needed, and matches are deduped in pattern order. This is the primitive backing `gx workspace sync`. --- src/repo_setup.rs | 209 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/src/repo_setup.rs b/src/repo_setup.rs index 05b09cf..e60d725 100644 --- a/src/repo_setup.rs +++ b/src/repo_setup.rs @@ -179,6 +179,73 @@ pub fn copy_setup_files( Ok(copied) } +/// Outcome of a [`sync_paths`] run. +#[derive(Debug, Default)] +pub struct CopyOutcome { + /// Repo-relative paths actually copied (or, in dry-run, that would be + /// copied), deduped and in pattern order. + pub copied: Vec, + /// Requested patterns/paths that matched nothing in the source. + pub missing: Vec, +} + +/// Like [`copy_setup_files`], but records patterns that matched no source path +/// (instead of silently skipping) and supports a no-write dry run. +/// +/// Patterns are repository-relative globs (same semantics as +/// [`copy_setup_files`]); directories are copied recursively and parent +/// directories are created as needed. When `dry_run` is true, matches are +/// still resolved and recorded but no files are written. +pub fn sync_paths( + src_root: &Path, + dst_root: &Path, + patterns: &[String], + dry_run: bool, +) -> Result { + let mut outcome = CopyOutcome::default(); + let mut copied_set = HashSet::new(); + + for pattern in patterns { + let normalized = normalize_pattern(pattern)?; + if normalized.is_empty() { + continue; + } + + let matches = matched_paths(src_root, &normalized)?; + if matches.is_empty() { + // A literal path that does not exist, or a glob that matched + // nothing, is summarized as "missing" rather than silently dropped. + outcome.missing.push(pattern.clone()); + continue; + } + + for rel_path in matches { + if !copied_set.insert(rel_path.clone()) { + continue; + } + + if !dry_run { + let src = src_root.join(&rel_path); + let dst = dst_root.join(&rel_path); + + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent).into_diagnostic()?; + } + + if src.is_dir() { + copy_dir_recursive(&src, &dst)?; + } else { + std::fs::copy(&src, &dst).into_diagnostic()?; + } + } + + outcome.copied.push(rel_path); + } + } + + Ok(outcome) +} + pub fn open_in_editor(path: &Path) -> Result<()> { let editor = std::env::var("VISUAL") .ok() @@ -773,6 +840,148 @@ mod tests { std::fs::remove_dir_all(&tmp).ok(); } + #[test] + fn test_sync_paths_copies_files_and_directories() { + let tmp = std::env::temp_dir().join(format!( + "gx-sync-paths-copy-test-{}-{}", + std::process::id(), + line!() + )); + let src = tmp.join("src"); + let dst = tmp.join("dst"); + std::fs::create_dir_all(src.join("config")).unwrap(); + std::fs::create_dir_all(&dst).unwrap(); + std::fs::write(src.join(".env"), "SECRET=1").unwrap(); + std::fs::write(src.join("config/local.toml"), "x = 1").unwrap(); + + let patterns = vec![".env".to_string(), "config".to_string()]; + let outcome = sync_paths(&src, &dst, &patterns, false).unwrap(); + + assert_eq!( + outcome.copied, + vec![".env".to_string(), "config".to_string()] + ); + assert!(outcome.missing.is_empty()); + assert_eq!( + std::fs::read_to_string(dst.join(".env")).unwrap(), + "SECRET=1" + ); + // Directory copied recursively. + assert_eq!( + std::fs::read_to_string(dst.join("config/local.toml")).unwrap(), + "x = 1" + ); + + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn test_sync_paths_creates_parent_dirs() { + let tmp = std::env::temp_dir().join(format!( + "gx-sync-paths-parent-test-{}-{}", + std::process::id(), + line!() + )); + let src = tmp.join("src"); + let dst = tmp.join("dst"); + std::fs::create_dir_all(src.join("nested/deep")).unwrap(); + std::fs::create_dir_all(&dst).unwrap(); + std::fs::write(src.join("nested/deep/file.txt"), "data").unwrap(); + + let patterns = vec!["nested/deep/file.txt".to_string()]; + let outcome = sync_paths(&src, &dst, &patterns, false).unwrap(); + + assert_eq!(outcome.copied, vec!["nested/deep/file.txt".to_string()]); + assert_eq!( + std::fs::read_to_string(dst.join("nested/deep/file.txt")).unwrap(), + "data" + ); + + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn test_sync_paths_dry_run_writes_nothing() { + let tmp = std::env::temp_dir().join(format!( + "gx-sync-paths-dryrun-test-{}-{}", + std::process::id(), + line!() + )); + let src = tmp.join("src"); + let dst = tmp.join("dst"); + std::fs::create_dir_all(src.join("config")).unwrap(); + std::fs::create_dir_all(&dst).unwrap(); + std::fs::write(src.join(".env"), "SECRET=1").unwrap(); + std::fs::write(src.join("config/local.toml"), "x = 1").unwrap(); + + let patterns = vec![".env".to_string(), "config".to_string()]; + let outcome = sync_paths(&src, &dst, &patterns, true).unwrap(); + + // Reports what *would* be copied... + assert_eq!( + outcome.copied, + vec![".env".to_string(), "config".to_string()] + ); + // ...but writes nothing. + assert!(!dst.join(".env").exists()); + assert!(!dst.join("config").exists()); + + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn test_sync_paths_summarizes_missing() { + let tmp = std::env::temp_dir().join(format!( + "gx-sync-paths-missing-test-{}-{}", + std::process::id(), + line!() + )); + let src = tmp.join("src"); + let dst = tmp.join("dst"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::create_dir_all(&dst).unwrap(); + std::fs::write(src.join(".env"), "SECRET=1").unwrap(); + + let patterns = vec![ + ".env".to_string(), + "missing.txt".to_string(), + "also/missing.toml".to_string(), + ]; + let outcome = sync_paths(&src, &dst, &patterns, false).unwrap(); + + assert_eq!(outcome.copied, vec![".env".to_string()]); + assert_eq!( + outcome.missing, + vec!["missing.txt".to_string(), "also/missing.toml".to_string()] + ); + // Missing sources are non-fatal: the present file was still copied. + assert!(dst.join(".env").exists()); + + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn test_sync_paths_dedupes_overlapping_patterns() { + let tmp = std::env::temp_dir().join(format!( + "gx-sync-paths-dedupe-test-{}-{}", + std::process::id(), + line!() + )); + let src = tmp.join("src"); + let dst = tmp.join("dst"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::create_dir_all(&dst).unwrap(); + std::fs::write(src.join(".env"), "SECRET=1").unwrap(); + + // Both patterns resolve to ".env"; it should be listed once. + let patterns = vec![".env".to_string(), ".env*".to_string()]; + let outcome = sync_paths(&src, &dst, &patterns, false).unwrap(); + + assert_eq!(outcome.copied, vec![".env".to_string()]); + + std::fs::remove_dir_all(&tmp).ok(); + } + #[test] fn test_array_terminated_ignores_bracket_inside_quotes() { assert!(!array_terminated("[\"foo]bar\",")); From 701f18a362d083b2bdb87fa655d0fe04e0e0fbc5 Mon Sep 17 00:00:00 2001 From: reckerp Date: Mon, 29 Jun 2026 21:46:52 +0200 Subject: [PATCH 2/4] feat(args): add `workspace sync` subcommand Add the CLI surface for syncing files between workspaces: an optional target, optional repo-relative paths, a --from source override, and --dry-run. Drop the `sync` alias from `workspace update` so it no longer collides with the new subcommand. --- src/args.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/args.rs b/src/args.rs index f281385..4a0552f 100644 --- a/src/args.rs +++ b/src/args.rs @@ -203,7 +203,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, @@ -229,6 +229,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, + + /// Paths to copy (repo-relative). Defaults to configured copy_files. + paths: Vec, + + /// Source workspace (same resolution as target). Defaults to the main + /// worktree. + #[arg(long)] + from: Option, + + /// Print what would be copied without writing any files. + #[arg(long)] + dry_run: bool, + }, } impl Commands { @@ -287,6 +308,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), }, Commands::Pr { action } => match action { None => commands::pr::run_interactive(), From a9f478e33b20f252905b61b951e9d71b31259150 Mon Sep 17 00:00:00 2001 From: reckerp Date: Mon, 29 Jun 2026 21:46:58 +0200 Subject: [PATCH 3/4] feat(workspace): implement `gx workspace sync` handler Add run_sync plus a source/target resolver that accepts workspace names, branch names, fuzzy queries, or absolute paths. Target defaults to the current workspace, source to the main worktree, and paths to the configured copy_files (global config plus the repo profile, deduped). Syncing a workspace to itself is refused. All human-readable output goes to stderr since this command does not participate in shell navigation. --- src/commands/workspace.rs | 213 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/src/commands/workspace.rs b/src/commands/workspace.rs index 1a8c052..ca3f1ab 100644 --- a/src/commands/workspace.rs +++ b/src/commands/workspace.rs @@ -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), @@ -460,6 +467,107 @@ 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, + from: Option, + paths: Vec, + 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(()) +} + +/// 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()) + }) +} + /// Interactive workspace manager (default when no subcommand is given). pub fn run_interactive() -> Result<()> { let worktrees = git::worktree::list().map_err(WorkspaceError::GitError)?; @@ -1001,6 +1109,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 { + 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 { let matcher = SkimMatcherV2::default(); @@ -1131,6 +1263,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")); From 6c2f75fa67852d2090ab03dffb4c0c5a3bcdfc70 Mon Sep 17 00:00:00 2001 From: reckerp Date: Mon, 29 Jun 2026 21:47:04 +0200 Subject: [PATCH 4/4] docs(readme): document `gx workspace sync` and how it differs from setup --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index cd90593..f2aeed4 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,18 @@ 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 sync # Copy configured setup files from main into the + # current workspace (manual copy tool) +gx workspace sync # Copy into (workspace name, branch, fuzzy + # query, or absolute path) instead of the current one +gx workspace sync .env config/local.toml # Copy explicit paths +gx workspace sync --from staging .env.local # Copy from another source +gx workspace sync --dry-run # Print what would be copied ``` +**`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 )"` yourself.