diff --git a/docs/content/list.md b/docs/content/list.md index 1a21234c8..275034c06 100644 --- a/docs/content/list.md +++ b/docs/content/list.md @@ -77,6 +77,16 @@ Output as JSON for scripting: $ wt list --format=json ``` +## Global listing + +List worktrees across all projects when `global-worktree-dir` is configured: + +```bash +$ wt list --global +``` + +This scans the global worktree directory and groups results by project. + ## Columns | Column | Shows | @@ -289,6 +299,9 @@ Usage: wt list [OPTIONS] --full Show CI, merge-base diffstat, and working tree conflict check + --global + List worktrees from all projects in global-worktree-dir + --progressive Show fast info immediately, update with slow info diff --git a/docs/content/switch.md b/docs/content/switch.md index c40254b40..d3f5ea667 100644 --- a/docs/content/switch.md +++ b/docs/content/switch.md @@ -38,11 +38,22 @@ If the branch already has a worktree, `wt switch` changes directories to it. Oth When creating a worktree, worktrunk: -1. Creates worktree at configured path +1. Creates worktree at configured path (see below) 2. Switches to new directory 3. Runs [post-create hooks](@/hook.md#post-create) (blocking) 4. Spawns [post-start hooks](@/hook.md#post-start) (background) +## Worktree placement + +By default, worktrees are placed in sibling directories based on the `worktree-path` template. When `global-worktree-dir` is configured, new worktrees are placed there instead using `{project}.{branch}` naming: + +```toml +# ~/.config/worktrunk/config.toml +global-worktree-dir = "~/worktrees" +``` + +This creates worktrees like `~/worktrees/myrepo.feature-auth`. + ```bash wt switch feature # Existing branch → creates worktree wt switch --create feature # New branch and worktree diff --git a/src/cli.rs b/src/cli.rs index 2d894ae7e..ad9038bfe 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1922,6 +1922,16 @@ Output as JSON for scripting: $ wt list --format=json ``` +## Global listing + +List worktrees across all projects when `global-worktree-dir` is configured: + +```console +$ wt list --global +``` + +This scans the global worktree directory and groups results by project. + ## Columns | Column | Shows | @@ -2123,17 +2133,21 @@ Missing a field that would be generally useful? Open an issue at https://github. format: OutputFormat, /// Include branches without worktrees - #[arg(long)] + #[arg(long, conflicts_with = "global")] branches: bool, /// Include remote branches - #[arg(long)] + #[arg(long, conflicts_with = "global")] remotes: bool, /// Show CI, merge-base diffstat, and working tree conflict check #[arg(long)] full: bool, + /// List worktrees from all projects in global-worktree-dir + #[arg(long)] + global: bool, + /// Show fast info immediately, update with slow info /// /// Displays local data (branches, paths, status) first, then updates @@ -2172,11 +2186,22 @@ If the branch already has a worktree, `wt switch` changes directories to it. Oth When creating a worktree, worktrunk: -1. Creates worktree at configured path +1. Creates worktree at configured path (see below) 2. Switches to new directory 3. Runs [post-create hooks](@/hook.md#post-create) (blocking) 4. Spawns [post-start hooks](@/hook.md#post-start) (background) +## Worktree placement + +By default, worktrees are placed in sibling directories based on the `worktree-path` template. When `global-worktree-dir` is configured, new worktrees are placed there instead using `{project}.{branch}` naming: + +```toml +# ~/.config/worktrunk/config.toml +global-worktree-dir = "~/worktrees" +``` + +This creates worktrees like `~/worktrees/myrepo.feature-auth`. + ```console wt switch feature # Existing branch → creates worktree wt switch --create feature # New branch and worktree diff --git a/src/commands/list/global.rs b/src/commands/list/global.rs new file mode 100644 index 000000000..73e500a71 --- /dev/null +++ b/src/commands/list/global.rs @@ -0,0 +1,396 @@ +//! Global worktree discovery and collection for `wt list --global`. +//! +//! Scans a configured global worktree directory to discover worktrees from +//! multiple projects, groups them by parent repository, and collects metadata. + +use anyhow::{Context, bail}; +use rayon::prelude::*; +use std::collections::HashMap; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use crate::commands::list::model::{ItemKind, ListItem}; +use worktrunk::config::WorktrunkConfig; +use worktrunk::git::Repository; + +/// Worktrees grouped by their parent project. +pub struct ProjectWorktrees { + /// Display name derived from project_identifier() + pub name: String, + /// Path to the parent repository's main worktree + pub path: PathBuf, + /// Worktrees belonging to this project + pub items: Vec, +} + +/// All global worktree data grouped by project. +pub struct GlobalListData { + pub projects: Vec, +} + +impl GlobalListData { + /// Total number of worktrees across all projects + pub fn worktree_count(&self) -> usize { + self.projects.iter().map(|p| p.items.len()).sum() + } + + /// Number of projects + pub fn project_count(&self) -> usize { + self.projects.len() + } +} + +/// Discover worktrees in the global directory by scanning for .git files. +/// +/// Git creates a `.git` file (not directory) for linked worktrees containing +/// the path back to the parent repository's gitdir. +/// +/// Returns tuples of (worktree_path, parent_gitdir). +fn discover_worktrees(global_dir: &Path) -> anyhow::Result> { + let mut worktrees = Vec::new(); + + let entries = std::fs::read_dir(global_dir) + .with_context(|| format!("Failed to read global worktree directory: {}", global_dir.display()))?; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let git_path = path.join(".git"); + + if git_path.is_file() { + if let Ok(Some(parent_gitdir)) = parse_git_file(&git_path) { + worktrees.push((path, parent_gitdir)); + } + } else if git_path.is_dir() { + // Main worktree (not linked) - include it grouped by itself + worktrees.push((path.clone(), git_path)); + } + } + + Ok(worktrees) +} + +/// Parse a .git file to extract the parent repository's gitdir path. +/// +/// Format: "gitdir: /path/to/.git/worktrees/branch-name" +/// Returns the path to the parent .git directory (navigates up from worktrees/X). +fn parse_git_file(git_file: &Path) -> anyhow::Result> { + let content = std::fs::read_to_string(git_file) + .with_context(|| format!("Failed to read .git file: {}", git_file.display()))?; + + if let Some(gitdir) = content.strip_prefix("gitdir: ") { + let gitdir = PathBuf::from(gitdir.trim()); + + // Navigate up from .git/worktrees/X to .git + // The gitdir points to .git/worktrees// + if let Some(parent) = gitdir.parent().and_then(|p| p.parent()) { + return Ok(Some(parent.to_path_buf())); + } + } + + Ok(None) +} + +/// Group discovered worktrees by their parent repository's gitdir. +fn group_by_parent(worktrees: Vec<(PathBuf, PathBuf)>) -> HashMap> { + let mut groups: HashMap> = HashMap::new(); + + for (path, parent_gitdir) in worktrees { + groups.entry(parent_gitdir).or_default().push(path); + } + + groups +} + +/// Collect global worktree data from all projects in the global directory. +pub fn collect_global( + global_dir: &Path, + _config: &WorktrunkConfig, +) -> anyhow::Result { + // 1. Discover worktrees and group by parent gitdir + let discovered = discover_worktrees(global_dir)?; + + if discovered.is_empty() { + return Ok(GlobalListData { projects: vec![] }); + } + + let by_parent = group_by_parent(discovered); + + // 2. For each parent repo, create Repository and collect worktree data + let projects: Vec<_> = by_parent + .into_par_iter() + .filter_map(|(gitdir, worktree_paths)| { + match collect_project_worktrees(&gitdir, &worktree_paths) { + Ok(project) => Some(project), + Err(e) => { + log::warn!("Skipping project at {}: {}", gitdir.display(), e); + None + } + } + }) + .collect(); + + // Sort projects by name for consistent output + let mut projects = projects; + projects.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + Ok(GlobalListData { projects }) +} + +/// Collect worktree data for a single project. +fn collect_project_worktrees( + gitdir: &Path, + worktree_paths: &[PathBuf], +) -> anyhow::Result { + use crate::commands::list::collect::build_worktree_item; + + // Create repository from gitdir + // For normal repos, gitdir ends with .git and we use the parent + // For bare repos, the gitdir IS the repo path + let worktree_path = if gitdir.file_name() == Some(OsStr::new(".git")) { + gitdir.parent().unwrap_or(gitdir) + } else { + gitdir + }; + let repo = Repository::at(worktree_path); + + // Get project identifier for display name + let project_id = repo.project_identifier()?; + // Use just the repo name for display + let name = project_id + .rsplit('/') + .next() + .unwrap_or(&project_id) + .to_string(); + + // Get all worktrees for this repository + let all_worktrees = repo.list_worktrees()?; + + // Filter to only worktrees in global_dir paths + let worktree_path_set: std::collections::HashSet<_> = worktree_paths + .iter() + .filter_map(|p| dunce::canonicalize(p).ok()) + .collect(); + + let default_branch = repo.default_branch().unwrap_or_default(); + + // Build items for each worktree in the global directory + let items: Vec<_> = all_worktrees + .into_iter() + .filter(|wt| { + dunce::canonicalize(&wt.path) + .map(|p| worktree_path_set.contains(&p)) + .unwrap_or(false) + }) + .map(|wt| { + let is_main = wt.branch.as_ref() == Some(&default_branch); + let is_current = false; // Not current in global listing context + let is_previous = false; + build_worktree_item(&wt, is_main, is_current, is_previous) + }) + .collect(); + + // Get the main worktree path for the project + let path = repo.worktree_base().unwrap_or_else(|_| gitdir.to_path_buf()); + + Ok(ProjectWorktrees { name, path, items }) +} + +/// Handle the `wt list --global` command. +pub fn handle_list_global( + format: crate::OutputFormat, + show_full: bool, + config: &WorktrunkConfig, +) -> anyhow::Result<()> { + // Check that global_worktree_dir is configured + let global_dir = config.global_worktree_dir_path().ok_or_else(|| { + anyhow::anyhow!( + "global-worktree-dir is not configured. Add it to your config:\n\n \ + [user config path]/config.toml:\n \ + global-worktree-dir = \"~/worktrees\"" + ) + })?; + + // Check that global_worktree_dir exists + if !global_dir.exists() { + bail!( + "global-worktree-dir does not exist: {}", + global_dir.display() + ); + } + + let _ = show_full; // TODO: use for CI status, etc. + + let data = collect_global(&global_dir, config)?; + + match format { + crate::OutputFormat::Table => { + render_global_table(&data)?; + } + crate::OutputFormat::Json => { + render_global_json(&data)?; + } + } + + Ok(()) +} + +/// Render the global worktree listing as a table. +fn render_global_table(data: &GlobalListData) -> anyhow::Result<()> { + use crate::commands::list::collect::TaskKind; + use crate::commands::list::layout::calculate_layout_with_width; + use color_print::cformat; + use worktrunk::styling::{get_terminal_width, info_message}; + + if data.projects.is_empty() { + crate::output::print(info_message("No worktrees found in global directory"))?; + return Ok(()); + } + + // Skip expensive tasks for global listing (no CI, no branch diff) + let skip_tasks: std::collections::HashSet = [ + TaskKind::BranchDiff, + TaskKind::CiStatus, + TaskKind::WorkingTreeConflicts, + ] + .into_iter() + .collect(); + + // Use the first project's path as main_worktree_path for relative path display + // (paths will show as absolute since they're from different repos) + let main_worktree_path = &data.projects[0].path; + + // Collect all items for layout calculation + let all_items: Vec = data + .projects + .iter() + .flat_map(|p| p.items.iter().cloned()) + .collect(); + + // Calculate layout based on all items + let layout = calculate_layout_with_width( + &all_items, + &skip_tasks, + get_terminal_width(), + main_worktree_path, + None, // no URL template for global listing + ); + + // Print header once at the top + crate::output::table(layout.format_header_line())?; + + for project in &data.projects { + // Project header in cyan + crate::output::table(cformat!("{}", project.name.to_uppercase()))?; + + for item in &project.items { + crate::output::table(layout.format_list_item_line(item, None))?; + } + } + + // Summary line + crate::output::table("")?; + let worktree_count = data.worktree_count(); + let project_count = data.project_count(); + let summary = format!( + "{} {} across {} {}", + worktree_count, + if worktree_count == 1 { "worktree" } else { "worktrees" }, + project_count, + if project_count == 1 { "project" } else { "projects" } + ); + crate::output::print(info_message(summary))?; + + Ok(()) +} + +/// Render the global worktree listing as JSON. +fn render_global_json(data: &GlobalListData) -> anyhow::Result<()> { + use serde::Serialize; + + #[derive(Serialize)] + struct JsonOutput { + worktrees: Vec, + summary: JsonSummary, + } + + #[derive(Serialize)] + struct JsonWorktree { + branch: Option, + project: String, + project_path: PathBuf, + path: PathBuf, + head_sha: String, + } + + #[derive(Serialize)] + struct JsonSummary { + worktree_count: usize, + project_count: usize, + } + + let mut worktrees = Vec::new(); + for project in &data.projects { + for item in &project.items { + let path = match &item.kind { + ItemKind::Worktree(wt_data) => wt_data.path.clone(), + ItemKind::Branch => continue, + }; + worktrees.push(JsonWorktree { + branch: item.branch.clone(), + project: project.name.clone(), + project_path: project.path.clone(), + path, + head_sha: item.head.clone(), + }); + } + } + + let output = JsonOutput { + worktrees, + summary: JsonSummary { + worktree_count: data.worktree_count(), + project_count: data.project_count(), + }, + }; + + let json = serde_json::to_string_pretty(&output) + .context("Failed to serialize global list to JSON")?; + crate::output::data(json)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_parse_git_file() { + let temp = TempDir::new().unwrap(); + let git_file = temp.path().join(".git"); + + // Write a valid .git file + fs::write(&git_file, "gitdir: /path/to/repo/.git/worktrees/my-branch\n").unwrap(); + + let result = parse_git_file(&git_file).unwrap(); + assert_eq!(result, Some(PathBuf::from("/path/to/repo/.git"))); + } + + #[test] + fn test_parse_git_file_invalid() { + let temp = TempDir::new().unwrap(); + let git_file = temp.path().join(".git"); + + // Write an invalid .git file + fs::write(&git_file, "not a valid git file").unwrap(); + + let result = parse_git_file(&git_file).unwrap(); + assert_eq!(result, None); + } +} diff --git a/src/commands/list/mod.rs b/src/commands/list/mod.rs index 669e8b270..1d0bf53b5 100644 --- a/src/commands/list/mod.rs +++ b/src/commands/list/mod.rs @@ -120,6 +120,7 @@ pub mod ci_status; pub(crate) mod collect; mod collect_progressive_impl; mod columns; +pub mod global; mod json_output; pub(crate) mod layout; pub mod model; diff --git a/src/commands/list/model.rs b/src/commands/list/model.rs index 13e12572c..90ccd8345 100644 --- a/src/commands/list/model.rs +++ b/src/commands/list/model.rs @@ -127,7 +127,7 @@ impl WorktreeData { /// WorktreeData is boxed to reduce the size of ItemKind enum (304 bytes → 24 bytes). /// This reduces stack pressure when passing ListItem by value and improves cache locality /// in `Vec` by keeping the discriminant and common fields together. -#[derive(serde::Serialize)] +#[derive(Clone, serde::Serialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum ItemKind { Worktree(Box), @@ -189,7 +189,7 @@ impl UpstreamStatus { } /// Unified item for displaying worktrees and branches in the same table -#[derive(serde::Serialize)] +#[derive(Clone, serde::Serialize)] pub struct ListItem { // Common fields (present for both worktrees and branches) #[serde(rename = "head_sha")] diff --git a/src/commands/worktree.rs b/src/commands/worktree.rs index ce2a00b43..3c89dcdbb 100644 --- a/src/commands/worktree.rs +++ b/src/commands/worktree.rs @@ -84,7 +84,7 @@ use dunce::canonicalize; use normalize_path::NormalizePath; use std::path::PathBuf; use worktrunk::HookType; -use worktrunk::config::WorktrunkConfig; +use worktrunk::config::{WorktrunkConfig, sanitize_branch_name}; use worktrunk::git::{GitError, Repository, ResolvedWorktree}; use worktrunk::styling::{ format_with_gutter, hint_message, info_message, progress_message, success_message, @@ -274,6 +274,14 @@ fn compute_worktree_path_with( return Ok(repo_root); } + // Check for global worktree directory first + if let Some(global_dir) = config.global_worktree_dir_path() { + let project = repo.project_identifier()?; + // Use just repo name for cleaner directory names (e.g., "worktrunk" not "github.com/user/worktrunk") + let project_name = project.rsplit('/').next().unwrap_or(&project); + return Ok(global_dir.join(format!("{}.{}", project_name, sanitize_branch_name(branch)))); + } + let repo_name = repo_root .file_name() .ok_or_else(|| anyhow::anyhow!("Repository path has no filename: {}", repo_root.display()))? @@ -607,6 +615,14 @@ pub fn handle_switch( } } + // Ensure parent directory exists (needed for global worktree directory) + if let Some(parent) = worktree_path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory {}", parent.display()))?; + } + } + // Create the worktree // Build git worktree add command let mut args = vec!["worktree", "add", worktree_path.to_str().unwrap()]; diff --git a/src/config/user.rs b/src/config/user.rs index 544f61306..98f22192f 100644 --- a/src/config/user.rs +++ b/src/config/user.rs @@ -129,6 +129,16 @@ pub struct WorktrunkConfig { #[serde(rename = "worktree-path", default = "default_worktree_path")] pub worktree_path: String, + /// Global directory where all worktrees are placed (when configured). + /// Supports `~` and environment variable expansion. + /// Example: `~/worktrees` + #[serde( + default, + rename = "global-worktree-dir", + skip_serializing_if = "Option::is_none" + )] + pub global_worktree_dir: Option, + #[serde(default, rename = "commit-generation")] pub commit_generation: CommitGenerationConfig, @@ -294,6 +304,7 @@ impl Default for WorktrunkConfig { fn default() -> Self { Self { worktree_path: default_worktree_path(), + global_worktree_dir: None, commit_generation: CommitGenerationConfig::default(), projects: std::collections::BTreeMap::new(), list: None, @@ -398,6 +409,24 @@ impl WorktrunkConfig { expand_template(&self.worktree_path, &vars, false) } + /// Get the expanded global worktree directory path, if configured. + /// + /// Expands `~` to the user's home directory. + /// + /// # Examples + /// + /// ``` + /// use worktrunk::config::WorktrunkConfig; + /// + /// let config = WorktrunkConfig::default(); + /// assert!(config.global_worktree_dir_path().is_none()); + /// ``` + pub fn global_worktree_dir_path(&self) -> Option { + self.global_worktree_dir + .as_ref() + .map(|path| PathBuf::from(shellexpand::tilde(path).as_ref())) + } + /// Check if a command is approved for the given project pub fn is_command_approved(&self, project: &str, command: &str) -> bool { self.projects @@ -861,6 +890,7 @@ rename-tab = "echo 'switched'" config.worktree_path, "../{{ main_worktree }}.{{ branch | sanitize }}" ); + assert!(config.global_worktree_dir.is_none()); assert!(config.projects.is_empty()); assert!(config.list.is_none()); assert!(config.commit.is_none()); diff --git a/src/main.rs b/src/main.rs index bfc45f562..8c92e498f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1173,6 +1173,7 @@ fn main() { branches, remotes, full, + global, progressive, no_progressive, } => match subcommand { @@ -1186,6 +1187,11 @@ fn main() { WorktrunkConfig::load() .context("Failed to load config") .and_then(|config| { + // Handle --global flag for cross-project listing + if global { + return commands::list::global::handle_list_global(format, full, &config); + } + // Get config values from global list config let (show_branches_config, show_remotes_config, show_full_config) = config .list diff --git a/tests/integration_tests/list.rs b/tests/integration_tests/list.rs index 4b53790fc..0bef49f65 100644 --- a/tests/integration_tests/list.rs +++ b/tests/integration_tests/list.rs @@ -2332,3 +2332,143 @@ fn test_list_handles_orphan_branch(repo: TestRepo) { snapshot_list_with_branches("orphan_branch_no_error", &repo); } + +// ============================================================================= +// --global flag tests +// ============================================================================= + +#[test] +fn test_list_global_not_configured() { + let repo = TestRepo::new(); + + let mut cmd = repo.wt_command(); + cmd.args(["list", "--global"]); + + let mut settings = setup_snapshot_settings(&repo); + settings.add_filter(r"(?m)^\s+.*config\.toml:$", " [CONFIG_PATH]:"); + run_snapshot(settings, "list_global_not_configured", cmd); +} + +#[test] +fn test_list_global_dir_not_exists() { + let repo = TestRepo::new(); + repo.write_test_config("global-worktree-dir = \"/nonexistent/path/to/worktrees\"\n"); + + let mut cmd = repo.wt_command(); + cmd.args(["list", "--global"]); + + run_snapshot( + setup_snapshot_settings(&repo), + "list_global_dir_not_exists", + cmd, + ); +} + +#[test] +fn test_list_global_empty_directory() { + let repo = TestRepo::new(); + + // Create an empty global worktree directory + let global_dir = repo.home_path().join("worktrees"); + std::fs::create_dir_all(&global_dir).unwrap(); + + repo.write_test_config(&format!( + "global-worktree-dir = \"{}\"\n", + global_dir.display() + )); + + let mut cmd = repo.wt_command(); + cmd.args(["list", "--global"]); + + run_snapshot( + setup_snapshot_settings(&repo), + "list_global_empty_directory", + cmd, + ); +} + +/// End-to-end test: create worktree in global dir, list it, switch to it +#[test] +fn test_global_worktree_create_and_switch() { + let repo = TestRepo::new(); + + // Create global worktree directory and configure it + let global_dir = repo.home_path().join("worktrees"); + std::fs::create_dir_all(&global_dir).unwrap(); + + repo.write_test_config(&format!( + "global-worktree-dir = \"{}\"\n", + global_dir.display() + )); + + // Create a worktree using switch --create + let output = repo + .wt_command() + .args(["switch", "--create", "test-feature"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "switch --create failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Verify worktree was created in global directory (not sibling) + // Project name is derived from repo name (TestRepo creates "repo") + let expected_path = global_dir.join("repo.test-feature"); + assert!( + expected_path.exists(), + "Worktree should be at {:?}, but it doesn't exist. Global dir contents: {:?}", + expected_path, + std::fs::read_dir(&global_dir) + .map(|entries| entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .collect::>()) + .unwrap_or_default() + ); + + // Verify wt list --global shows the worktree + let output = repo + .wt_command() + .args(["list", "--global", "--format=json"]) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("test-feature"), + "list --global should show test-feature branch" + ); + + // Switch back to main worktree + let output = repo + .wt_command() + .args(["switch", "main"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "switch main failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Switch to the global worktree (verifies switch finds it by branch name) + let output = repo + .wt_command() + .args(["switch", "test-feature"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "switch test-feature failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Verify the output mentions the global path + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("worktrees") || stderr.contains("test-feature"), + "switch output should reference the worktree" + ); +} diff --git a/tests/snapshots/integration__integration_tests__help__help_list_long.snap b/tests/snapshots/integration__integration_tests__help__help_list_long.snap index 55c1ac345..70efe6c87 100644 --- a/tests/snapshots/integration__integration_tests__help__help_list_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_list_long.snap @@ -41,6 +41,9 @@ Usage: wt list [OPTIONS] --full Show CI, merge-base diffstat, and working tree conflict check + --global + List worktrees from all projects in global-worktree-dir + --progressive Show fast info immediately, update with slow info @@ -83,6 +86,14 @@ Output as JSON for scripting: $ wt list --format=json +Global listing + +List worktrees across all projects when global-worktree-dir is configured: + + $ wt list --global + +This scans the global worktree directory and groups results by project. + Columns Column Shows diff --git a/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap b/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap index 98e890a6c..9c5bb9fb7 100644 --- a/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap +++ b/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap @@ -41,6 +41,9 @@ Usage: wt list [OPTIONS] --full Show CI, merge-base diffstat, and working tree conflict check + --global + List worktrees from all projects in global-worktree-dir + --progressive Show fast info immediately, update with slow info @@ -86,6 +89,14 @@ Output as JSON for scripting: $ wt list --format=json +Global listing + +List worktrees across all projects when global-worktree-dir is configured: + + $ wt list --global + +This scans the global worktree directory and groups results by project. + Columns Column Shows diff --git a/tests/snapshots/integration__integration_tests__help__help_list_short.snap b/tests/snapshots/integration__integration_tests__help__help_list_short.snap index c77afca3e..ed3a0194c 100644 --- a/tests/snapshots/integration__integration_tests__help__help_list_short.snap +++ b/tests/snapshots/integration__integration_tests__help__help_list_short.snap @@ -31,6 +31,7 @@ Usage: wt list [OPTIONS] --branches Include branches without worktrees --remotes Include remote branches --full Show CI, merge-base diffstat, and working tree conflict check + --global List worktrees from all projects in global-worktree-dir --progressive Show fast info immediately, update with slow info -h, --help Print help (see more with '--help') diff --git a/tests/snapshots/integration__integration_tests__help__help_page_switch.snap b/tests/snapshots/integration__integration_tests__help__help_page_switch.snap index ccc3df609..db7071a3b 100644 --- a/tests/snapshots/integration__integration_tests__help__help_page_switch.snap +++ b/tests/snapshots/integration__integration_tests__help__help_page_switch.snap @@ -48,11 +48,22 @@ If the branch already has a worktree, `wt switch` changes directories to it. Oth When creating a worktree, worktrunk: -1. Creates worktree at configured path +1. Creates worktree at configured path (see below) 2. Switches to new directory 3. Runs [post-create hooks](@/hook.md#post-create) (blocking) 4. Spawns [post-start hooks](@/hook.md#post-start) (background) +## Worktree placement + +By default, worktrees are placed in sibling directories based on the `worktree-path` template. When `global-worktree-dir` is configured, new worktrees are placed there instead using `{project}.{branch}` naming: + +```toml +# ~/.config/worktrunk/config.toml +global-worktree-dir = "~/worktrees" +``` + +This creates worktrees like `~/worktrees/myrepo.feature-auth`. + ```bash wt switch feature # Existing branch → creates worktree wt switch --create feature # New branch and worktree diff --git a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap index ca225d624..e8f34cd50 100644 --- a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap @@ -99,11 +99,21 @@ If the branch already has a worktree, wt switch changes directories to i When creating a worktree, worktrunk: -1. Creates worktree at configured path +1. Creates worktree at configured path (see below) 2. Switches to new directory 3. Runs post-create hooks (blocking) 4. Spawns post-start hooks (background) +Worktree placement + +By default, worktrees are placed in sibling directories based on the worktree-path template. When global-worktree-dir is configured, new worktrees +are placed there instead using {project}.{branch} naming: + + # ~/.config/worktrunk/config.toml + global-worktree-dir = "~/worktrees" + +This creates worktrees like ~/worktrees/myrepo.feature-auth. + wt switch feature # Existing branch → creates worktree wt switch --create feature # New branch and worktree wt switch --create fix --base release # New branch from release diff --git a/tests/snapshots/integration__integration_tests__list__list_global_dir_not_exists.snap b/tests/snapshots/integration__integration_tests__list__list_global_dir_not_exists.snap new file mode 100644 index 000000000..714503537 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__list__list_global_dir_not_exists.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/list.rs +assertion_line: 124 +info: + program: wt + args: + - list + - "--global" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "150" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_EDITOR: "" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PATH: "[PATH]" + RUST_LOG: warn + SOURCE_DATE_EPOCH: "1735776000" + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ global-worktree-dir does not exist: /nonexistent/path/to/worktrees diff --git a/tests/snapshots/integration__integration_tests__list__list_global_empty_directory.snap b/tests/snapshots/integration__integration_tests__list__list_global_empty_directory.snap new file mode 100644 index 000000000..c4203f298 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__list__list_global_empty_directory.snap @@ -0,0 +1,34 @@ +--- +source: tests/integration_tests/list.rs +assertion_line: 124 +info: + program: wt + args: + - list + - "--global" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "150" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_EDITOR: "" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PATH: "[PATH]" + RUST_LOG: warn + SOURCE_DATE_EPOCH: "1735776000" + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +○ No worktrees found in global directory diff --git a/tests/snapshots/integration__integration_tests__list__list_global_not_configured.snap b/tests/snapshots/integration__integration_tests__list__list_global_not_configured.snap new file mode 100644 index 000000000..5eb7eacb0 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__list__list_global_not_configured.snap @@ -0,0 +1,41 @@ +--- +source: tests/integration_tests/list.rs +assertion_line: 124 +info: + program: wt + args: + - list + - "--global" + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "150" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_EDITOR: "" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PATH: "[PATH]" + RUST_LOG: warn + SOURCE_DATE_EPOCH: "1735776000" + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +[a] Multiline error without context: global-worktree-dir is not configured. Add it to your config: + [CONFIG_PATH]: + global-worktree-dir = "[PROJECT_ID]" +✗ Command failed +  global-worktree-dir is not configured. Add it to your config: +  +  [user config path]/config.toml: +  global-worktree-dir = "[PROJECT_ID]"