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: [1m[36mwt list[0m [36m[OPTIONS]
[1m[36m--full
Show CI, merge-base diffstat, and working tree conflict check
+ [1m[36m--global
+ List worktrees from all projects in global-worktree-dir
+
[1m[36m--progressive
Show fast info immediately, update with slow info
@@ -83,6 +86,14 @@ Output as JSON for scripting:
[2m$ wt list --format=json
+[32mGlobal listing
+
+List worktrees across all projects when [2mglobal-worktree-dir[0m is configured:
+
+ [2m$ wt list --global
+
+This scans the global worktree directory and groups results by project.
+
[32mColumns
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: [1m[36mwt list[0m [36m[OPTIONS]
[1m[36m--full
Show CI, merge-base diffstat, and working tree conflict check
+ [1m[36m--global
+ List worktrees from all projects in global-worktree-dir
+
[1m[36m--progressive
Show fast info immediately, update with slow info
@@ -86,6 +89,14 @@ Output as JSON for scripting:
[2m$ wt list --format=json
+[32mGlobal listing
+
+List worktrees across all projects when [2mglobal-worktree-dir[0m is configured:
+
+ [2m$ wt list --global
+
+This scans the global worktree directory and groups results by project.
+
[32mColumns
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: [1m[36mwt list[0m [36m[OPTIONS]
[1m[36m--branches[0m Include branches without worktrees
[1m[36m--remotes[0m Include remote branches
[1m[36m--full[0m Show CI, merge-base diffstat, and working tree conflict check
+ [1m[36m--global[0m List worktrees from all projects in global-worktree-dir
[1m[36m--progressive[0m Show fast info immediately, update with slow info
[1m[36m-h[0m, [1m[36m--help[0m 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, [2mwt switch[0m 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)
+[32mWorktree placement
+
+By default, worktrees are placed in sibling directories based on the [2mworktree-path[0m template. When [2mglobal-worktree-dir[0m is configured, new worktrees
+are placed there instead using [2m{project}.{branch}[0m naming:
+
+ [2m# ~/.config/worktrunk/config.toml
+ [2mglobal-worktree-dir = "~/worktrees"
+
+This creates worktrees like [2m~/worktrees/myrepo.feature-auth[0m.
+
[2mwt switch feature # Existing branch → creates worktree
[2mwt switch --create feature # New branch and worktree
[2mwt 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 -----
+[31m✗[39m [31mglobal-worktree-dir does not exist: /nonexistent/path/to/worktrees[39m
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 -----
+[2m○[22m 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 -----
+[2m[a][22m Multiline error without context: global-worktree-dir is not configured. Add it to your config:
+ [CONFIG_PATH]:
+ global-worktree-dir = "[PROJECT_ID]"
+[31m✗[39m [31mCommand failed[39m
+[107m [0m global-worktree-dir is not configured. Add it to your config:
+[107m [0m
+[107m [0m [user config path]/config.toml:
+[107m [0m global-worktree-dir = "[PROJECT_ID]"