Skip to content
Open
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
28 changes: 28 additions & 0 deletions RECURSIVE_WORKTREES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Recursive worktree inventory

```sh
meta git worktree list --recursive --json
meta git worktree status my-feature --recursive --json
```

The global `--recursive` / `-r` option now reaches these two commands.
Without it, discovery still stops at the first repository in each subtree.
With it, discovery continues beneath repositories, retaining fully qualified
relative aliases, sorted deterministically. Only actual `.git` files count as
members; configured repositories without a realized checkout are absent.

For a set containing root, `open-source`, and `open-source/atc`, the default
member aliases remain `[".", "open-source"]`; recursive inventory returns
`[".", "open-source", "open-source/atc"]`. JSON field names and types are unchanged.

Recursive discovery does not follow directory symlinks or enter hidden
directories. It stops with an error on traversal failures, malformed Git files,
failed Git status observations, or more than 2,000,000 scanned entries per set.
These errors produce a nonzero exit without a partial successful JSON payload.
`list` fails if any scanned set fails; `status NAME` confines the scan to one set.
Build trees are scanned and can add latency. Consumers should bound subprocess
runtime and treat failure as unknown, not an empty or clean inventory.

This option does not change create, add, remove, prune, exec, diff, or automatic
worktree context detection. It performs no Git mutation. The accompanying
`meta_cli` discovery API must land before this plugin change is built/released.
9 changes: 8 additions & 1 deletion src/commands/worktree/cli_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,17 @@ pub struct DestroyArgs {
}

#[derive(Args)]
pub struct ListArgs {}
pub struct ListArgs {
/// Include realized repositories nested beneath other worktree repositories
#[arg(long, short = 'r')]
pub recursive: bool,
}

#[derive(Args)]
pub struct StatusArgs {
/// Include realized repositories nested beneath other worktree repositories
#[arg(long, short = 'r')]
pub recursive: bool,
/// Worktree name
pub name: String,
}
Expand Down
52 changes: 36 additions & 16 deletions src/commands/worktree/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ use meta_git_lib::worktree::types::*;

use super::cli_types::ListArgs;

pub(crate) fn handle_list(_args: ListArgs, _verbose: bool, json: bool) -> Result<()> {
/// Render worktree sets, failing recursive inventory atomically on observation errors.
pub(crate) fn handle_list(args: ListArgs, _verbose: bool, json: bool) -> Result<()> {
let meta_dir = find_meta_dir();
let worktree_root = resolve_worktree_root(meta_dir.as_deref())?;

Expand All @@ -32,36 +33,52 @@ pub(crate) fn handle_list(_args: ListArgs, _verbose: bool, json: bool) -> Result
let now = Utc::now().timestamp();

// Collect directory entries, then process in parallel
let dir_entries: Vec<_> = std::fs::read_dir(&worktree_root)?
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
.collect();
let mut dir_entries = Vec::new();
for entry in std::fs::read_dir(&worktree_root)? {
if args.recursive {
let entry = entry?;
if entry.file_type()?.is_dir() {
dir_entries.push(entry);
}
} else if let Ok(entry) = entry {
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
dir_entries.push(entry);
}
}
}

let mut entries: Vec<ListEntry> = dir_entries
.par_iter()
.filter_map(|entry| {
.map(|entry| -> Result<Option<ListEntry>> {
let name = entry.file_name().to_string_lossy().to_string();
let wt_dir = entry.path();

let repos = discover_worktree_repos(&wt_dir).unwrap_or_default();
let repos = if args.recursive {
meta_cli::worktree::discover_worktree_repos_recursive(&wt_dir)?
} else {
discover_worktree_repos(&wt_dir).unwrap_or_default()
};
if repos.is_empty() {
return None; // Not a valid worktree set
return Ok(None); // Not a valid worktree set
}

let has_meta_root = repos.iter().any(|r| r.alias == ".");
let repo_entries: Vec<ListRepoEntry> = repos
.par_iter()
.map(|r| {
let dirty = git_status_summary(&r.path)
.map(|s| s.dirty)
.unwrap_or(false);
ListRepoEntry {
let status = git_status_summary(&r.path);
let dirty = if args.recursive {
status?.dirty
} else {
status.map(|s| s.dirty).unwrap_or(false)
};
Ok(ListRepoEntry {
alias: r.alias.clone(),
branch: r.branch.clone(),
dirty,
}
})
})
.collect();
.collect::<Result<Vec<_>>>()?;

// Merge store metadata if available
let wt_key = wt_dir.to_string_lossy().to_string();
Expand All @@ -76,16 +93,19 @@ pub(crate) fn handle_list(_args: ListArgs, _verbose: bool, json: bool) -> Result
(None, None, None)
};

Some(ListEntry {
Ok(Some(ListEntry {
name,
root: wt_dir.display().to_string(),
has_meta_root,
repos: repo_entries,
ephemeral,
ttl_remaining_seconds: ttl_remaining,
custom,
})
}))
})
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect();

// Sort by name for deterministic output
Expand Down
17 changes: 15 additions & 2 deletions src/commands/worktree/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,14 @@ pub fn execute_worktree_command(

// Inject --recursive if the protocol option was set but the flag wasn't in the
// command args (the host strips global flags like --recursive before forwarding).
// Only inject for `create` — it's the only subcommand that declares this flag.
// Inject only for subcommands that explicitly support recursive behavior.
let is_create = matches!(clap_args.first().map(String::as_str), Some("create"));
if recursive && is_create && !clap_args.iter().any(|a| a == "--recursive" || a == "-r") {
let supports_recursive = matches!(
clap_args.first().map(String::as_str),
Some("create" | "list" | "status")
);
if recursive && supports_recursive && !clap_args.iter().any(|a| a == "--recursive" || a == "-r")
{
clap_args.push("--recursive".to_string());
}
if dry_run && is_create && !clap_args.iter().any(|a| a == "--dry-run") {
Expand Down Expand Up @@ -101,6 +106,7 @@ pub fn execute_worktree_command(
}
}

/// Dispatch parsed worktree commands while retaining command-specific policies.
fn handle_worktree_command(
command: WorktreeCommands,
verbose: bool,
Expand Down Expand Up @@ -168,6 +174,7 @@ pub fn eprint_worktree_help() {
write_worktree_help(&mut std::io::stderr());
}

/// Write top-level help including recursive inventory usage.
fn write_worktree_help(w: &mut dyn std::io::Write) {
let _ = writeln!(w, "Manage git worktrees across repos");
let _ = writeln!(w);
Expand Down Expand Up @@ -206,6 +213,12 @@ fn write_worktree_help(w: &mut dyn std::io::Write) {
);
let _ = writeln!(w, " --meta <KEY=VALUE> Store custom metadata");
let _ = writeln!(w);
let _ = writeln!(w, "LIST / STATUS OPTIONS:");
let _ = writeln!(
w,
" -r, --recursive Include repositories nested beneath worktree repos"
);
let _ = writeln!(w);
let _ = writeln!(w, "REMOVE OPTIONS:");
let _ = writeln!(
w,
Expand Down
36 changes: 26 additions & 10 deletions src/commands/worktree/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,43 @@ use colored::*;
use rayon::prelude::*;

use meta_git_lib::worktree::git_ops::*;
use meta_git_lib::worktree::helpers::discover_and_validate_worktree;
use meta_git_lib::worktree::helpers::{
discover_and_validate_worktree, resolve_existing_worktree, validate_worktree_name,
};
use meta_git_lib::worktree::types::*;

use super::cli_types::StatusArgs;

/// Render a named set, preserving legacy defaults unless recursive is requested.
pub(crate) fn handle_status(args: StatusArgs, _verbose: bool, json: bool) -> Result<()> {
let name = &args.name;

let repos = discover_and_validate_worktree(name)?;
let repos = if args.recursive {
validate_worktree_name(name)?;
let ctx = resolve_existing_worktree(name)?;
let repos = meta_cli::worktree::discover_worktree_repos_recursive(&ctx.wt_dir)?;
anyhow::ensure!(!repos.is_empty(), "No repos found in worktree '{name}'");
repos
} else {
discover_and_validate_worktree(name)?
};

let statuses: Vec<StatusRepoEntry> = repos
.par_iter()
.map(|r| {
let summary = git_status_summary(&r.path).unwrap_or(GitStatusSummary {
dirty: false,
modified_files: vec![],
untracked_count: 0,
});
let observed = git_status_summary(&r.path);
let summary = if args.recursive {
observed?
} else {
observed.unwrap_or(GitStatusSummary {
dirty: false,
modified_files: vec![],
untracked_count: 0,
})
};
let (ahead, behind) = git_ahead_behind(&r.path).unwrap_or((0, 0));

StatusRepoEntry {
Ok(StatusRepoEntry {
alias: r.alias.clone(),
path: r.path.display().to_string(),
branch: r.branch.clone(),
Expand All @@ -33,9 +49,9 @@ pub(crate) fn handle_status(args: StatusArgs, _verbose: bool, json: bool) -> Res
ahead,
behind,
modified_files: summary.modified_files,
}
})
})
.collect();
.collect::<Result<Vec<_>>>()?;

if json {
let output = StatusOutput {
Expand Down
Loading
Loading