Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
951cd09
fix(worktree): prune and remove only this workspace's sets
gold-silver-copper Oct 26, 2026
6afc21d
fix(worktree): report add's unrecorded worktree as failed, one --ttl …
gold-silver-copper Oct 26, 2026
a94a7df
fix(config): propagate config lookup errors in worktree and git commands
gold-silver-copper Oct 26, 2026
c43bb50
fix(worktree): escape unknown subcommands and parse errors; name unre…
gold-silver-copper Oct 27, 2026
136b544
fix(worktree): remove from inside a set finds the workspace's sets
gold-silver-copper Oct 27, 2026
cd388dd
fix(worktree): remove from inside a set uses the set's recorded origin
gold-silver-copper Oct 27, 2026
9840132
fix(worktree): prune ignores sets another workspace made; review round 4
gold-silver-copper Oct 27, 2026
3d920c0
fix(worktree): prune inside a set uses its origin; escape create's va…
gold-silver-copper Oct 27, 2026
862188b
fix(worktree): drop the source-project-missing prune reason; escape d…
gold-silver-copper Oct 27, 2026
8cd0ee0
fix(worktree): a set made inside a set records the checkout it stands…
gold-silver-copper Oct 27, 2026
eb75806
fix(worktree): create and add place repos at their path; escape clone…
gold-silver-copper Oct 27, 2026
cbcc3c1
fix(worktree): one worktree per place; parents first; exec by name
gold-silver-copper Oct 27, 2026
b32a24c
fix(worktree): parents by path; add compares places as paths
gold-silver-copper Oct 27, 2026
e9ed842
fix(worktree): parents from places; one branch per place; exec names
gold-silver-copper Oct 27, 2026
e1dfd1c
fix(worktree): add dedupes by place; parents only from projects; exec…
gold-silver-copper Oct 27, 2026
2c4997f
fix(worktree): parents through their own configs; add finds members b…
gold-silver-copper Oct 28, 2026
c3a2caf
fix(worktree): visible parent-skip warnings; dry-run notes links; --n…
gold-silver-copper Oct 28, 2026
eb31de7
fix(worktree): warn about a skipped parent only when it is a repo
gold-silver-copper Oct 28, 2026
033573e
fix(worktree): exec names from set_trees
gold-silver-copper Oct 28, 2026
e61b184
fix(worktree): exec uses set_tree; escape the PR-fetch alias
gold-silver-copper Oct 28, 2026
2f3ab5f
fix(worktree): hooks read the workspace's config, worked out before d…
gold-silver-copper Oct 28, 2026
4c15960
fix(worktree): add finds repos already in the set at any depth
gold-silver-copper Oct 28, 2026
07cb728
fix(worktree): add uses lenient discovery; refuses an occupied place
gold-silver-copper Oct 28, 2026
5cb5c2e
fix(worktree): add names files in the way that are no checkout
gold-silver-copper Oct 28, 2026
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
7 changes: 5 additions & 2 deletions src/clone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ pub(crate) fn execute_git_clone(
// Output what we know - just the meta repo clone command
// (Child repos are in .meta file which hasn't been cloned yet)
println!("{} Would clone meta repository:", style("[DRY RUN]").cyan());
println!(" {clone_cmd_str}");
println!(" {}", meta_core::display::escape(&clone_cmd_str));
return Ok(CommandResult::Message(String::new()));
}

Expand All @@ -124,7 +124,10 @@ pub(crate) fn execute_git_clone(
ssh_setup::SshMasters::UserManaged | ssh_setup::SshMasters::Failed => None,
};

println!("Cloning meta repository: {url}");
println!(
"Cloning meta repository: {}",
meta_core::display::escape(&url)
);
let mut clone_cmd = Command::new("git");
clone_cmd.arg("clone").args(&git_clone_args).arg(&url);
if let Some(ref dir) = dir_arg {
Expand Down
126 changes: 87 additions & 39 deletions src/commands/worktree/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ pub(crate) fn handle_add(args: AddArgs, verbose: bool, json: bool) -> Result<()>
let wt_dir = worktree_set_dir(&worktree_root, name)?;

if !wt_dir.exists() {
anyhow::bail!("Worktree '{}' not found at {}", name, escape_path(&wt_dir));
anyhow::bail!(
"Worktree '{}' not found at {}",
escape(name),
escape_path(&wt_dir)
);
}

// Check existing repos in the worktree
Expand All @@ -42,58 +46,81 @@ pub(crate) fn handle_add(args: AddArgs, verbose: bool, json: bool) -> Result<()>

// Check every repo before adding any: one refused after others were
// added would leave a set the caller didn't ask for
let mut planned = Vec::new();
for (i, spec) in repo_specs.iter().enumerate() {
// As in create: a repeat is one worktree, a repeat on another branch
// is refused
if let Some(first) = repo_specs.iter().take(i).find(|o| o.alias == spec.alias) {
if first.branch != spec.branch {
let mut planned: Vec<(&RepoSpec, std::path::PathBuf, String)> = Vec::new();
for spec in repo_specs {
// Use recursive lookup for nested paths (containing '/')
let (source, _project) = lookup_nested_project(&meta_dir, &spec.alias)?;
// The repo goes where it is in the workspace
let place = place_in_set(&meta_dir, &spec.alias, &source);
// As in create: a repo given twice, by name or by path, is one
// worktree, and refused on two branches
if let Some((first, ..)) = planned.iter().find(|(_, _, p)| same_place(p, &place)) {
let first_branch = resolve_branch(name, None, first.branch.as_deref());
let branch = resolve_branch(name, None, spec.branch.as_deref());
if first_branch != branch {
anyhow::bail!(
"Repo '{}' is given twice, on branches '{}' and '{}'",
escape(&spec.alias),
escape(first.branch.as_deref().unwrap_or("(default)")),
escape(spec.branch.as_deref().unwrap_or("(default)"))
escape(&place),
escape(&first_branch),
escape(&branch)
);
}
continue;
}
if existing.iter().any(|r| r.alias == spec.alias) {
anyhow::bail!(
"Repo '{}' already exists in worktree '{name}'",
escape(&spec.alias)
);
}

// Use recursive lookup for nested paths (containing '/')
let (source, _project) = lookup_nested_project(&meta_dir, &spec.alias)?;

// Preserve the full alias as the destination path so that relative
// references (e.g., Cargo.toml workspace members) remain valid.
let dest = wt_dir.join(&spec.alias);
// A checkout already in the worktree (such as the root repo's) may
// hold a symlink where this one goes; git must not follow it out
meta_core::config::resolve_inside(&wt_dir, &spec.alias).map_err(|why| {
let dest = dest_in_set(&wt_dir, &place).map_err(|why| {
anyhow::anyhow!(
"Refusing to add the worktree for '{}': {why}",
escape(&spec.alias)
)
})?;
planned.push((spec, source, dest));
// There already, under its place or where a link there leads, or
// nested in a member (`parent/leaf`); or its place holds other files
let real = |path: &std::path::Path| path.canonicalize().ok();
let member = dest.join(".git").symlink_metadata().is_ok()
|| existing.iter().any(|r| {
same_place(&r.alias, &place)
|| (real(&r.path).is_some() && real(&r.path) == real(&dest))
});
if member {
anyhow::bail!(
"Repo '{}' already exists in worktree '{}'",
escape(&spec.alias),
escape(name)
);
}
let taken = std::fs::read_dir(&dest)
.map_or(dest.symlink_metadata().is_ok(), |mut entries| {
entries.next().is_some()
});
if taken {
anyhow::bail!(
"{} in worktree '{}' already holds files that aren't a checkout of '{}'; move them away first",
escape_path(&dest),
escape(name),
escape(&spec.alias)
);
}
planned.push((spec, source, place));
}

let mut added = Vec::new();
let mut failed = None;
for (spec, source, dest) in planned {
for (spec, source, place) in planned {
let branch = resolve_branch(name, None, spec.branch.as_deref());
// Again now: a checkout added above (a parent repo's) may have put a
// symlink where this one goes
if let Err(why) = meta_core::config::resolve_inside(&wt_dir, &spec.alias) {
failed = Some(anyhow::anyhow!(
"Refusing to add the worktree for '{}': {why}",
escape(&spec.alias)
));
break;
}
let dest = match dest_in_set(&wt_dir, &place) {
Ok(dest) => dest,
Err(why) => {
failed = Some(anyhow::anyhow!(
"Refusing to add the worktree for '{}': {why}",
escape(&spec.alias)
));
break;
}
};

// Ensure parent directories exist for nested paths (e.g., "vendor/")
if let Some(parent) = dest.parent() {
Expand Down Expand Up @@ -125,12 +152,7 @@ pub(crate) fn handle_add(args: AddArgs, verbose: bool, json: bool) -> Result<()>
break;
}
};
added.push(CreateRepoEntry {
alias: spec.alias.clone(),
path: dest.display().to_string(),
branch,
created_branch,
});
// A worktree that can't be recorded is reported as failed, not added
if let Some(set_record) = &set_record {
if let Err(e) =
linked_worktree_admin_dir(&dest).and_then(|admin| record::write(&admin, set_record))
Expand All @@ -142,6 +164,12 @@ pub(crate) fn handle_add(args: AddArgs, verbose: bool, json: bool) -> Result<()>
break;
}
}
added.push(CreateRepoEntry {
alias: spec.alias.clone(),
path: dest.display().to_string(),
branch,
created_branch,
});
}

if json {
Expand All @@ -166,3 +194,23 @@ pub(crate) fn handle_add(args: AddArgs, verbose: bool, json: bool) -> Result<()>

failed.map_or(Ok(()), Err)
}

/// Whether two places in a set name the same directory. Discovery spells a
/// place with the platform's separator (`vendor\\app` on Windows), plans with
/// `/`; as paths they compare equal.
fn same_place(a: &str, b: &str) -> bool {
std::path::Path::new(a) == std::path::Path::new(b)
}

#[cfg(test)]
mod tests {
use super::same_place;

#[test]
fn places_compare_as_paths() {
assert!(same_place("vendor/app", "vendor/app"));
assert!(!same_place("vendor/app", "vendor/other"));
#[cfg(windows)]
assert!(same_place("vendor\\app", "vendor/app"));
}
}
7 changes: 4 additions & 3 deletions src/commands/worktree/cli_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ pub struct CreateArgs {
///
/// By default, worktree create includes the root repo and resolves
/// dependencies via provides/depends_on from .meta.yaml.
/// Use --no-deps to include only explicitly specified repos.
/// Use --no-deps to include only the named repos (and, for a repo nested
/// inside another project's path, that parent project).
#[arg(long)]
pub no_deps: bool,

Expand Down Expand Up @@ -169,8 +170,8 @@ pub struct ExecArgs {
/// Start from a specific commit-ish (ephemeral only)
pub commit_ish: Option<String>,

/// Only run in these repos (comma-separated): each value is a repo's alias,
/// its whole path in the set (. for the root), never part of one
/// Only run in these repos (comma-separated): each value is a repo's name,
/// or its whole path in the set (. for the root), never part of one
#[arg(long, value_delimiter = ',')]
pub include: Vec<String>,

Expand Down
Loading
Loading