//! Command implementations. Each `pub mod` maps to one `rv` subcommand.

use crate::config::{Config, Project, Scheme};
use crate::remote::{self, SchemeFlag};
use crate::workspace::Workspace;
use anyhow::{bail, Result};
use std::path::Path;

/// Resolve a project's clone/remote URL using config + scheme resolution.
fn project_url(cfg: &Config, p: &Project, flag: SchemeFlag) -> Result<String> {
    let rem = cfg
        .project_remote(p)
        .ok_or_else(|| anyhow::anyhow!("no remote for project {}", p.name))?;
    let resolved = remote::resolve(flag, cfg.defaults.scheme, &remote::SystemEnv);
    Ok(remote::url_for(&rem.host, &p.name, &resolved))
}

/// Whether any overlay links are configured (root `links:`, nested configs,
/// or `shared:` deps). On platforms without symlink overlays, overlay
/// maintenance is a no-op when nothing is configured instead of an error.
#[cfg(not(unix))]
fn has_overlay_config(ws: &Workspace) -> Result<bool> {
    if !ws.config.links.is_empty() || ws.config.projects.iter().any(|p| !p.shared.is_empty()) {
        return Ok(true);
    }
    for consumer in doctor::nested_consumers(ws)? {
        let cfg = Config::load(&consumer.join(crate::config::CONFIG_FILE))?;
        if !cfg.links.is_empty() || cfg.projects.iter().any(|p| !p.shared.is_empty()) {
            return Ok(true);
        }
    }
    Ok(false)
}

fn scheme_flag(https: bool, ssh: bool) -> SchemeFlag {
    if https {
        SchemeFlag::Https
    } else if ssh {
        SchemeFlag::Ssh
    } else {
        SchemeFlag::Unset
    }
}

fn current_or_workspace_targets(
    ws: &Workspace,
    targets: &[String],
) -> Result<Vec<(String, std::path::PathBuf)>> {
    if targets == ["all"] {
        return Ok(ws
            .config
            .projects
            .iter()
            .map(|p| (p.path.clone(), ws.project_dir(p)))
            .collect());
    }
    if targets.is_empty() {
        let cwd = std::env::current_dir()?;
        if crate::git::is_repo(&cwd) {
            let top = crate::git::check(&cwd, &["rev-parse", "--show-toplevel"])?
                .trim()
                .to_string();
            let dir = std::path::PathBuf::from(top);
            let label = dir
                .strip_prefix(&ws.root)
                .ok()
                .map(|p| p.to_string_lossy().replace('\\', "/"))
                .filter(|s| !s.is_empty())
                .unwrap_or_else(|| ".".into());
            return Ok(vec![(label, dir)]);
        }
    }
    Ok(ws
        .resolve_targets(targets)?
        .into_iter()
        .map(|p| (p.path.clone(), ws.project_dir(p)))
        .collect())
}

/// Split a trailing-var-arg list into (git args, workspace targets): trailing
/// args that resolve as workspace targets are peeled off the end.
fn split_git_args_and_targets(ws: &Workspace, args: &[String]) -> (Vec<String>, Vec<String>) {
    if args.len() < 2 {
        return (args.to_vec(), Vec::new());
    }
    let mut split = args.len();
    while split > 1 {
        let candidate = &args[split - 1..];
        if ws.resolve_targets(candidate).is_ok() {
            split -= 1;
        } else {
            break;
        }
    }
    (args[..split].to_vec(), args[split..].to_vec())
}

fn current_git_target() -> Result<Option<(String, std::path::PathBuf)>> {
    let cwd = std::env::current_dir()?;
    if !crate::git::is_repo(&cwd) {
        return Ok(None);
    }
    let top = crate::git::check(&cwd, &["rev-parse", "--show-toplevel"])?
        .trim()
        .to_string();
    Ok(Some((".".into(), std::path::PathBuf::from(top))))
}

fn ensure_no_unlinked_marker(ws: &Workspace) -> Result<()> {
    let marker = ws.root.join(".repoverse.unlinked");
    if marker.is_file() {
        let note = std::fs::read_to_string(&marker).unwrap_or_default();
        let summary = note
            .lines()
            .find(|line| line.starts_with("operation:"))
            .unwrap_or("operation: unknown");
        bail!(
            "pending Repoverse overlay-detach state ({summary}). Run `rv doctor --fix` \
to relink overlays before starting another git operation, or inspect {}",
            marker.display()
        );
    }
    Ok(())
}

// ---------------------------------------------------------------------------

/// Minimal ANSI coloring; disabled when not a TTY or NO_COLOR is set.
mod color {
    use std::io::IsTerminal;
    use std::sync::OnceLock;
    fn on() -> bool {
        static O: OnceLock<bool> = OnceLock::new();
        *O.get_or_init(|| std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal())
    }
    fn wrap(s: &str, code: &str) -> String {
        if on() {
            format!("\x1b[{code}m{s}\x1b[0m")
        } else {
            s.to_string()
        }
    }
    pub fn green(s: &str) -> String {
        wrap(s, "32")
    }
    pub fn yellow(s: &str) -> String {
        wrap(s, "33")
    }
    pub fn red(s: &str) -> String {
        wrap(s, "31")
    }
    pub fn cyan(s: &str) -> String {
        wrap(s, "36")
    }
    pub fn blue(s: &str) -> String {
        wrap(s, "34")
    }
    pub fn dim(s: &str) -> String {
        wrap(s, "2")
    }
}

pub mod doctor {
    use super::*;
    use crate::git;
    use std::collections::BTreeSet;
    use std::path::PathBuf;

    #[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)]
    struct Overlay {
        display: String,
        consumer: PathBuf,
        sub_rel: String,
    }

    pub fn run(fix: bool) -> Result<()> {
        let ws = Workspace::discover()?;
        let marker = ws.root.join(".repoverse.unlinked");
        let has_unlinked_marker = marker.is_file();
        let overlays = collect_overlays(&ws)?;
        let mut issues = Vec::new();
        let mut fixed = 0usize;
        let mut doctor_fixable = false;
        let mut needs_relink = false;

        if has_unlinked_marker && !fix {
            let note = std::fs::read_to_string(&marker).unwrap_or_default();
            let summary = note
                .lines()
                .find(|line| line.starts_with("operation:"))
                .unwrap_or("operation: unknown");
            issues.push(format!(
                "{} pending overlay-detach marker ({summary})",
                color::yellow("relink")
            ));
            needs_relink = true;
        }

        for overlay in overlays {
            let path = overlay.consumer.join(&overlay.sub_rel);
            if git::gitlink_sha(&overlay.consumer, &overlay.sub_rel).is_none() {
                continue;
            }

            let meta = std::fs::symlink_metadata(&path).ok();
            let is_symlink = meta
                .as_ref()
                .map(|m| m.file_type().is_symlink())
                .unwrap_or(false);
            let is_empty_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false)
                && std::fs::read_dir(&path)
                    .map(|mut entries| entries.next().is_none())
                    .unwrap_or(false);

            if is_symlink && !has_skip_worktree(&overlay.consumer, &overlay.sub_rel)? {
                if fix {
                    git::set_skip_worktree(&overlay.consumer, &overlay.sub_rel, true)?;
                    println!("  fixed {} (set skip-worktree)", overlay.display);
                    fixed += 1;
                } else {
                    doctor_fixable = true;
                    issues.push(format!(
                        "{} {} is a Repoverse symlink but is missing skip-worktree",
                        color::yellow("fixable"),
                        overlay.display
                    ));
                }
            } else if is_empty_dir {
                if fix {
                    needs_relink = true;
                } else {
                    needs_relink = true;
                    issues.push(format!(
                        "{} {} is an empty gitlink directory; run `rv link`",
                        color::yellow("relink"),
                        overlay.display
                    ));
                }
            } else if !is_symlink {
                if fix {
                    needs_relink = true;
                } else {
                    needs_relink = true;
                    issues.push(format!(
                        "{} {} is a gitlink path but is not a Repoverse symlink",
                        color::yellow("unlinked"),
                        overlay.display
                    ));
                }
            }
        }

        if fix && (needs_relink || has_unlinked_marker) {
            super::adopt::relink()?;
            fixed += 1;
        }

        if issues.is_empty() && fixed == 0 {
            println!("repoverse doctor: clean");
        } else if !issues.is_empty() {
            println!("{}", color::dim("repoverse doctor"));
            for issue in &issues {
                println!("  {issue}");
            }
            println!();
            if doctor_fixable {
                println!("recommended: {}", color::cyan("rv doctor --fix"));
            }
            if needs_relink {
                println!(
                    "recommended: {} after git pull/rebase/merge work is done",
                    color::cyan("rv link")
                );
            }
        }
        Ok(())
    }

    pub fn issue_count(ws: &Workspace) -> Result<usize> {
        let marker = ws.root.join(".repoverse.unlinked");
        let mut count = usize::from(marker.is_file());
        for overlay in collect_overlays(ws)? {
            let path = overlay.consumer.join(&overlay.sub_rel);
            if git::gitlink_sha(&overlay.consumer, &overlay.sub_rel).is_none() {
                continue;
            }

            let meta = std::fs::symlink_metadata(&path).ok();
            let is_symlink = meta
                .as_ref()
                .map(|m| m.file_type().is_symlink())
                .unwrap_or(false);
            let is_empty_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false)
                && std::fs::read_dir(&path)
                    .map(|mut entries| entries.next().is_none())
                    .unwrap_or(false);

            if is_empty_dir
                || !is_symlink
                || !has_skip_worktree(&overlay.consumer, &overlay.sub_rel)?
            {
                count += 1;
            }
        }
        Ok(count)
    }

    pub fn fix_quiet(ws: &Workspace) -> Result<usize> {
        let marker = ws.root.join(".repoverse.unlinked");
        let has_unlinked_marker = marker.is_file();
        let mut fixed = 0usize;
        let mut needs_relink = false;
        for overlay in collect_overlays(ws)? {
            let path = overlay.consumer.join(&overlay.sub_rel);
            if git::gitlink_sha(&overlay.consumer, &overlay.sub_rel).is_none() {
                continue;
            }

            let meta = std::fs::symlink_metadata(&path).ok();
            let is_symlink = meta
                .as_ref()
                .map(|m| m.file_type().is_symlink())
                .unwrap_or(false);
            let is_empty_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false)
                && std::fs::read_dir(&path)
                    .map(|mut entries| entries.next().is_none())
                    .unwrap_or(false);

            if is_symlink && !has_skip_worktree(&overlay.consumer, &overlay.sub_rel)? {
                git::set_skip_worktree(&overlay.consumer, &overlay.sub_rel, true)?;
                fixed += 1;
            } else if is_empty_dir || !is_symlink {
                needs_relink = true;
            }
        }

        if needs_relink || has_unlinked_marker {
            super::adopt::relink()?;
            fixed += 1;
        }
        Ok(fixed)
    }

    fn has_skip_worktree(repo: &Path, path: &str) -> Result<bool> {
        let out = git::check(repo, &["ls-files", "-v", "--", path])?;
        Ok(out
            .lines()
            .next()
            .and_then(|line| line.chars().next())
            .is_some_and(|flag| flag == 'S'))
    }

    fn collect_overlays(ws: &Workspace) -> Result<BTreeSet<Overlay>> {
        let mut overlays = BTreeSet::new();
        for link in &ws.config.links {
            if let Some((consumer, sub_rel)) = owning_repo(&ws.root, &link.at, &ws.root) {
                overlays.insert(Overlay {
                    display: link.at.clone(),
                    consumer,
                    sub_rel,
                });
            }
        }

        for consumer in nested_consumers(ws)? {
            let cfg = Config::load(&consumer.join(crate::config::CONFIG_FILE))?;
            let prefix = consumer
                .strip_prefix(&ws.root)
                .ok()
                .map(|p| p.to_string_lossy().replace('\\', "/"))
                .unwrap_or_else(|| consumer.display().to_string());
            for link in &cfg.links {
                if let Some((owner, sub_rel)) = owning_repo(&consumer, &link.at, &consumer) {
                    overlays.insert(Overlay {
                        display: format!("{prefix}/{}", link.at),
                        consumer: owner,
                        sub_rel,
                    });
                }
            }
            for project in &cfg.projects {
                for dep in &project.shared {
                    if let Some((owner, sub_rel)) = owning_repo(&consumer, &dep.path, &consumer) {
                        overlays.insert(Overlay {
                            display: format!("{prefix}/{}", dep.path),
                            consumer: owner,
                            sub_rel,
                        });
                    }
                }
            }
        }
        Ok(overlays)
    }

    pub(super) fn nested_consumers(ws: &Workspace) -> Result<Vec<PathBuf>> {
        let mut consumers = Vec::new();
        for project in &ws.config.projects {
            let dir = ws.project_dir(project);
            if dir != ws.root && dir.join(crate::config::CONFIG_FILE).is_file() {
                consumers.push(dir);
            }
        }

        let mut stack = vec![ws.root.clone()];
        while let Some(dir) = stack.pop() {
            let Ok(entries) = std::fs::read_dir(&dir) else {
                continue;
            };
            for entry in entries.flatten() {
                let path = entry.path();
                let name = entry.file_name();
                let name = name.to_string_lossy();
                if path.is_symlink() {
                    continue;
                }
                if path.is_dir() {
                    if name == ".git"
                        || name == "node_modules"
                        || name == "target"
                        || name == "repos"
                        || name.starts_with('.') && name != "."
                    {
                        continue;
                    }
                    stack.push(path);
                } else if name == crate::config::CONFIG_FILE
                    && dir != ws.root
                    && !consumers.iter().any(|c| c == &dir)
                {
                    consumers.push(dir.clone());
                }
            }
        }
        consumers.sort();
        consumers.dedup();
        Ok(consumers)
    }

    fn owning_repo(base: &Path, rel: &str, stop: &Path) -> Option<(PathBuf, String)> {
        let full = base.join(rel);
        let mut cur = full.parent();
        while let Some(dir) = cur {
            if dir.join(".git").exists() {
                let sub_rel = full
                    .strip_prefix(dir)
                    .ok()
                    .map(|p| p.to_string_lossy().replace('\\', "/"))?;
                return Some((dir.to_path_buf(), sub_rel));
            }
            if dir == stop {
                break;
            }
            cur = dir.parent();
        }
        None
    }
}

pub mod status {
    use super::*;
    use crate::git;
    use std::collections::{BTreeMap, BTreeSet};
    use std::path::{Component, Path, PathBuf};

    #[derive(Clone)]
    struct StatusProject {
        name: String,
        path: String,
        dir: PathBuf,
    }

    pub fn layout() -> Result<()> {
        let (rows, subs) = collect()?;
        print_layout(&rows, &subs);
        if rows.is_empty() && subs.is_empty() {
            println!("no .repoverse.yaml and no .gitmodules here");
        }
        Ok(())
    }

    pub fn run(json: bool, dirty_only: bool, target: Option<&str>) -> Result<()> {
        let cwd = std::env::current_dir()?;
        let ws = Workspace::discover().ok();

        if dirty_only {
            return print_dirty_status(ws.as_ref());
        }
        if let Some(target) = target {
            return print_repo_status(ws.as_ref(), &cwd, target);
        }
        if !json {
            if let Some(ws) = &ws {
                if cwd != ws.root {
                    if let Some(project) = project_containing_cwd(ws, &cwd) {
                        return print_repo_status(Some(ws), &cwd, &project.path);
                    }
                }
            }
        }
        if !json {
            return print_status_tree(ws.as_ref(), &cwd);
        }

        let (rows, subs) = collect()?;
        println!(
            "{}",
            serde_json::to_string_pretty(
                &serde_json::json!({"projects": rows, "submodules": subs})
            )?
        );
        Ok(())
    }

    fn collect() -> Result<(Vec<serde_json::Value>, Vec<serde_json::Value>)> {
        // Workspace is optional: still show submodules pre-adoption.
        let cwd = std::env::current_dir()?;
        let ws = Workspace::discover().ok();
        let root = ws
            .as_ref()
            .map(|w| w.root.clone())
            .unwrap_or_else(|| cwd.clone());

        let mut rows = Vec::new();
        if let Some(ws) = &ws {
            let lock = ws.lock()?;
            let gitlink_drifts = super::gitlinks::drift_counts(ws).unwrap_or_default();
            for p in &ws.config.projects {
                let dir = ws.project_dir(p);
                let row = if !git::is_repo(&dir) {
                    serde_json::json!({
                        "path": p.path, "name": p.name, "state": "missing",
                        "gitlink_drift": gitlink_drifts.get(&p.path).copied().unwrap_or(0),
                    })
                } else {
                    let branch = git::current_branch(&dir).unwrap_or_default();
                    let dirty = git::is_dirty(&dir).unwrap_or(false);
                    let (ahead, behind) = git::ahead_behind(&dir)?.unwrap_or((0, 0));
                    serde_json::json!({
                        "path": p.path, "name": p.name, "branch": branch,
                        "dirty": dirty, "ahead": ahead, "behind": behind,
                        "locked": lock.sha_for(&p.path),
                        "gitlink_drift": gitlink_drifts.get(&p.path).copied().unwrap_or(0),
                    })
                };
                rows.push(row);
            }
        }

        // Submodule overlay: scan .gitmodules tree, classify adoption state.
        let provided: std::collections::BTreeSet<String> = ws
            .as_ref()
            .map(|w| {
                w.config
                    .projects
                    .iter()
                    .map(|p| p.path.clone())
                    .chain(w.config.provides.iter().cloned())
                    .collect()
            })
            .unwrap_or_default();
        let mut subs = Vec::new();
        if root.join(".gitmodules").is_file() {
            let rid = git::run(&root, &["remote", "get-url", "origin"])
                .ok()
                .filter(|o| o.ok())
                .map(|o| crate::submodule::normalize_url(o.stdout.trim()))
                .unwrap_or_else(|| "workspace/root".into());
            for n in crate::submodule::scan(&root, &rid).order {
                for p in &n.paths {
                    let abs = root.join(p);
                    let is_link = std::fs::symlink_metadata(&abs)
                        .map(|m| m.file_type().is_symlink())
                        .unwrap_or(false);
                    let state = if is_link {
                        "shared"
                    } else if provided.contains(p)
                        || provided.contains(n.id.split_once('/').map(|x| x.1).unwrap_or(""))
                    {
                        "adopted"
                    } else {
                        "submodule"
                    };
                    let (branch, dirty) = if git::is_repo(&abs) {
                        (
                            git::current_branch(&abs).unwrap_or_else(|_| "?".into()),
                            git::is_dirty(&abs).unwrap_or(false),
                        )
                    } else {
                        ("-".into(), false)
                    };
                    let effective_target = if is_link {
                        display_target(&root, p, &abs, ws.as_ref())
                    } else {
                        None
                    };
                    subs.push(serde_json::json!({
                        "id": n.id, "path": p, "state": state,
                        "branch": branch, "dirty": dirty,
                        "shared_target": if is_link {
                            std::fs::read_link(&abs).ok()
                                .map(|t| t.to_string_lossy().into_owned())
                        } else { None },
                        "effective_target": effective_target,
                        "branch_conflict": !n.branch_conflicts.is_empty(),
                    }));
                }
            }
        }

        Ok((rows, subs))
    }

    fn display_target(
        root: &Path,
        path: &str,
        abs: &Path,
        ws: Option<&Workspace>,
    ) -> Option<String> {
        let target_abs = std::fs::canonicalize(abs).ok()?;
        if let Some(ws) = ws {
            for p in &ws.config.projects {
                let project_abs = std::fs::canonicalize(ws.project_dir(p)).ok();
                if project_abs.as_ref() == Some(&target_abs) {
                    return Some(p.path.clone());
                }
            }
        }
        let raw = std::fs::read_link(abs).ok()?;
        if raw.is_absolute() {
            return Some(path_to_string(&lexical_normalize(root, &raw)));
        }
        let mut resolved = logical_parent(root, path, ws).unwrap_or_else(|| {
            let mut p = PathBuf::from(path);
            p.pop();
            p
        });
        resolved.push(raw);
        Some(path_to_string(&lexical_normalize(Path::new(""), &resolved)))
    }

    fn logical_parent(root: &Path, path: &str, ws: Option<&Workspace>) -> Option<PathBuf> {
        let parts: Vec<&str> = path.split('/').collect();
        for i in (1..parts.len()).rev() {
            let prefix = parts[..i].join("/");
            let abs = root.join(&prefix);
            let is_link = std::fs::symlink_metadata(&abs)
                .map(|m| m.file_type().is_symlink())
                .unwrap_or(false);
            if !is_link {
                continue;
            }
            let target_abs = std::fs::canonicalize(&abs).ok()?;
            let target = project_path_for_abs(&target_abs, ws)
                .unwrap_or_else(|| path_to_string(&lexical_normalize(root, &target_abs)));
            let mut out = PathBuf::from(target);
            for part in &parts[i..parts.len() - 1] {
                out.push(part);
            }
            return Some(out);
        }
        None
    }

    fn project_path_for_abs(target_abs: &Path, ws: Option<&Workspace>) -> Option<String> {
        let ws = ws?;
        for p in &ws.config.projects {
            let project_abs = std::fs::canonicalize(ws.project_dir(p)).ok()?;
            if project_abs == target_abs {
                return Some(p.path.clone());
            }
        }
        None
    }

    fn lexical_normalize(root: &Path, path: &Path) -> PathBuf {
        let mut out = PathBuf::new();
        for c in path.components() {
            match c {
                Component::CurDir => {}
                Component::ParentDir => {
                    out.pop();
                }
                Component::Normal(p) => out.push(p),
                Component::RootDir | Component::Prefix(_) => out.push(c.as_os_str()),
            }
        }
        out.strip_prefix(root).unwrap_or(&out).to_path_buf()
    }

    fn path_to_string(path: &Path) -> String {
        let s = path.to_string_lossy();
        if s.is_empty() {
            ".".into()
        } else {
            s.into_owned()
        }
    }

    fn print_repo_status(ws: Option<&Workspace>, cwd: &Path, target: &str) -> Result<()> {
        let (label, dir) = resolve_status_target(ws, cwd, target)?;
        if !git::is_repo(&dir) {
            bail!("{label}: not a git checkout");
        }
        let branch = git::current_branch(&dir).unwrap_or_else(|_| "?".into());
        let (ahead, behind) = git::ahead_behind(&dir)?.unwrap_or((0, 0));
        let files = porcelain_status(&dir)?;
        let mark = if files.is_empty() {
            color::green("●")
        } else {
            color::yellow("●")
        };
        let ab = if ahead == 0 && behind == 0 {
            String::new()
        } else {
            color::yellow(&format!("  ↑{ahead} ↓{behind}"))
        };
        println!("{} {} {}{}", mark, label, color::dim(&branch), ab);
        if files.is_empty() {
            println!("{}", color::green("  clean"));
        } else {
            for line in files {
                println!("  {line}");
            }
        }
        Ok(())
    }

    fn print_dirty_status(ws: Option<&Workspace>) -> Result<()> {
        let Some(ws) = ws else {
            bail!("no .repoverse.yaml found");
        };
        let mut any = false;
        println!("{}", color::dim("repoverse dirty status"));
        let root_files = if git::is_repo(&ws.root) {
            porcelain_status(&ws.root)?
        } else {
            Vec::new()
        };
        if !root_files.is_empty() {
            any = true;
            let branch = git::current_branch(&ws.root).unwrap_or_else(|_| "?".into());
            println!();
            println!("{} {:<46} {}", color::yellow("●"), ".", color::dim(&branch));
            for line in root_files {
                println!("    {line}");
            }
        }
        for p in collect_status_projects(ws)? {
            let dir = p.dir;
            if !git::is_repo(&dir) {
                continue;
            }
            let files = porcelain_status(&dir)?;
            if files.is_empty() {
                continue;
            }
            any = true;
            let branch = git::current_branch(&dir).unwrap_or_else(|_| "?".into());
            let (ahead, behind) = git::ahead_behind(&dir)?.unwrap_or((0, 0));
            let ab = if ahead == 0 && behind == 0 {
                String::new()
            } else {
                color::yellow(&format!("  ↑{ahead} ↓{behind}"))
            };
            println!();
            println!(
                "{} {:<46} {}{}",
                color::yellow("●"),
                p.path,
                color::dim(&branch),
                ab
            );
            for line in files {
                println!("    {line}");
            }
        }
        if !any {
            println!("{}", color::green("  clean"));
        }
        Ok(())
    }

    fn print_status_tree(ws: Option<&Workspace>, cwd: &Path) -> Result<()> {
        let Some(ws) = ws else {
            return print_repo_status(None, cwd, ".");
        };
        let root_branch = git::current_branch(&ws.root).unwrap_or_else(|_| "?".into());
        let root_diverged = divergence_note(&ws.root);
        let root_dirty = porcelain_status(&ws.root)
            .map(|f| !f.is_empty())
            .unwrap_or(false);
        let gitlink_drifts = super::gitlinks::drift_counts(ws).unwrap_or_default();
        let root_name = ws
            .root
            .file_name()
            .map(|s| s.to_string_lossy())
            .unwrap_or_else(|| ".".into());
        let root_gitlink_note = gitlink_drifts
            .get(".")
            .filter(|count| **count > 0)
            .map(|count| format!(" {}", color::yellow(&format!("[gitlinks:{count}]"))))
            .unwrap_or_default();
        let overlay_issues = super::doctor::issue_count(ws).unwrap_or_default();
        let root_overlay_note = if overlay_issues > 0 {
            format!(
                " {}",
                color::yellow(&format!("[overlays:{overlay_issues}]"))
            )
        } else {
            String::new()
        };
        let root_mark = if root_dirty {
            format!(" {}", color::red("[dirty]"))
        } else {
            String::new()
        };
        println!(
            "{}/ {}{}{}{}{}",
            color::cyan(&root_name),
            color::blue(&format!("[{root_branch}]")),
            root_diverged,
            root_mark,
            root_gitlink_note,
            root_overlay_note
        );

        let projects = collect_status_projects(ws)?;
        for (idx, p) in projects.iter().enumerate() {
            let last = idx + 1 == projects.len();
            let prefix = if last { "└── " } else { "├── " };
            let dir = p.dir.clone();
            if !git::is_repo(&dir) {
                println!(
                    "{}{} {}",
                    prefix,
                    color::cyan(&format!("{}/", p.path)),
                    color::red("[missing]")
                );
                continue;
            }
            let branch = git::current_branch(&dir).unwrap_or_else(|_| "?".into());
            let diverged = divergence_note(&dir);
            let dirty = porcelain_status(&dir)
                .map(|f| !f.is_empty())
                .unwrap_or(false);
            let dirty_note = if dirty {
                format!(" {}", color::red("[dirty]"))
            } else {
                String::new()
            };
            let gitlink_note = gitlink_drifts
                .get(&p.path)
                .filter(|count| **count > 0)
                .map(|count| format!(" {}", color::yellow(&format!("[gitlinks:{count}]"))))
                .unwrap_or_default();
            println!(
                "{}{} {}{}{}{}",
                prefix,
                color::cyan(&format!("{}/", p.path)),
                color::blue(&format!("[{branch}]")),
                diverged,
                dirty_note,
                gitlink_note
            );
        }

        println!();
        println!("{}", color::green("Legend:"));
        println!("  {}     - on branch", color::blue("[branch]"));
        println!("  {}   - detached HEAD", color::blue("(detached)"));
        println!("  {}      - uncommitted changes", color::red("[dirty]"));
        println!(
            "  {} - committed gitlinks differ from current checkouts",
            color::yellow("[gitlinks:N]")
        );
        println!(
            "  {} - Repoverse symlink overlays need relink/skip-worktree repair",
            color::yellow("[overlays:N]")
        );
        println!("  {}    - repo not checked out", color::red("[missing]"));
        println!(
            "  {}",
            color::dim("use `rv status --dirty` to list changed files")
        );
        Ok(())
    }

    fn divergence_note(dir: &Path) -> String {
        match git::ahead_behind(dir) {
            Ok(Some((ahead, behind))) if ahead > 0 || behind > 0 => {
                format!(" {}", color::yellow(&format!("↑{ahead} ↓{behind}")))
            }
            _ => String::new(),
        }
    }

    fn porcelain_status(dir: &Path) -> Result<Vec<String>> {
        let out = git::check(dir, &["status", "--porcelain", "--ignore-submodules=all"])?;
        Ok(out
            .lines()
            .filter(|l| !l.trim().is_empty())
            .map(|l| l.to_string())
            .collect())
    }

    fn resolve_status_target(
        ws: Option<&Workspace>,
        cwd: &Path,
        target: &str,
    ) -> Result<(String, PathBuf)> {
        if target == "." {
            return Ok((path_to_string(cwd), cwd.to_path_buf()));
        }
        if let Some(ws) = ws {
            let projects = collect_status_projects(ws)?;
            let matches: Vec<_> = projects
                .iter()
                .filter(|p| {
                    p.path == target
                        || p.name == target
                        || p.name.rsplit('/').next() == Some(target)
                })
                .collect();
            if matches.len() == 1 {
                let p = matches[0];
                return Ok((p.path.clone(), p.dir.clone()));
            }
            if matches.len() > 1 {
                bail!("ambiguous target `{target}`; use a full path or owner/repo name");
            }
            let rel = ws.root.join(target);
            if rel.exists() {
                return Ok((target.to_string(), rel));
            }
        }
        let path = PathBuf::from(target);
        if path.exists() {
            return Ok((target.to_string(), path));
        }
        bail!("unknown status target `{target}`")
    }

    fn project_containing_cwd(ws: &Workspace, cwd: &Path) -> Option<StatusProject> {
        let cwd = std::fs::canonicalize(cwd).ok()?;
        collect_status_projects(ws)
            .ok()?
            .into_iter()
            .filter_map(|p| {
                let dir = std::fs::canonicalize(&p.dir).ok()?;
                if cwd.starts_with(&dir) {
                    Some((dir.components().count(), p))
                } else {
                    None
                }
            })
            .max_by_key(|(depth, _)| *depth)
            .map(|(_, p)| p)
    }

    fn collect_status_projects(ws: &Workspace) -> Result<Vec<StatusProject>> {
        let mut projects = Vec::new();
        let mut seen_configs = BTreeSet::new();
        let mut seen_dirs = BTreeSet::new();
        collect_status_projects_from(
            ws,
            &ws.root,
            &ws.config,
            &mut seen_configs,
            &mut seen_dirs,
            &mut projects,
        )?;
        projects.sort_by(|a, b| {
            a.path
                .to_ascii_lowercase()
                .cmp(&b.path.to_ascii_lowercase())
        });
        Ok(projects)
    }

    fn collect_status_projects_from(
        ws: &Workspace,
        base: &Path,
        cfg: &Config,
        seen_configs: &mut BTreeSet<PathBuf>,
        seen_dirs: &mut BTreeSet<PathBuf>,
        projects: &mut Vec<StatusProject>,
    ) -> Result<()> {
        let config_path = base.join(crate::config::CONFIG_FILE);
        let config_key = std::fs::canonicalize(&config_path).unwrap_or(config_path);
        if !seen_configs.insert(config_key) {
            return Ok(());
        }

        let mut nested_configs = Vec::new();
        for project in &cfg.projects {
            let dir = if project.path == "." {
                base.to_path_buf()
            } else {
                base.join(&project.path)
            };
            let dir_key = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
            if seen_dirs.insert(dir_key) {
                projects.push(StatusProject {
                    name: project.name.clone(),
                    path: display_path(ws, &dir),
                    dir: dir.clone(),
                });
            }

            let nested = dir.join(crate::config::CONFIG_FILE);
            if dir != base && nested.is_file() {
                nested_configs.push((dir, nested));
            }
        }

        for (dir, config_path) in nested_configs {
            let cfg = Config::load(&config_path)?;
            collect_status_projects_from(ws, &dir, &cfg, seen_configs, seen_dirs, projects)?;
        }
        Ok(())
    }

    fn display_path(ws: &Workspace, dir: &Path) -> String {
        dir.strip_prefix(&ws.root)
            .unwrap_or(dir)
            .to_string_lossy()
            .replace('\\', "/")
    }

    fn print_layout(rows: &[serde_json::Value], subs: &[serde_json::Value]) {
        if rows.is_empty() && subs.is_empty() {
            return;
        }

        println!("{}", color::dim("repoverse layout"));
        println!(
            "{}",
            color::dim(
                "  legend: green=clean yellow=dirty cyan=shared->checkout grey=plain submodule"
            )
        );

        let mut projects: BTreeMap<String, &serde_json::Value> = BTreeMap::new();
        for r in rows {
            if let Some(path) = r["path"].as_str() {
                projects.insert(path.to_string(), r);
            }
        }

        let mut target_to_paths: BTreeMap<String, Vec<&serde_json::Value>> = BTreeMap::new();
        for s in subs {
            if s["state"] == "shared" {
                if let Some(target) = s["effective_target"].as_str() {
                    target_to_paths
                        .entry(target.to_string())
                        .or_default()
                        .push(s);
                }
            }
        }

        let direct_counts = direct_descendant_counts(subs);
        let level_roots = direct_level_roots(&direct_counts);
        let nested_roots = nested_shared_roots(subs, &target_to_paths);
        let mut printed_projects = BTreeSet::new();
        let mut printed_paths = BTreeSet::new();

        print_level(
            "Level 1: App Workspace",
            "",
            rows,
            subs,
            &projects,
            &target_to_paths,
            &direct_counts,
            &nested_roots,
            &mut printed_projects,
            &mut printed_paths,
        );

        let mut level_num = 2;
        for root in level_roots {
            let title = format!("Level {level_num}: {}", title_for(&root));
            print_level(
                &title,
                &format!("{root}/"),
                rows,
                subs,
                &projects,
                &target_to_paths,
                &direct_counts,
                &nested_roots,
                &mut printed_projects,
                &mut printed_paths,
            );
            level_num += 1;
        }
        for root in nested_roots {
            let title = format!("Level {level_num}: {}", title_for(nested_name(&root)));
            print_level(
                &title,
                &format!("{root}/"),
                rows,
                subs,
                &projects,
                &target_to_paths,
                &direct_counts,
                &[],
                &mut printed_projects,
                &mut printed_paths,
            );
            level_num += 1;
        }
    }

    fn direct_descendant_counts(subs: &[serde_json::Value]) -> BTreeMap<String, usize> {
        let mut counts = BTreeMap::new();
        for s in subs {
            let Some(path) = s["path"].as_str() else {
                continue;
            };
            if let Some((root, _)) = path.split_once('/') {
                *counts.entry(root.to_string()).or_insert(0) += 1;
            }
        }
        counts
    }

    fn direct_level_roots(direct_counts: &BTreeMap<String, usize>) -> Vec<String> {
        let mut roots = BTreeSet::new();
        for (root, count) in direct_counts {
            if *count > 1 {
                roots.insert(root.to_string());
            }
        }
        roots.into_iter().collect()
    }

    fn nested_shared_roots(
        subs: &[serde_json::Value],
        target_to_paths: &BTreeMap<String, Vec<&serde_json::Value>>,
    ) -> Vec<String> {
        let mut roots = BTreeSet::new();
        for paths in target_to_paths.values() {
            if paths.len() <= 1 {
                continue;
            }
            let Some(root) = paths
                .iter()
                .filter_map(|s| s["path"].as_str())
                .filter(|path| {
                    subs.iter().any(|s| {
                        s["path"]
                            .as_str()
                            .map(|p| p.starts_with(&format!("{path}/")))
                            .unwrap_or(false)
                    })
                })
                .min_by_key(|path| path.matches('/').count())
            else {
                continue;
            };
            if root.contains('/') {
                roots.insert(root.to_string());
            }
        }
        roots.into_iter().collect()
    }

    fn nested_name(root: &str) -> &str {
        root.rsplit('/').next().unwrap_or(root)
    }

    #[allow(clippy::too_many_arguments)]
    fn print_level(
        title: &str,
        prefix: &str,
        rows: &[serde_json::Value],
        subs: &[serde_json::Value],
        projects: &BTreeMap<String, &serde_json::Value>,
        target_to_paths: &BTreeMap<String, Vec<&serde_json::Value>>,
        direct_counts: &BTreeMap<String, usize>,
        nested_roots: &[String],
        printed_projects: &mut BTreeSet<String>,
        printed_paths: &mut BTreeSet<String>,
    ) {
        let level_paths: Vec<&serde_json::Value> = subs
            .iter()
            .filter(|s| {
                let Some(path) = s["path"].as_str() else {
                    return false;
                };
                if prefix.is_empty() {
                    is_app_level_path(path, direct_counts)
                } else {
                    path.starts_with(prefix)
                        && !is_nested_child_for_level(path, prefix, nested_roots)
                }
            })
            .collect();

        let mut checkout_paths = BTreeSet::new();
        let mut plain_submodules = Vec::new();
        for s in &level_paths {
            match s["state"].as_str().unwrap_or("submodule") {
                "shared" => {
                    if let Some(target) = s["effective_target"].as_str() {
                        checkout_paths.insert(target.to_string());
                    }
                }
                "adopted" => {
                    if let Some(path) = s["path"].as_str() {
                        checkout_paths.insert(path.to_string());
                    }
                }
                _ => plain_submodules.push(*s),
            }
        }

        let checkouts: Vec<&serde_json::Value> = rows
            .iter()
            .filter(|r| {
                let Some(path) = r["path"].as_str() else {
                    return false;
                };
                checkout_paths.contains(path) && !printed_projects.contains(path)
            })
            .collect();
        if checkouts.is_empty() && plain_submodules.is_empty() && level_paths.is_empty() {
            return;
        }

        println!();
        println!("{}", color::dim(title));
        if !checkouts.is_empty() || !plain_submodules.is_empty() {
            println!("{}", color::dim("  checkouts"));
            for r in checkouts {
                if let Some(path) = r["path"].as_str() {
                    printed_projects.insert(path.to_string());
                }
                print_checkout_row(r, true);
            }
            for s in plain_submodules {
                print_plain_submodule_checkout(s);
            }
        }

        let paths: Vec<&serde_json::Value> = level_paths
            .into_iter()
            .filter(|s| {
                let Some(path) = s["path"].as_str() else {
                    return false;
                };
                s["state"] != "submodule" && !printed_paths.contains(path)
            })
            .collect();
        if !paths.is_empty() {
            println!("{}", color::dim("  paths"));
            for s in paths {
                if let Some(path) = s["path"].as_str() {
                    printed_paths.insert(path.to_string());
                }
                print_path_row(s, projects, target_to_paths);
            }
        }
    }

    fn is_nested_child_for_level(path: &str, prefix: &str, nested_roots: &[String]) -> bool {
        for root in nested_roots {
            let nested_prefix = format!("{root}/");
            if nested_prefix == prefix {
                continue;
            }
            if path.starts_with(&nested_prefix) {
                return true;
            }
        }
        false
    }

    fn is_app_level_path(path: &str, direct_counts: &BTreeMap<String, usize>) -> bool {
        if path.matches('/').count() == 0 {
            return true;
        }
        if let Some((root, _)) = path.split_once('/') {
            return direct_counts.get(root).copied().unwrap_or(0) <= 1;
        }
        false
    }

    fn title_for(root: &str) -> String {
        match root {
            "bioscript" => "BioScript".into(),
            "exvitae" => "ExVitae".into(),
            "htslib-rs" => "htslib-rs".into(),
            other => other
                .split(['-', '_'])
                .filter(|p| !p.is_empty())
                .map(|p| {
                    let mut chars = p.chars();
                    match chars.next() {
                        Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
                        None => String::new(),
                    }
                })
                .collect::<Vec<_>>()
                .join(" "),
        }
    }

    fn print_checkout_row(r: &serde_json::Value, repoverse: bool) {
        let path = r["path"].as_str().unwrap_or("?");
        if r["state"] == "missing" {
            println!(
                "    {}  {}",
                color::red("x"),
                color::red(&format!("{path} (missing)"))
            );
            return;
        }
        let dirty = r["dirty"].as_bool().unwrap_or(false);
        let mark = if dirty {
            color::yellow("●")
        } else {
            color::green("●")
        };
        let suffix = if repoverse {
            String::new()
        } else {
            format!(" {}", color::dim("[plain submodule, not mapped]"))
        };
        println!(
            "    {} {:<46} {}{}",
            mark,
            path,
            r["branch"].as_str().unwrap_or("?"),
            suffix
        );
    }

    fn print_plain_submodule_checkout(s: &serde_json::Value) {
        let dirty = s["dirty"].as_bool().unwrap_or(false);
        let mark = if dirty {
            color::yellow("●")
        } else {
            color::dim("●")
        };
        println!(
            "    {} {:<46} branch {} not mapped",
            mark,
            s["path"].as_str().unwrap_or("?"),
            color::dim(s["branch"].as_str().unwrap_or("-"))
        );
    }

    fn print_path_row(
        s: &serde_json::Value,
        projects: &BTreeMap<String, &serde_json::Value>,
        target_to_paths: &BTreeMap<String, Vec<&serde_json::Value>>,
    ) {
        let path = s["path"].as_str().unwrap_or("?");
        let dirty = s["dirty"].as_bool().unwrap_or(false);
        let conflict = if s["branch_conflict"].as_bool().unwrap_or(false) {
            color::red("  branch-conflict")
        } else {
            String::new()
        };
        match s["state"].as_str().unwrap_or("submodule") {
            "shared" => {
                let target = s["effective_target"]
                    .as_str()
                    .or_else(|| s["shared_target"].as_str())
                    .unwrap_or("?");
                let branch = projects
                    .get(target)
                    .and_then(|r| r["branch"].as_str())
                    .unwrap_or_else(|| s["branch"].as_str().unwrap_or("-"));
                let mark = if dirty {
                    color::yellow("⇄")
                } else {
                    color::cyan("⇄")
                };
                let fanin = target_to_paths
                    .get(target)
                    .map(|v| {
                        if v.len() > 1 {
                            format!(" {}", color::dim(&format!("[shared x{}]", v.len())))
                        } else {
                            String::new()
                        }
                    })
                    .unwrap_or_default();
                println!(
                    "    {} {:<62} -> {:<34} {}{}{}",
                    mark,
                    path,
                    color::cyan(target),
                    color::dim(branch),
                    fanin,
                    conflict
                );
            }
            "adopted" => {
                let mark = if dirty {
                    color::yellow("●")
                } else {
                    color::green("●")
                };
                println!(
                    "    {} {:<62} {}{}",
                    mark,
                    path,
                    color::green("[repoverse checkout]"),
                    conflict
                );
            }
            _ => {
                let mark = if dirty {
                    color::yellow("●")
                } else {
                    color::dim("●")
                };
                println!(
                    "    {} {:<62} {} {}{}",
                    mark,
                    path,
                    color::dim(s["branch"].as_str().unwrap_or("-")),
                    color::dim("[plain submodule, not mapped]"),
                    conflict
                );
            }
        }
    }
}

pub mod gitlinks {
    use super::*;
    use crate::git;
    use crate::graph::Graph;
    use std::collections::{BTreeMap, BTreeSet};
    use std::path::PathBuf;

    #[derive(Debug, Clone)]
    pub struct GitlinkState {
        pub consumer: String,
        pub dependency: String,
        pub sub_path: String,
        pub actual: Option<String>,
        pub expected: String,
    }

    impl GitlinkState {
        pub fn in_sync(&self) -> bool {
            self.actual.as_deref() == Some(self.expected.as_str())
        }
    }

    pub fn run(commit: bool) -> Result<()> {
        let ws = Workspace::discover()?;
        if commit {
            let fixed = super::doctor::fix_quiet(&ws)?;
            if fixed > 0 {
                println!("repoverse overlays: repaired {fixed}");
            }
        }
        let states = collect(&ws)?;
        if states.is_empty() {
            println!("no gitlink edges declared in .repoverse.yaml");
            return Ok(());
        }

        let drift: Vec<_> = states.iter().filter(|s| !s.in_sync()).collect();
        print_report(&states, commit);

        if drift.is_empty() {
            println!("gitlinks clean");
            return Ok(());
        }
        if !commit {
            println!();
            println!(
                "{} drift(s); run `rv gitlinks --commit` to stamp them",
                drift.len()
            );
            return Ok(());
        }

        stamp_until_clean(&ws)?;
        let fixed = super::doctor::fix_quiet(&ws)?;
        if fixed > 0 {
            println!("repoverse overlays: repaired {fixed}");
        }
        Ok(())
    }

    pub fn drift_counts(ws: &Workspace) -> Result<BTreeMap<String, usize>> {
        let mut counts = BTreeMap::new();
        for state in collect(ws)? {
            if !state.in_sync() {
                *counts.entry(state.consumer).or_insert(0) += 1;
            }
        }
        Ok(counts)
    }

    pub fn collect(ws: &Workspace) -> Result<Vec<GitlinkState>> {
        let mut seen = BTreeSet::new();
        let mut states = collect_link_states(ws, &mut seen)?;
        states.extend(collect_consume_states(ws, &mut seen)?);
        states.sort_by(|a, b| {
            a.consumer
                .cmp(&b.consumer)
                .then_with(|| a.sub_path.cmp(&b.sub_path))
        });
        Ok(states)
    }

    fn collect_consume_states(
        ws: &Workspace,
        seen: &mut BTreeSet<(String, String)>,
    ) -> Result<Vec<GitlinkState>> {
        let projects_by_path: BTreeMap<&str, &crate::config::Project> = ws
            .config
            .projects
            .iter()
            .map(|p| (p.path.as_str(), p))
            .collect();
        let graph = Graph::from_config(&ws.config);
        let mut states = Vec::new();

        for (consumer_path, deps) in &graph.deps {
            let Some(consumer) = projects_by_path.get(consumer_path.as_str()) else {
                continue;
            };
            let consumer_dir = ws.project_dir(consumer);
            if !git::is_repo(&consumer_dir) {
                continue;
            }
            for dep_path in deps {
                let Some(dep) = projects_by_path.get(dep_path.as_str()) else {
                    continue;
                };
                let dep_dir = ws.project_dir(dep);
                if !git::is_repo(&dep_dir) {
                    continue;
                }
                let Some(sub_path) = resolve_sub_path(ws, consumer, dep, &consumer_dir) else {
                    continue;
                };
                let expected = git::head_sha(&dep_dir)?;
                let actual = git::head_gitlink_sha(&consumer_dir, &sub_path);
                if !seen.insert((consumer.path.clone(), sub_path.clone())) {
                    continue;
                }
                states.push(GitlinkState {
                    consumer: consumer.path.clone(),
                    dependency: dep.path.clone(),
                    sub_path,
                    actual,
                    expected,
                });
            }
        }
        Ok(states)
    }

    fn collect_link_states(
        ws: &Workspace,
        seen: &mut BTreeSet<(String, String)>,
    ) -> Result<Vec<GitlinkState>> {
        let mut states = Vec::new();
        let provider_dir = |name: &str| -> Option<PathBuf> {
            ws.config
                .projects
                .iter()
                .find(|p| p.name == name)
                .map(|p| ws.project_dir(p))
                .filter(|d| git::is_repo(d))
        };
        let mut links: Vec<_> = ws.config.links.iter().collect();
        links.sort_by_key(|link| link.at.matches('/').count());
        for link in &links {
            let Some(dep_dir) = provider_dir(&link.repo) else {
                continue;
            };
            let physical = physical_link_path(ws, link, &links, &provider_dir);
            let Some((consumer_dir, sub_path)) = owning_repo_for_path(&ws.root, &physical) else {
                continue;
            };
            let consumer = consumer_label(ws, &consumer_dir);
            if !seen.insert((consumer.clone(), sub_path.clone())) {
                continue;
            }
            let expected = git::head_sha(&dep_dir)?;
            let actual = git::head_gitlink_sha(&consumer_dir, &sub_path);
            states.push(GitlinkState {
                consumer,
                dependency: provider_label(ws, &link.repo),
                sub_path,
                actual,
                expected,
            });
        }
        Ok(states)
    }

    fn stamp_until_clean(ws: &Workspace) -> Result<()> {
        let mut stamped_any = false;
        for _ in 0..64 {
            let states = collect(ws)?;
            let drift: Vec<_> = states.iter().filter(|s| !s.in_sync()).collect();
            if drift.is_empty() {
                if stamped_any {
                    println!("gitlinks clean");
                }
                return Ok(());
            }

            let dirty_consumers: BTreeSet<&str> =
                drift.iter().map(|state| state.consumer.as_str()).collect();
            let mut drifts_by_consumer: BTreeMap<&str, Vec<&GitlinkState>> = BTreeMap::new();
            for state in &drift {
                drifts_by_consumer
                    .entry(state.consumer.as_str())
                    .or_default()
                    .push(*state);
            }
            let ready: Vec<_> = drifts_by_consumer
                .values()
                .filter(|states| {
                    states
                        .iter()
                        .all(|state| !dirty_consumers.contains(state.dependency.as_str()))
                })
                .flat_map(|states| states.iter().copied())
                .collect();
            if ready.is_empty() {
                bail!("cannot find a leaf gitlink consumer to stamp; dependency cycle or unresolved graph");
            }

            stamp_and_commit(ws, &ready)?;
            stamped_any = true;
        }
        bail!("gitlink stamping did not converge after 64 passes");
    }

    fn stamp_and_commit(ws: &Workspace, drift: &[&GitlinkState]) -> Result<()> {
        let mut by_consumer: BTreeMap<&str, Vec<&GitlinkState>> = BTreeMap::new();
        for state in drift {
            by_consumer
                .entry(state.consumer.as_str())
                .or_default()
                .push(*state);
        }

        for (consumer_path, states) in by_consumer {
            let dir = consumer_dir_from_label(ws, consumer_path);
            ensure_no_unrelated_staged_changes(&dir, consumer_path, &states)?;
            for state in &states {
                git::set_gitlink(&dir, &state.sub_path, &state.expected)?;
                println!(
                    "  {} {}:{} -> {}",
                    color::green("fixed"),
                    consumer_path,
                    state.sub_path,
                    short_sha(&state.expected)
                );
            }
            if states
                .iter()
                .any(|state| index_differs_from_head(&dir, &state.sub_path))
            {
                git::check(
                    &dir,
                    &["commit", "-m", "stamp gitlinks from repoverse checkouts"],
                )?;
                println!("  {} {consumer_path}", color::green("committed"));
            }
        }
        Ok(())
    }

    fn print_report(states: &[GitlinkState], commit: bool) {
        let drift_count = states.iter().filter(|state| !state.in_sync()).count();
        let ok_count = states.len().saturating_sub(drift_count);
        if commit && drift_count > 0 {
            println!("repoverse gitlinks (before stamp)");
        } else {
            println!("repoverse gitlinks");
        }

        if drift_count == 0 {
            println!(
                "  {}",
                color::green(&format!("all {} gitlink(s) ok", states.len()))
            );
            return;
        }

        let mut by_consumer: BTreeMap<&str, Vec<&GitlinkState>> = BTreeMap::new();
        for state in states.iter().filter(|state| !state.in_sync()) {
            by_consumer
                .entry(state.consumer.as_str())
                .or_default()
                .push(state);
        }

        let total = by_consumer.len();
        for (idx, (consumer, group)) in by_consumer.iter().enumerate() {
            let branch = if idx + 1 == total {
                "└──"
            } else {
                "├──"
            };
            let child_prefix = if idx + 1 == total { "    " } else { "│   " };
            println!(
                "{} {} {}",
                branch,
                color::cyan(consumer),
                color::yellow(&format!("[drift:{}]", group.len()))
            );
            for state in group {
                let actual = state
                    .actual
                    .as_deref()
                    .map(short_sha)
                    .unwrap_or_else(|| "missing".to_string());
                println!(
                    "{}{} {} -> {}  {} -> {}",
                    child_prefix,
                    color::yellow("!"),
                    state.sub_path,
                    state.dependency,
                    actual,
                    short_sha(&state.expected)
                );
            }
        }

        if ok_count > 0 {
            println!(
                "  {}",
                color::dim(&format!("{ok_count} ok gitlink(s) hidden"))
            );
        }
    }

    fn consumer_dir_from_label(ws: &Workspace, consumer_path: &str) -> PathBuf {
        if consumer_path == "." {
            return ws.root.clone();
        }
        ws.config
            .projects
            .iter()
            .find(|p| p.path == consumer_path)
            .map(|p| ws.project_dir(p))
            .unwrap_or_else(|| ws.root.join(consumer_path))
    }

    fn ensure_no_unrelated_staged_changes(
        dir: &Path,
        consumer_path: &str,
        states: &[&GitlinkState],
    ) -> Result<()> {
        let managed: BTreeSet<&str> = states.iter().map(|state| state.sub_path.as_str()).collect();
        let unrelated: Vec<_> = staged_paths(dir)?
            .into_iter()
            .filter(|path| !managed.contains(path.as_str()))
            .collect();
        if !unrelated.is_empty() {
            bail!(
                "{} has staged changes outside gitlinks being stamped: {}",
                consumer_path,
                unrelated.join(", ")
            );
        }
        Ok(())
    }

    fn staged_paths(dir: &Path) -> Result<Vec<String>> {
        let out = git::check(
            dir,
            &[
                "status",
                "--porcelain=v1",
                "--untracked-files=no",
                "--ignore-submodules=all",
            ],
        )?;
        Ok(out
            .lines()
            .filter(|line| line.len() > 3 && line.as_bytes()[0] != b' ')
            .map(|line| line[3..].split(" -> ").last().unwrap_or("").to_string())
            .filter(|path| !path.is_empty())
            .collect())
    }

    fn index_differs_from_head(dir: &Path, path: &str) -> bool {
        git::gitlink_sha(dir, path) != git::head_gitlink_sha(dir, path)
    }

    fn resolve_sub_path(
        ws: &Workspace,
        consumer: &crate::config::Project,
        dep: &crate::config::Project,
        consumer_dir: &Path,
    ) -> Option<String> {
        let mut candidates = Vec::new();

        for link in &ws.config.links {
            if link.repo != dep.name {
                continue;
            }
            for alias in consumer_aliases(ws, consumer) {
                if alias == "." {
                    candidates.push(link.at.clone());
                } else if let Some(rest) = link.at.strip_prefix(&format!("{alias}/")) {
                    candidates.push(rest.to_string());
                }
            }
        }

        if consumer.path == "." {
            candidates.push(dep.path.clone());
        } else if let Some(rest) = dep.path.strip_prefix(&format!("{}/", consumer.path)) {
            candidates.push(rest.to_string());
        }
        candidates.push(dep.path.clone());
        if let Some(short) = dep.path.rsplit('/').next() {
            candidates.push(short.to_string());
        }

        let candidates = dedup(candidates);
        candidates
            .iter()
            .find(|candidate| git::gitlink_sha(consumer_dir, candidate).is_some())
            .cloned()
            .or_else(|| candidates.into_iter().next())
    }

    fn physical_link_path<F>(
        ws: &Workspace,
        link: &crate::config::Link,
        links: &[&crate::config::Link],
        provider_dir: &F,
    ) -> PathBuf
    where
        F: Fn(&str) -> Option<PathBuf>,
    {
        links
            .iter()
            .filter(|parent| {
                link.at != parent.at
                    && link
                        .at
                        .strip_prefix(&parent.at)
                        .is_some_and(|suffix| suffix.starts_with('/'))
            })
            .max_by_key(|parent| parent.at.len())
            .and_then(|parent| {
                let suffix = link.at[parent.at.len()..].trim_start_matches('/');
                provider_dir(&parent.repo).map(|dir| dir.join(suffix))
            })
            .unwrap_or_else(|| ws.root.join(&link.at))
    }

    fn owning_repo_for_path(root: &Path, full: &Path) -> Option<(PathBuf, String)> {
        let mut cur = full.parent();
        while let Some(dir) = cur {
            if dir.join(".git").exists() {
                let sub_rel = full
                    .strip_prefix(dir)
                    .ok()
                    .map(|p| p.to_string_lossy().replace('\\', "/"))?;
                return Some((dir.to_path_buf(), sub_rel));
            }
            if dir == root {
                break;
            }
            cur = dir.parent();
        }
        None
    }

    fn consumer_label(ws: &Workspace, dir: &Path) -> String {
        for p in &ws.config.projects {
            if ws.project_dir(p) == dir {
                return p.path.clone();
            }
        }
        let label = dir
            .strip_prefix(&ws.root)
            .unwrap_or(dir)
            .to_string_lossy()
            .replace('\\', "/");
        if label.is_empty() {
            ".".to_string()
        } else {
            label
        }
    }

    fn provider_label(ws: &Workspace, repo: &str) -> String {
        ws.config
            .projects
            .iter()
            .find(|p| p.name == repo)
            .map(|p| p.path.clone())
            .unwrap_or_else(|| repo.to_string())
    }

    fn consumer_aliases(ws: &Workspace, consumer: &crate::config::Project) -> Vec<String> {
        let mut aliases = vec![consumer.path.clone()];
        aliases.extend(
            ws.config
                .links
                .iter()
                .filter(|link| link.repo == consumer.name)
                .map(|link| link.at.clone()),
        );
        dedup(aliases)
    }

    fn dedup(items: Vec<String>) -> Vec<String> {
        let mut seen = BTreeSet::new();
        items
            .into_iter()
            .filter(|item| !item.is_empty() && seen.insert(item.clone()))
            .collect()
    }

    fn short_sha(sha: &str) -> String {
        sha.chars().take(12).collect()
    }
}

pub mod init {
    use super::*;
    use crate::config::Submodules;
    use crate::git;
    use std::collections::BTreeSet;
    use std::path::PathBuf;

    pub fn run(https: bool, ssh: bool) -> Result<()> {
        let cwd = std::env::current_dir()?;
        let cfg_path =
            Config::discover(&cwd).ok_or_else(|| anyhow::anyhow!("no .repoverse.yaml here"))?;
        let root = cfg_path.parent().unwrap().to_path_buf();
        let cfg = Config::load(&cfg_path)?;
        let flag = scheme_flag(https, ssh);
        let mut seen_configs = BTreeSet::new();
        let mut seen_dirs = BTreeSet::new();
        init_config(&root, &cfg, flag, &mut seen_configs, &mut seen_dirs)?;
        super::tasks_cmd::run(super::tasks_cmd::Task::Setup, false, &[])
            .unwrap_or_else(|e| eprintln!("  setup skipped: {e:#}"));
        Ok(())
    }

    fn init_config(
        root: &Path,
        cfg: &Config,
        flag: SchemeFlag,
        seen_configs: &mut BTreeSet<PathBuf>,
        seen_dirs: &mut BTreeSet<PathBuf>,
    ) -> Result<()> {
        let cfg_path = root.join(crate::config::CONFIG_FILE);
        let key = std::fs::canonicalize(&cfg_path).unwrap_or(cfg_path);
        if !seen_configs.insert(key) {
            return Ok(());
        }

        let ui = crate::ui::Multi::new();
        for p in &cfg.projects {
            if p.path == "." {
                continue;
            }
            let dir = root.join(&p.path);
            let dir_key = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
            if !seen_dirs.insert(dir_key) {
                println!("  ok   {}", p.path);
                continue;
            }
            if git::is_repo(&dir) {
                println!("  ok   {}", p.path);
                continue;
            }
            if dir_has_entries(&dir)? {
                println!("  ok   {} (existing)", p.path);
                continue;
            }
            let url = project_url(cfg, p, flag)?;
            let t = ui.task(&p.path, "cloning");
            git::clone(root, &url, &p.path)?;
            git::checkout(&dir, cfg.project_revision(p))?;
            if p.submodules != Submodules::None {
                let mut a = vec!["submodule", "update", "--init", "--recursive"];
                if p.submodules == Submodules::Shallow {
                    a.push("--depth");
                    a.push("1");
                }
                git::check(&dir, &a)?;
            }
            t.done(&format!("clone {}", p.path));
        }

        for p in &cfg.projects {
            if p.path == "." {
                continue;
            }
            let dir = root.join(&p.path);
            let nested = dir.join(crate::config::CONFIG_FILE);
            if nested.is_file() {
                let nested_cfg = Config::load(&nested)?;
                init_config(&dir, &nested_cfg, flag, seen_configs, seen_dirs)?;
            }
        }
        Ok(())
    }

    fn dir_has_entries(dir: &Path) -> Result<bool> {
        if !dir.is_dir() {
            return Ok(false);
        }
        Ok(std::fs::read_dir(dir)?.next().is_some())
    }
}

pub mod fetch {
    use super::*;
    use crate::git;
    use rayon::prelude::*;

    pub fn run(jobs: usize, targets: &[String]) -> Result<()> {
        let ws = Workspace::discover()?;
        if !targets.is_empty() && ws.resolve_targets(targets).is_err() {
            if let Some((label, dir)) = current_git_target()? {
                let mut args = vec![
                    "-c",
                    "submodule.recurse=false",
                    "-c",
                    "fetch.recurseSubmodules=false",
                    "fetch",
                    "--recurse-submodules=no",
                ];
                args.extend(targets.iter().map(String::as_str));
                git::check(&dir, &args)?;
                println!("  fetched {}", label);
                return Ok(());
            }
        }
        let projects = current_or_workspace_targets(&ws, targets)?;
        let pool = rayon::ThreadPoolBuilder::new()
            .num_threads(jobs.max(1))
            .build()?;
        let ui = crate::ui::Multi::new();
        let failed = std::sync::atomic::AtomicBool::new(false);
        pool.install(|| {
            projects.par_iter().for_each(|(label, dir)| {
                if git::is_repo(dir) {
                    let t = ui.task(label, "fetching");
                    match git::fetch(dir) {
                        Ok(()) => t.done(&format!("fetched {label}")),
                        Err(e) => {
                            failed.store(true, std::sync::atomic::Ordering::Relaxed);
                            t.fail(&format!("FAIL {label} {e:#}"));
                        }
                    }
                }
            });
        });
        if failed.load(std::sync::atomic::Ordering::Relaxed) {
            bail!("fetch failed for one or more repos");
        }
        Ok(())
    }
}

pub mod upstream {
    use super::*;
    use crate::git;
    use rayon::prelude::*;

    pub fn fetch(jobs: usize, targets: &[String]) -> Result<()> {
        let ws = Workspace::discover()?;
        let projects = ws.resolve_targets(targets)?;
        let pool = rayon::ThreadPoolBuilder::new()
            .num_threads(jobs.max(1))
            .build()?;
        let ui = crate::ui::Multi::new();
        let failed = std::sync::atomic::AtomicBool::new(false);
        let mark_failed = || failed.store(true, std::sync::atomic::Ordering::Relaxed);
        pool.install(|| {
            projects.par_iter().for_each(|p| {
                let dir = ws.project_dir(p);
                if !git::is_repo(&dir) {
                    return;
                }
                let Some(upstream) = p.upstream.as_ref() else {
                    return;
                };
                let t = ui.task(&p.path, "fetching upstream");
                let remote_name = upstream.remote.as_deref().unwrap_or_else(|| {
                    if ws.config.remotes.contains_key("upstream") {
                        "upstream"
                    } else {
                        &ws.config.defaults.remote
                    }
                });
                let Some(remote) = ws.config.remotes.get(remote_name) else {
                    mark_failed();
                    t.fail(&format!(
                        "FAIL {} unknown upstream remote `{remote_name}`",
                        p.path
                    ));
                    return;
                };
                let repo = upstream.name.as_deref().unwrap_or(&p.name);
                let revision = upstream
                    .revision
                    .as_deref()
                    .or(p.revision.as_deref())
                    .unwrap_or(&ws.config.defaults.revision);
                let resolved = remote::resolve(
                    SchemeFlag::Unset,
                    ws.config.defaults.scheme,
                    &remote::SystemEnv,
                );
                let url = remote::url_for(&remote.host, repo, &resolved);

                let remote_exists = git::run(&dir, &["remote", "get-url", remote_name])
                    .map(|out| out.ok())
                    .unwrap_or(false);
                let remote_result = if remote_exists {
                    git::check(&dir, &["remote", "set-url", remote_name, &url]).map(|_| ())
                } else {
                    git::check(&dir, &["remote", "add", remote_name, &url]).map(|_| ())
                };
                if let Err(e) = remote_result {
                    mark_failed();
                    t.fail(&format!("FAIL {} set {remote_name}: {e:#}", p.path));
                    return;
                }

                match git::check(
                    &dir,
                    &[
                        "-c",
                        "submodule.recurse=false",
                        "-c",
                        "fetch.recurseSubmodules=false",
                        "fetch",
                        remote_name,
                        revision,
                        "--recurse-submodules=no",
                    ],
                ) {
                    Ok(_) => t.done(&format!("fetched {} {remote_name}/{revision}", p.path)),
                    Err(e) => {
                        mark_failed();
                        t.fail(&format!(
                            "FAIL {} fetch {remote_name}/{revision}: {e:#}",
                            p.path
                        ));
                    }
                }
            });
        });
        if failed.load(std::sync::atomic::Ordering::Relaxed) {
            bail!("upstream fetch failed for one or more repos");
        }
        Ok(())
    }
}

pub mod history {
    use super::*;
    use crate::git;

    #[derive(Clone, Copy)]
    pub enum Action {
        Merge,
        Rebase,
    }

    impl Action {
        fn verb(self) -> &'static str {
            match self {
                Action::Merge => "merge",
                Action::Rebase => "rebase",
            }
        }
    }

    pub fn run(action: Action, args: &[String]) -> Result<()> {
        let ws = Workspace::discover()?;
        let (git_args, target_args) = split_git_args_and_targets(&ws, args);
        if git_args.is_empty() {
            bail!("rv {} requires git arguments", action.verb());
        }
        let targets = current_or_workspace_targets(&ws, &target_args)?;
        if targets.is_empty() {
            bail!("no targets selected");
        }

        ensure_no_unlinked_marker(&ws)?;
        super::adopt::detach(None, action.verb())?;
        let mut failed = false;
        for (label, dir) in targets {
            if !git::is_repo(&dir) {
                eprintln!("  skip {} (missing)", label);
                continue;
            }
            if let Err(e) = git::fetch(&dir) {
                failed = true;
                eprintln!("  FAIL fetch {} {e:#}", label);
                continue;
            }
            let resolved_args = resolve_revisions(&dir, &git_args);
            let mut cmd_args = match action {
                Action::Merge => vec![
                    "-c",
                    "submodule.recurse=false",
                    "merge",
                    "--no-recurse-submodules",
                ],
                Action::Rebase => vec!["-c", "submodule.recurse=false", "rebase"],
            };
            cmd_args.extend(resolved_args.iter().map(String::as_str));
            match git::check(&dir, &cmd_args) {
                Ok(_) => println!("  {} {}", action.verb(), label),
                Err(e) => {
                    failed = true;
                    eprintln!("  FAIL {} {} — {e:#}", action.verb(), label);
                }
            }
        }

        if failed {
            bail!(
                "{} failed in one or more repos; leaving tree as-is for conflict/error inspection",
                action.verb()
            );
        }
        super::adopt::relink()?;
        Ok(())
    }

    fn resolve_revisions(dir: &Path, args: &[String]) -> Vec<String> {
        args.iter().map(|arg| resolve_revision(dir, arg)).collect()
    }

    fn resolve_revision(dir: &Path, rev: &str) -> String {
        if rev.starts_with('-') {
            return rev.to_string();
        }
        if rev.contains('/') || rev == "HEAD" {
            return rev.to_string();
        }
        let origin = format!("origin/{rev}");
        if git::run(dir, &["rev-parse", "--verify", &origin])
            .map(|o| o.ok())
            .unwrap_or(false)
        {
            origin
        } else {
            rev.to_string()
        }
    }
}

pub mod git_run {
    use super::*;
    use crate::git;

    /// `rv git -- <args> [targets...]`: arbitrary git command per selected
    /// repo with overlays detached around it. Makes plain-git muscle memory
    /// (stash, cherry-pick, revert, reset, log, ...) safe in an overlaid
    /// workspace without a wrapper per command.
    pub fn run(args: &[String]) -> Result<()> {
        let ws = Workspace::discover()?;
        let (git_args, target_args) = split_git_args_and_targets(&ws, args);
        if git_args.is_empty() {
            bail!("rv git requires git arguments, e.g. `rv git -- stash list`");
        }
        let targets = current_or_workspace_targets(&ws, &target_args)?;
        if targets.is_empty() {
            bail!("no targets selected");
        }

        ensure_no_unlinked_marker(&ws)?;
        super::adopt::detach(None, "git")?;
        let mut failed = false;
        for (label, dir) in targets {
            if !git::is_repo(&dir) {
                eprintln!("  skip {} (missing)", label);
                continue;
            }
            let arg_refs: Vec<&str> = git_args.iter().map(String::as_str).collect();
            match git::run(&dir, &arg_refs) {
                Ok(o) => {
                    println!("== {label}");
                    if !o.stdout.is_empty() {
                        print!("{}", o.stdout);
                    }
                    if !o.stderr.is_empty() {
                        eprint!("{}", o.stderr);
                    }
                    if !o.ok() {
                        failed = true;
                        eprintln!("  FAIL {} — git exited {}", label, o.status);
                    }
                }
                Err(e) => {
                    failed = true;
                    eprintln!("  FAIL {} — {e:#}", label);
                }
            }
        }
        if failed {
            bail!("git command failed in one or more repos; leaving tree as-is for inspection");
        }
        super::adopt::relink()?;
        Ok(())
    }
}

pub mod pull {
    use super::*;
    use crate::git;

    pub fn run(rebase: bool, targets: &[String]) -> Result<()> {
        let ws = Workspace::discover()?;
        ensure_no_unlinked_marker(&ws)?;
        super::adopt::detach(None, if rebase { "pull --rebase" } else { "pull" })?;
        let ui = crate::ui::Multi::new();
        let mut failed = false;
        for (label, dir) in current_or_workspace_targets(&ws, targets)? {
            if !git::is_repo(&dir) {
                continue;
            }
            let mut a = vec![
                "-c",
                "submodule.recurse=false",
                "pull",
                "--recurse-submodules=no",
            ];
            if rebase {
                a.push("--rebase");
            }
            let t = ui.task(&label, "pulling");
            match git::check(&dir, &a) {
                Ok(_) => t.done(&format!("pulled {label}")),
                Err(e) => {
                    failed = true;
                    t.fail(&format!("FAIL {label} {e:#}"));
                }
            }
        }
        if failed {
            bail!("pull failed in one or more repos; leaving tree as-is for conflict/error inspection");
        }
        super::adopt::relink()?;
        Ok(())
    }
}

pub mod sync {
    use super::*;
    use crate::git;

    pub fn run(force: bool) -> Result<()> {
        let ws = Workspace::discover()?;
        let lock = ws.lock()?;
        eprintln!(
            "note: rv sync checks out .repoverse.lock SHAs and may detach HEAD; \
use `rv branch-sync --pull` to checkout configured .repoverse.yaml branches."
        );
        ensure_no_unlinked_marker(&ws)?;
        super::adopt::detach(None, "sync")?;
        let mut failed = false;
        for p in &ws.config.projects {
            let dir = ws.project_dir(p);
            if !git::is_repo(&dir) {
                eprintln!("  skip {} (missing — run `rv init`)", p.path);
                continue;
            }
            if force {
                if let Err(e) = git::check(&dir, &["reset", "--hard"]) {
                    failed = true;
                    eprintln!("  FAIL reset {} — {e:#}", p.path);
                    continue;
                }
                if let Err(e) = git::check(&dir, &["clean", "-fd"]) {
                    failed = true;
                    eprintln!("  FAIL clean {} — {e:#}", p.path);
                    continue;
                }
            } else if git::is_dirty(&dir)? {
                failed = true;
                eprintln!("  skip {} (dirty — use --force)", p.path);
                continue;
            }
            let target = lock
                .sha_for(&p.path)
                .map(|s| s.to_string())
                .unwrap_or_else(|| ws.config.project_revision(p).to_string());
            match git::checkout(&dir, &target) {
                Ok(()) => println!("  synced {} -> {}", p.path, &target[..target.len().min(12)]),
                Err(e) => {
                    failed = true;
                    eprintln!("  FAIL sync {} — {e:#}", p.path);
                }
            }
        }
        if failed {
            bail!("sync incomplete (failed or dirty repos skipped); leaving tree as-is for inspection");
        }
        super::adopt::relink()?;
        Ok(())
    }
}

pub mod branch_sync {
    use super::*;
    use crate::git;
    use std::collections::{BTreeMap, BTreeSet};

    pub fn run(pull: bool, targets: &[String]) -> Result<()> {
        let ws = Workspace::discover()?;
        ensure_no_unlinked_marker(&ws)?;
        let projects: Vec<Project> = if targets.is_empty() {
            ws.config.projects.clone()
        } else {
            ws.resolve_targets(targets)?.into_iter().cloned().collect()
        };
        let dirty_before = projects
            .iter()
            .filter_map(|p| {
                let dir = ws.project_dir(p);
                git::is_repo(&dir).then(|| git::is_dirty(&dir).map(|dirty| (p.path.clone(), dirty)))
            })
            .collect::<Result<BTreeMap<_, _>>>()?;

        #[cfg(unix)]
        super::adopt::detach(None, "branch-sync")?;
        let mut failed = false;
        let mut skipped_dirty = BTreeSet::new();
        let mut summary = Vec::new();
        for p in &projects {
            let dir = ws.project_dir(p);
            if !git::is_repo(&dir) {
                eprintln!("  skip {} (missing — run `rv init`)", p.path);
                continue;
            }
            if dirty_before.get(&p.path).copied().unwrap_or(false) {
                skipped_dirty.insert(p.path.clone());
                eprintln!("  skip {} (dirty before overlay detach)", p.path);
                if let Ok(current) = git::current_branch(&dir) {
                    summary.push((p.path.clone(), current));
                }
                continue;
            }
            let branch = ws.config.project_branch_sync_revision(p).to_string();
            match checkout_configured_branch(&dir, &branch) {
                Ok(()) => {
                    if pull {
                        match git::check(
                            &dir,
                            &[
                                "-c",
                                "submodule.recurse=false",
                                "pull",
                                "--recurse-submodules=no",
                                "--ff-only",
                            ],
                        ) {
                            Ok(_) => println!("  {} -> {} (pulled)", p.path, branch),
                            Err(e) => {
                                failed = true;
                                eprintln!("  FAIL pull {} — {e:#}", p.path);
                            }
                        }
                    } else {
                        println!("  {} -> {}", p.path, branch);
                    }
                }
                Err(e) => {
                    failed = true;
                    eprintln!("  FAIL branch-sync {} -> {} — {e:#}", p.path, branch);
                }
            }
            if let Ok(current) = git::current_branch(&dir) {
                summary.push((p.path.clone(), current));
            }
        }

        #[cfg(unix)]
        {
            if let Err(e) = super::adopt::relink() {
                failed = true;
                eprintln!("  FAIL relink overlays — {e:#}");
                eprintln!("  recovery: rv link");
            }
        }

        println!("branch summary");
        for (path, branch) in summary {
            let suffix = if skipped_dirty.contains(&path) {
                " (skipped dirty)"
            } else {
                ""
            };
            println!("  {path} [{branch}]{suffix}");
        }
        super::doctor::run(false)?;
        if failed {
            bail!("branch-sync failed");
        }
        Ok(())
    }

    fn checkout_configured_branch(dir: &Path, branch: &str) -> Result<()> {
        if git::branch_exists(dir, branch) {
            return git::checkout(dir, branch);
        }
        git::fetch(dir)?;
        let remote_ref = format!("refs/remotes/origin/{branch}");
        if git::run(dir, &["show-ref", "--verify", "--quiet", &remote_ref])
            .map(|o| o.ok())
            .unwrap_or(false)
        {
            let upstream = format!("origin/{branch}");
            git::check(dir, &["checkout", "-B", branch, &upstream])?;
            return Ok(());
        }
        git::checkout(dir, branch)
    }
}

pub mod pin {
    use super::*;
    use crate::git;
    use crate::lock::{Lock, LockedProject};

    pub fn run() -> Result<()> {
        let ws = Workspace::discover()?;
        let mut lock = Lock::new();
        for p in &ws.config.projects {
            let dir = ws.project_dir(p);
            if !git::is_repo(&dir) {
                continue;
            }
            lock.projects.push(LockedProject {
                path: p.path.clone(),
                name: p.name.clone(),
                revision: ws.config.project_revision(p).to_string(),
                sha: git::head_sha(&dir)?,
            });
        }
        lock.save(&ws.lock_path)?;
        println!(
            "pinned {} projects -> {}",
            lock.projects.len(),
            ws.lock_path.display()
        );
        Ok(())
    }
}

pub mod self_update {
    use super::*;
    use std::path::{Path, PathBuf};
    use std::process::{Command, Stdio};

    #[cfg(not(windows))]
    const INSTALL_SH_URL: &str =
        "https://raw.githubusercontent.com/madhavajay/repoverse/main/install.sh";
    #[cfg(windows)]
    const INSTALL_PS1_URL: &str =
        "https://raw.githubusercontent.com/madhavajay/repoverse/main/install.ps1";

    pub fn run() -> Result<()> {
        let exe = std::env::current_exe()?;
        if is_cargo_installed_exe(&exe) && command_exists("cargo") {
            println!("updating rv with cargo install repoverse --bin rv");
            run_status(Command::new("cargo").args(["install", "repoverse", "--bin", "rv"]))
        } else {
            println!("updating rv with the release installer");
            run_installer()
        }
    }

    fn run_installer() -> Result<()> {
        #[cfg(windows)]
        {
            let script = format!("irm {INSTALL_PS1_URL} | iex");
            run_status(Command::new("powershell").args([
                "-NoProfile",
                "-ExecutionPolicy",
                "Bypass",
                "-Command",
                &script,
            ]))
        }

        #[cfg(not(windows))]
        {
            let script = format!("curl -fsSL {INSTALL_SH_URL} | sh");
            run_status(Command::new("sh").args(["-c", &script]))
        }
    }

    fn run_status(cmd: &mut Command) -> Result<()> {
        let program = cmd.get_program().to_string_lossy().into_owned();
        let status = cmd
            .stdin(Stdio::inherit())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .status()?;
        if !status.success() {
            bail!("{program} failed with {status}");
        }
        Ok(())
    }

    fn command_exists(name: &str) -> bool {
        Command::new(name)
            .arg("--version")
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .map(|status| status.success())
            .unwrap_or(false)
    }

    fn is_cargo_installed_exe(exe: &Path) -> bool {
        let Some(cargo_bin) = cargo_bin_dir() else {
            return false;
        };
        is_cargo_installed_exe_in(exe, &cargo_bin)
    }

    fn is_cargo_installed_exe_in(exe: &Path, cargo_bin: &Path) -> bool {
        let name = exe.file_name().and_then(|s| s.to_str()).unwrap_or_default();
        if !matches!(name, "rv" | "rv.exe") {
            return false;
        }
        same_path(exe, &cargo_bin.join(name))
    }

    fn cargo_bin_dir() -> Option<PathBuf> {
        if let Some(home) = std::env::var_os("CARGO_HOME") {
            return Some(PathBuf::from(home).join("bin"));
        }
        home_dir().map(|home| home.join(".cargo").join("bin"))
    }

    fn home_dir() -> Option<PathBuf> {
        #[cfg(windows)]
        {
            std::env::var_os("USERPROFILE").map(PathBuf::from)
        }
        #[cfg(not(windows))]
        {
            std::env::var_os("HOME").map(PathBuf::from)
        }
    }

    fn same_path(left: &Path, right: &Path) -> bool {
        let left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
        let right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
        left == right
    }

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

        #[test]
        fn cargo_install_detection_requires_cargo_bin_rv() {
            let cargo_bin = Path::new("/tmp/rv-cargo-home/bin");
            assert!(is_cargo_installed_exe_in(
                Path::new("/tmp/rv-cargo-home/bin/rv"),
                cargo_bin
            ));
            assert!(!is_cargo_installed_exe_in(
                Path::new("/tmp/rv-cargo-home/bin/other"),
                cargo_bin
            ));
            assert!(!is_cargo_installed_exe_in(
                Path::new("/tmp/somewhere/rv"),
                cargo_bin
            ));
        }
    }
}

pub mod lock_update {
    use super::*;
    use crate::git;
    use crate::lock::{Lock, LockedProject};

    pub fn run() -> Result<()> {
        let ws = Workspace::discover()?;
        let mut lock = Lock::new();
        for p in &ws.config.projects {
            let dir = ws.project_dir(p);
            if !git::is_repo(&dir) {
                continue;
            }
            git::fetch(&dir)?;
            let rev = ws.config.project_revision(p);
            let sha = git::check(&dir, &["rev-parse", &format!("origin/{rev}")])
                .or_else(|_| git::check(&dir, &["rev-parse", rev]))?
                .trim()
                .to_string();
            lock.projects.push(LockedProject {
                path: p.path.clone(),
                name: p.name.clone(),
                revision: rev.to_string(),
                sha,
            });
        }
        lock.save(&ws.lock_path)?;
        println!("updated lock to branch tips");
        Ok(())
    }
}

pub mod branch {
    use super::*;
    use crate::git;
    use std::collections::BTreeSet;

    pub fn run(name: &str, all: bool, write_config: bool, targets: &[String]) -> Result<()> {
        let ws = Workspace::discover()?;
        let mut cfg = ws.config.clone();
        let candidates = if targets.is_empty() {
            cfg.projects.clone()
        } else {
            ws.resolve_targets(targets)?.into_iter().cloned().collect()
        };
        let mut touched = Vec::new();
        for p in &candidates {
            let dir = ws.project_dir(p);
            if !git::is_repo(&dir) {
                continue;
            }
            if all || git::is_dirty(&dir)? {
                git::checkout_new_branch(&dir, name)?;
                println!("  {} -> {name}", p.path);
                touched.push(p.path.clone());
            }
        }
        if touched.is_empty() {
            println!("no dirty repos; nothing to branch");
            return Ok(());
        }
        if write_config {
            let touched_set: BTreeSet<_> = touched.iter().cloned().collect();
            let mut repos = BTreeSet::new();
            let mut changed = false;
            for p in &mut cfg.projects {
                if touched_set.contains(&p.path) {
                    if p.revision.as_deref() != Some(name) {
                        p.revision = Some(name.to_string());
                        changed = true;
                    }
                    repos.insert(p.name.clone());
                }
            }
            for link in &mut cfg.links {
                if repos.contains(&link.repo) && link.branch.as_deref() != Some(name) {
                    link.branch = Some(name.to_string());
                    changed = true;
                }
            }
            if changed {
                let cfg_path = ws.root.join(crate::config::CONFIG_FILE);
                std::fs::write(&cfg_path, serde_yaml::to_string(&cfg)?)?;
                println!(
                    "  updated {} revision/link branches",
                    crate::config::CONFIG_FILE
                );
            } else {
                println!("  {} already matched {name}", crate::config::CONFIG_FILE);
            }
        }
        Ok(())
    }
}

pub mod checkout {
    use super::*;
    use crate::git;

    pub fn run(rev: &str, targets: &[String], reset: bool) -> Result<()> {
        let ws = Workspace::discover()?;
        ensure_no_unlinked_marker(&ws)?;
        super::adopt::detach(None, "checkout")?;
        let mut failed = false;
        for (label, dir) in current_or_workspace_targets(&ws, targets)? {
            if !git::is_repo(&dir) {
                continue;
            }
            if reset {
                if let Err(e) = git::check(&dir, &["reset", "--hard"]) {
                    failed = true;
                    eprintln!("  FAIL reset {} — {e:#}", label);
                    continue;
                }
            }
            match git::checkout(&dir, rev) {
                Ok(()) => println!("  {} -> {rev}", label),
                Err(e) => {
                    failed = true;
                    eprintln!("  FAIL checkout {} — {e:#}", label);
                }
            }
        }
        if failed {
            bail!("checkout failed in one or more repos; leaving tree as-is for conflict/error inspection");
        }
        super::adopt::relink()?;
        Ok(())
    }
}

pub mod switch {
    use super::*;
    use crate::git;

    pub fn run(create: bool, branch: &str, targets: &[String]) -> Result<()> {
        let ws = Workspace::discover()?;
        ensure_no_unlinked_marker(&ws)?;
        super::adopt::detach(None, "switch")?;
        let mut failed = false;
        for p in ws.resolve_targets(targets)? {
            let dir = ws.project_dir(p);
            if !git::is_repo(&dir) {
                continue;
            }
            let result = if create {
                git::checkout_new_branch(&dir, branch)
            } else {
                git::checkout(&dir, branch)
            };
            match result {
                Ok(()) => println!("  {} -> {branch}", p.path),
                Err(e) => {
                    failed = true;
                    eprintln!("  FAIL switch {} — {e:#}", p.path);
                }
            }
        }
        if failed {
            bail!("switch failed in one or more repos; leaving tree as-is for conflict/error inspection");
        }
        super::adopt::relink()?;
        Ok(())
    }
}

pub mod remote_cmd {
    use super::*;
    use crate::git;

    pub fn run(scheme: &str) -> Result<()> {
        let ws = Workspace::discover()?;
        let flag = if scheme == "https" {
            SchemeFlag::Https
        } else {
            SchemeFlag::Ssh
        };
        for p in &ws.config.projects {
            let dir = ws.project_dir(p);
            if !git::is_repo(&dir) {
                continue;
            }
            let url = project_url(&ws.config, p, flag)?;
            git::check(&dir, &["remote", "set-url", "origin", &url])?;
            println!("  {} -> {scheme}", p.path);
        }
        Ok(())
    }
}

pub mod commit {
    use super::*;
    use crate::git;

    pub fn run(message: &str, targets: &[String]) -> Result<()> {
        let ws = Workspace::discover()?;
        let ui = crate::ui::Multi::new();
        let mut n = 0;
        for p in ws.resolve_targets(targets)? {
            let dir = ws.project_dir(p);
            if git::is_repo(&dir) && git::is_dirty(&dir)? {
                let t = ui.task(&p.path, "committing");
                git::check(&dir, &["add", "-A"])?;
                git::check(&dir, &["commit", "-m", message])?;
                t.done(&format!("committed {}", p.path));
                n += 1;
            }
        }
        ui.println(&format!("{n} repo(s) committed"));
        Ok(())
    }
}

pub mod push {
    use super::*;
    use crate::git;

    pub fn run() -> Result<()> {
        let ws = Workspace::discover()?;
        let ui = crate::ui::Multi::new();
        let mut failed = false;
        for p in &ws.config.projects {
            let dir = ws.project_dir(p);
            if !git::is_repo(&dir) {
                continue;
            }
            let br = git::current_branch(&dir)?;
            let t = ui.task(&p.path, "pushing");
            match git::check(&dir, &["push", "-u", "origin", &br]) {
                Ok(_) => t.done(&format!("pushed {} ({br})", p.path)),
                Err(e) => {
                    failed = true;
                    t.fail(&format!("FAIL {} {e:#}", p.path));
                }
            }
        }
        if failed {
            bail!("push failed for one or more repos");
        }
        Ok(())
    }
}

pub mod tasks_cmd {
    use super::*;
    use crate::git;
    use crate::tasks::{self, Kind};
    use std::process::Command;

    #[derive(Clone, Copy)]
    pub enum Task {
        Setup,
        Lint,
        Test,
    }

    impl Task {
        fn kind(self) -> Kind {
            match self {
                Task::Setup => Kind::Setup,
                Task::Lint => Kind::Lint,
                Task::Test => Kind::Test,
            }
        }
    }

    fn root_task_configured(ws: &Workspace, kind: Kind) -> bool {
        match kind {
            Kind::Setup => ws.config.setup.is_some(),
            Kind::Lint => ws.config.lint.is_some(),
            Kind::Test => ws.config.test.is_some(),
        }
    }

    fn task_projects(ws: &Workspace, targets: &[String], kind: Kind) -> Result<Vec<Project>> {
        let wants_all = targets.is_empty() || targets == ["all"];
        let wants_root = targets == ["."];
        let mut projects: Vec<Project> =
            if wants_root && !ws.config.projects.iter().any(|p| p.path == ".") {
                Vec::new()
            } else {
                ws.resolve_targets(targets)?.into_iter().cloned().collect()
            };

        if (wants_all || wants_root)
            && root_task_configured(ws, kind)
            && !projects.iter().any(|p| p.path == ".")
        {
            projects.insert(
                0,
                Project {
                    name: ws
                        .root
                        .file_name()
                        .map(|s| s.to_string_lossy().to_string())
                        .unwrap_or_else(|| ".".to_string()),
                    path: ".".to_string(),
                    main_branch: None,
                    revision: None,
                    remote: None,
                    upstream: None,
                    submodules: Default::default(),
                    consumes: vec![],
                    ci: None,
                    setup: None,
                    lint: None,
                    test: None,
                    shared: vec![],
                },
            );
        }
        Ok(projects)
    }

    /// Run a task script with output captured: success is quiet, failure
    /// replays the full combined output. Capturing also keeps spinner
    /// redraws clean while scripts run.
    fn run_script(dir: &Path, script: &str) -> Result<(bool, String)> {
        let out = Command::new("bash")
            .arg("-eo")
            .arg("pipefail")
            .arg("-c")
            .arg(script)
            .current_dir(dir)
            .output()?;
        let mut log = String::from_utf8_lossy(&out.stdout).into_owned();
        log.push_str(&String::from_utf8_lossy(&out.stderr));
        Ok((out.status.success(), log))
    }

    pub fn run(task: Task, force: bool, targets: &[String]) -> Result<()> {
        let ws = Workspace::discover()?;
        let kind = task.kind();
        let verb = match task {
            Task::Setup => "setting up",
            Task::Lint => "linting",
            Task::Test => "testing",
        };
        let ui = crate::ui::Multi::new();
        let mut failures = 0;
        for p in task_projects(&ws, targets, kind)? {
            let dir = ws.project_dir(&p);
            if !git::is_repo(&dir) {
                continue;
            }
            // setup always runs; lint/test only in dirty repos unless --force
            let dirty = git::is_dirty(&dir).unwrap_or(false);
            if !matches!(task, Task::Setup) && !force && !dirty {
                continue;
            }
            let t = ui.task(&p.path, verb);
            match tasks::resolve_with_config(Some(&ws.config), &p, &dir, kind)? {
                None => t.skip(&format!(
                    "skip {} ({} — no command resolved)",
                    p.path,
                    kind.as_str()
                )),
                Some(r) => {
                    for c in &r.caveats {
                        ui.eprintln(&format!("  note {} {}", p.path, c));
                    }
                    match run_script(&dir, &r.script) {
                        Ok((true, _)) => {
                            t.done(&format!("ok   {} {} [{}]", p.path, kind.as_str(), r.source))
                        }
                        Ok((false, log)) => {
                            t.fail(&format!("FAIL {} {}", p.path, kind.as_str()));
                            for line in log.lines() {
                                ui.eprintln(&format!("    {line}"));
                            }
                            failures += 1;
                        }
                        Err(e) => {
                            t.fail(&format!("FAIL {} {} — {e:#}", p.path, kind.as_str()));
                            failures += 1;
                        }
                    }
                }
            }
        }
        if failures > 0 {
            bail!("{failures} {} failure(s)", kind.as_str());
        }
        Ok(())
    }
}

pub mod check {
    use super::*;

    pub fn run(json: bool, all: bool, targets: &[String]) -> Result<()> {
        let force = all;
        let lint = super::tasks_cmd::run(super::tasks_cmd::Task::Lint, force, targets);
        let test = super::tasks_cmd::run(super::tasks_cmd::Task::Test, force, targets);
        if json {
            println!(
                "{}",
                serde_json::json!({
                    "lint_ok": lint.is_ok(),
                    "test_ok": test.is_ok(),
                })
            );
        }
        if lint.is_err() || test.is_err() {
            bail!("check failed");
        }
        println!("check: all green");
        Ok(())
    }
}

pub mod import {
    use super::*;

    /// Lightweight attribute scan of a Google-repo manifest.xml.
    fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
        let pat = format!("{key}=\"");
        let i = tag.find(&pat)? + pat.len();
        let rest = &tag[i..];
        let j = rest.find('"')?;
        Some(&rest[..j])
    }

    pub fn run(manifest: &str) -> Result<()> {
        let text = std::fs::read_to_string(manifest)?;
        let mut default_rev = "main".to_string();
        let mut host = "github.com".to_string();
        let mut projects = Vec::new();
        for raw in text.split('<') {
            let tag = raw.trim();
            if let Some(t) = tag.strip_prefix("default ") {
                if let Some(r) = attr(t, "revision") {
                    default_rev = r.to_string();
                }
            } else if let Some(t) = tag.strip_prefix("remote ") {
                if let Some(f) = attr(t, "fetch") {
                    host = f
                        .trim_start_matches("ssh://")
                        .trim_start_matches("https://")
                        .trim_start_matches("git@")
                        .trim_end_matches('/')
                        .to_string();
                }
            } else if let Some(t) = tag.strip_prefix("project ") {
                let name = attr(t, "name").unwrap_or_default().to_string();
                let path = attr(t, "path").unwrap_or(&name).to_string();
                let rev = attr(t, "revision").map(|s| s.to_string());
                projects.push(Project {
                    name,
                    path,
                    main_branch: None,
                    revision: rev.filter(|r| r != &default_rev),
                    remote: None,
                    upstream: None,
                    submodules: Default::default(),
                    consumes: vec![],
                    ci: None,
                    setup: None,
                    lint: None,
                    test: None,
                    shared: vec![],
                });
            }
        }
        let mut remotes = std::collections::BTreeMap::new();
        remotes.insert("github".to_string(), crate::config::Remote { host });
        let cfg = Config {
            version: 1,
            defaults: crate::config::Defaults {
                remote: "github".into(),
                main_branch: None,
                revision: default_rev,
                scheme: Scheme::Ssh,
            },
            remotes,
            setup: None,
            lint: None,
            test: None,
            projects,
            provides: vec![],
            links: vec![],
        };
        cfg.validate()?;
        let out = std::path::Path::new(crate::config::CONFIG_FILE);
        std::fs::write(out, serde_yaml::to_string(&cfg)?)?;
        println!(
            "wrote {} ({} projects) from {manifest}",
            out.display(),
            cfg.projects.len()
        );
        Ok(())
    }
}

pub mod adopt {
    use super::*;
    use crate::config::{Defaults, Remote};
    use std::collections::BTreeMap;

    /// One occurrence of a shared dep: the consumer superproject, the
    /// submodule path within it, and the pinned gitlink SHA.
    #[cfg(unix)]
    struct Occurrence {
        consumer: std::path::PathBuf,
        sub_rel: String,
        ws_rel: String,
        sha: String,
        url: String,
        sub_name: String,
        /// .gitmodules `branch=` label declared by this consumer, if any
        branch: Option<String>,
    }

    #[cfg(unix)]
    fn occurrences(root: &Path, ws_paths: &[String]) -> Result<Vec<Occurrence>> {
        let mut out = Vec::new();
        for wsp in ws_paths {
            let dir = root.join(wsp);
            if !crate::git::is_repo(&dir) {
                continue;
            }
            let sup = crate::git::run(&dir, &["rev-parse", "--show-superproject-working-tree"])?;
            if !sup.ok() || sup.stdout.trim().is_empty() {
                bail!("{wsp}: no superproject (not a submodule?)");
            }
            // `--show-superproject-working-tree` returns a real path; the
            // consumer may be reached through an already-lifted symlink, so
            // canonicalize both sides before deriving the in-repo path.
            let consumer = std::fs::canonicalize(sup.stdout.trim())
                .unwrap_or_else(|_| std::path::PathBuf::from(sup.stdout.trim()));
            let dir_canon = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
            let sub_rel = match dir_canon.strip_prefix(&consumer) {
                Ok(p) => p.to_string_lossy().replace('\\', "/"),
                Err(_) => bail!(
                    "{wsp}: cannot locate inside superproject {} \
(refusing to guess — no changes made)",
                    consumer.display()
                ),
            };
            // Prefer the actually checked-out HEAD (what the user has and
            // wants lifted) over the superproject's recorded gitlink — two
            // copies on the same branch must not look "divergent" just
            // because their parents pin different SHAs (the lift re-stamps
            // those gitlinks anyway).
            let sha = crate::git::head_sha(&dir)
                .ok()
                .or_else(|| crate::git::gitlink_sha(&consumer, &sub_rel))
                .ok_or_else(|| anyhow::anyhow!("{wsp}: cannot resolve sha"))?;
            let url = crate::git::run(&dir, &["remote", "get-url", "origin"])?
                .stdout
                .trim()
                .to_string();
            // submodule name from the consumer's .gitmodules (name may != path)
            let gm = std::fs::read_to_string(consumer.join(".gitmodules")).unwrap_or_default();
            let entry = crate::submodule::parse(&gm)
                .into_iter()
                .find(|e| e.path == sub_rel);
            let sub_name = entry
                .as_ref()
                .map(|e| e.name.clone())
                .unwrap_or_else(|| sub_rel.clone());
            let branch = entry.and_then(|e| e.branch);
            out.push(Occurrence {
                consumer,
                sub_rel,
                ws_rel: wsp.clone(),
                sha,
                url,
                sub_name,
                branch,
            });
        }
        Ok(out)
    }

    /// `rv adopt --step <repo>`: lift one repo to a single shared copy.
    /// Dry-run unless `yes`.
    #[cfg_attr(not(unix), allow(unused_variables))]
    pub fn step(
        repo: &str,
        into: Option<&str>,
        normalize_branch: Option<&str>,
        standalone: bool,
        yes: bool,
    ) -> Result<()> {
        #[cfg(not(unix))]
        {
            bail!(
                "`adopt --step` symlink overlay is unix-only here; on Windows run it in CI via rv"
            );
        }
        #[cfg(unix)]
        {
            let root = std::env::current_dir()?;
            if !root.join(".gitmodules").is_file() {
                bail!("run from the workspace root (no .gitmodules here)");
            }
            let root_id = git_origin_name(&root)
                .map(|n| crate::submodule::normalize_url(&n))
                .unwrap_or_else(|| "workspace/root".into());
            let plan = crate::submodule::scan(&root, &root_id);
            let node = plan
                .order
                .iter()
                .find(|n| n.id == repo || n.id.ends_with(&format!("/{repo}")))
                .ok_or_else(|| {
                    anyhow::anyhow!("`{repo}` not found in scan; try `rv adopt --plan`")
                })?;

            let occ = occurrences(&root, &node.paths)?;
            if occ.is_empty() {
                bail!("no checked-out copies of `{}` found", node.id);
            }
            // The real invariant is gitlink-SHA equality. The .gitmodules
            // `branch=` label can differ harmlessly (it's only used by
            // `submodule update --remote`); warn, don't block.
            let sha0 = occ[0].sha.clone();
            if let Some(bad) = occ.iter().find(|o| o.sha != sha0) {
                bail!(
                    "copies are on different commits ({} vs {}). Converge to one \
SHA before lifting (see noodles procedure).",
                    &sha0[..12.min(sha0.len())],
                    &bad.sha[..12.min(bad.sha.len())]
                );
            }
            if !node.branch_conflicts.is_empty() {
                println!(
                    "  note: .gitmodules branch labels differ ({}) but all copies \
are on the same commit {} — safe to lift.",
                    node.branch_conflicts.join(", "),
                    &sha0[..12.min(sha0.len())]
                );
            }
            for o in &occ {
                if crate::git::is_dirty(&root.join(&o.ws_rel)).unwrap_or(false) {
                    bail!("{} is dirty — commit/stash before lifting", o.ws_rel);
                }
            }

            let basename = node.id.rsplit('/').next().unwrap().to_string();
            // default: a single dedicated `repos/` dir at the workspace root
            let into_dir = match into {
                Some(d) => d.trim_matches('/').to_string(),
                None => "repos".to_string(),
            };
            let dest_rel = if into_dir.is_empty() || into_dir == "." {
                basename.clone()
            } else {
                format!("{into_dir}/{basename}")
            };
            let ignore_entry = if into_dir.is_empty() || into_dir == "." {
                format!("/{basename}/")
            } else {
                format!("/{}/", into_dir.split('/').next().unwrap())
            };
            let dest = root.join(&dest_rel);
            if let Some(parent) = dest.parent() {
                std::fs::create_dir_all(parent).ok();
            }

            println!(
                "lift `{}` -> one shared copy at {}",
                node.id,
                color::cyan(&format!("./{dest_rel}"))
            );
            println!("  canonical commit: {}", &sha0[..12.min(sha0.len())]);
            println!("  {} consumer copies become symlinks:", occ.len());
            for o in &occ {
                println!("    {}  (in {})", o.ws_rel, o.sub_name);
            }
            println!("  workspace .gitignore += {ignore_entry}");
            {
                // preview branch intent using one existing checkout for the
                // `--contains` lookup (the real run uses the cloned copy)
                let probe = root.join(&occ[0].ws_rel);
                match canonical_branch(&probe, &occ, normalize_branch, &sha0) {
                    Some(b) => println!("  .repoverse.yaml revision (branch intent): {b}"),
                    None => println!(
                        "  no branch detected — pass --normalize-branch <b> to record intent"
                    ),
                }
            }
            if let Some(b) = normalize_branch {
                println!(
                    "  normalize .gitmodules branch= -> {b} in {} consumer(s)",
                    occ.len()
                );
            }
            if !yes {
                println!(
                    "\ndry-run. Nothing changed. Re-run with --yes to perform it.\n\
(.gitmodules + gitlinks stay intact; no pushes; reversible.)"
                );
                return Ok(());
            }

            // 0. gitignore the shared dir in the workspace superproject
            let gi = root.join(".gitignore");
            let cur = std::fs::read_to_string(&gi).unwrap_or_default();
            if !cur.lines().any(|l| l.trim() == ignore_entry) {
                let mut s = cur;
                if !s.is_empty() && !s.ends_with('\n') {
                    s.push('\n');
                }
                s.push_str(&format!("{ignore_entry}\n"));
                std::fs::write(&gi, s)?;
                println!("  gitignored {ignore_entry}");
            }

            // 1. materialize the single shared copy — NON-recursive.
            // Its own submodules are that repo's concern (dedup them via a
            // nested .repoverse.yaml inside the shared copy). Recursively
            // pulling them here is slow and re-fetches things that get
            // re-pointed anyway.
            if !dest.exists() {
                println!("→ cloning shared copy (non-recursive)");
                crate::git::clone(&root, &o_url(&occ[0]), &dest_rel)?;
                crate::git::checkout(&dest, &sha0)?;
            }
            // 2. each consumer: deinit + symlink + skip-worktree
            for o in &occ {
                let abs = root.join(&o.ws_rel);
                establish_link(&o.consumer, &o.sub_rel, &o.sub_name, &abs, &dest)?;
                println!("  linked {} -> shared", o.ws_rel);
            }
            // 2b. optionally normalize stale .gitmodules branch labels
            if let Some(b) = normalize_branch {
                for o in &occ {
                    crate::git::check(
                        &o.consumer,
                        &[
                            "config",
                            "-f",
                            ".gitmodules",
                            &format!("submodule.{}.branch", o.sub_name),
                            b,
                        ],
                    )?;
                    crate::git::check(&o.consumer, &["add", ".gitmodules"])?;
                    println!(
                        "  normalized {} branch= -> {b} (staged, not pushed)",
                        o.ws_rel
                    );
                }
            }
            // 3. record provides/shared
            let branch = canonical_branch(&dest, &occ, normalize_branch, &sha0);
            // The shared copy is THE edit point — leave it ON its branch
            // (tracking origin), never detached at a SHA.
            if let Some(b) = &branch {
                if crate::git::run(&dest, &["rev-parse", "--verify", &format!("origin/{b}")])
                    .map(|o| o.ok())
                    .unwrap_or(false)
                {
                    let _ = crate::git::check(
                        &dest,
                        &["checkout", "-B", b, "--track", &format!("origin/{b}")],
                    );
                } else {
                    let _ = crate::git::check(&dest, &["checkout", "-B", b]);
                }
                println!("  shared copy on branch {b} (editable)");
            }
            match &branch {
                Some(b) => println!("  recording branch intent: {b}"),
                None => println!(
                    "  no branch detected for {} — yaml inherits workspace default; \
re-run with --normalize-branch <b> to pin intent",
                    node.id
                ),
            }
            write_shared_config(&root, node, &occ, &dest_rel, branch.as_deref(), standalone)?;
            println!(
                "\ndone. Edit ./{} once; `rv rollup` propagates the SHA to all \
consumers. .gitmodules untouched; nothing pushed.",
                dest_rel
            );
            Ok(())
        }
    }

    #[cfg(unix)]
    fn o_url(o: &Occurrence) -> String {
        o.url.clone()
    }

    /// Deinit a (possibly populated) submodule and replace its worktree path
    /// with a relative symlink to the shared provider. Idempotent.
    #[cfg(unix)]
    fn establish_link(
        consumer: &Path,
        sub_rel: &str,
        sub_name: &str,
        _link_abs: &Path,
        provider: &Path,
    ) -> Result<()> {
        let _ = crate::git::submodule_deinit(consumer, sub_rel);
        // Resolve the link's *physical* location: the consumer (or an
        // ancestor) may itself be a shared symlink (e.g. a lifted parent),
        // so canonicalize the parent dir before placing the link. This
        // keeps the relative target correct no matter how it's reached.
        let logical = consumer.join(sub_rel);
        let real_link = match (logical.parent(), logical.file_name()) {
            (Some(p), Some(name)) => std::fs::canonicalize(p)
                .map(|rp| rp.join(name))
                .unwrap_or_else(|_| logical.clone()),
            _ => logical.clone(),
        };
        let provider = std::fs::canonicalize(provider).unwrap_or_else(|_| provider.to_path_buf());
        if std::fs::symlink_metadata(&real_link)
            .map(|m| m.file_type().is_symlink())
            .unwrap_or(false)
        {
            std::fs::remove_file(&real_link).ok();
        }

        // GUARD 1 — never create a cycle. If the resolved provider is the
        // same path as the link, or one nests inside the other, a symlink
        // here would loop (this is what corrupted bioscript<->repos/bioscript).
        let rl_canon = std::fs::canonicalize(&real_link).unwrap_or_else(|_| real_link.clone());
        if rl_canon == provider
            || provider.starts_with(&rl_canon)
            || rl_canon.starts_with(&provider)
        {
            bail!(
                "refusing to link {} -> {}: would create a symlink cycle / \
self-nest (provider and link overlap). This is the parent-contains-shared \
case — handle it as a nested workspace, not a top-level link.",
                real_link.display(),
                provider.display()
            );
        }

        let meta = std::fs::symlink_metadata(&real_link);
        match meta {
            Ok(m) if m.file_type().is_symlink() => {
                std::fs::remove_file(&real_link).ok();
            }
            Ok(m) if m.is_dir() => {
                // GUARD 2 — never delete real data. Only remove a directory
                // here if it's an (uninitialised) submodule checkout or
                // empty. A populated non-submodule dir = bail, don't nuke
                // (this is what destroyed the 580M clone).
                let is_submodule = crate::git::gitlink_sha(consumer, sub_rel).is_some()
                    || real_link.join(".git").exists();
                let empty = std::fs::read_dir(&real_link)
                    .map(|mut d| d.next().is_none())
                    .unwrap_or(true);
                if !is_submodule && !empty {
                    bail!(
                        "refusing to delete non-submodule directory {} \
({} — not a gitlink): aborting to avoid data loss",
                        real_link.display(),
                        "populated"
                    );
                }
                std::fs::remove_dir_all(&real_link).ok();
            }
            Ok(_) => {
                std::fs::remove_file(&real_link).ok();
            }
            Err(_) => {}
        }
        if let Some(p) = real_link.parent() {
            std::fs::create_dir_all(p).ok();
        }
        let target = pathdiff_rel(&real_link, &provider);
        std::os::unix::fs::symlink(&target, &real_link)?;
        let _ = crate::git::run(
            consumer,
            &["config", &format!("submodule.{sub_name}.ignore"), "all"],
        );
        if crate::git::gitlink_sha(consumer, sub_rel).is_some() {
            crate::git::set_skip_worktree(consumer, sub_rel, true)?;
        }
        Ok(())
    }

    /// Inverse of [`establish_link`]: drop the symlink overlay and restore
    /// the real submodule checkout, so plain git ops work again.
    #[cfg(unix)]
    fn unlink_one(consumer: &Path, sub_rel: &str, sub_name: &str, hydrate: bool) -> Result<()> {
        let real = consumer.join(sub_rel);
        let has_gitlink = crate::git::gitlink_sha(consumer, sub_rel).is_some();
        if std::fs::symlink_metadata(&real)
            .map(|m| m.file_type().is_symlink())
            .unwrap_or(false)
        {
            std::fs::remove_file(&real).ok();
        }
        // clear the overlay bookkeeping so git treats it as a normal submodule
        let _ = crate::git::set_skip_worktree(consumer, sub_rel, false);
        let _ = crate::git::run(
            consumer,
            &["config", "--unset", &format!("submodule.{sub_name}.ignore")],
        );
        if !has_gitlink {
            return Ok(());
        }
        if !hydrate {
            return Ok(());
        }
        // restore the real checkout at the pinned gitlink (no --remote)
        crate::git::check(consumer, &["submodule", "update", "--init", sub_rel])?;
        Ok(())
    }

    /// `rv` internal pre-git cleanup: remove overlay symlinks and local
    /// bookkeeping without hydrating submodule contents.
    pub fn detach(repo: Option<&str>, operation: &str) -> Result<()> {
        unlink_inner(repo, false, operation)
    }

    /// `rv unlink [repo]`: restore shared overlays to real submodules.
    /// Run before git history surgery; `rv link` re-applies after.
    pub fn unlink(repo: Option<&str>, hydrate: bool) -> Result<()> {
        let operation = if hydrate {
            "unlink"
        } else {
            "unlink --no-submodules"
        };
        unlink_inner(repo, hydrate, operation)
    }

    fn unlink_inner(repo: Option<&str>, hydrate: bool, operation: &str) -> Result<()> {
        #[cfg(not(unix))]
        {
            let _ = repo;
            let _ = hydrate;
            let _ = operation;
            let ws = Workspace::discover()?;
            if !has_overlay_config(&ws)? {
                return Ok(());
            }
            bail!("`rv unlink` is unix-only here");
        }
        #[cfg(unix)]
        {
            let ws = Workspace::discover()?;
            let owning = |full: &Path| -> Option<(std::path::PathBuf, String)> {
                // literal-path walk: do NOT canonicalize the link itself (it
                // may symlink out to the shared copy). sub_rel is the path
                // relative to the owning repo on disk.
                let mut cur = full.parent();
                while let Some(d) = cur {
                    if d.join(".git").exists() {
                        let rel = full
                            .strip_prefix(d)
                            .ok()
                            .map(|p| p.to_string_lossy().replace('\\', "/"))?;
                        return Some((d.to_path_buf(), rel));
                    }
                    if d == ws.root {
                        break;
                    }
                    cur = d.parent();
                }
                None
            };
            let provider_dir = |name: &str| -> Option<std::path::PathBuf> {
                ws.config
                    .projects
                    .iter()
                    .find(|p| p.name == name)
                    .map(|p| ws.project_dir(p))
                    .filter(|d| d.exists())
            };
            let mut n = 0;
            let mut failed = 0;
            let mut changed_paths = Vec::new();
            let mut root_links: Vec<_> = ws.config.links.iter().collect();
            root_links.sort_by_key(|l| std::cmp::Reverse(l.at.matches('/').count()));
            let all_links = root_links.clone();
            for l in root_links {
                if let Some(r) = repo {
                    if l.repo != r && !l.repo.ends_with(&format!("/{r}")) {
                        continue;
                    }
                }
                let physical = all_links
                    .iter()
                    .filter(|parent| {
                        l.at != parent.at
                            && l.at
                                .strip_prefix(&parent.at)
                                .is_some_and(|suffix| suffix.starts_with('/'))
                    })
                    .max_by_key(|parent| parent.at.len())
                    .and_then(|parent| {
                        let suffix = l.at[parent.at.len()..].trim_start_matches('/');
                        provider_dir(&parent.repo).map(|dir| dir.join(suffix))
                    })
                    .unwrap_or_else(|| ws.root.join(&l.at));
                let Some((consumer, sub_rel)) = owning(&physical) else {
                    eprintln!("  skip {} (no owning repo)", l.at);
                    continue;
                };
                let sub_name = sub_rel.rsplit('/').next().unwrap_or(&sub_rel);
                match unlink_one(&consumer, &sub_rel, sub_name, hydrate) {
                    Ok(()) => {
                        if hydrate {
                            println!("  unlinked {} (restored submodule)", l.at);
                        } else {
                            println!("  detached {} (overlay removed)", l.at);
                        }
                        changed_paths.push(l.at.clone());
                        n += 1;
                    }
                    Err(e) => {
                        failed += 1;
                        if hydrate {
                            eprintln!("  FAIL unlink {} — {e:#}", l.at);
                        } else {
                            eprintln!("  FAIL detach {} — {e:#}", l.at);
                        }
                    }
                }
            }
            if hydrate {
                println!("unlink: {n} restored. Do git work, then `rv link`.");
            } else {
                write_unlinked_marker(&ws.root, operation, &changed_paths)?;
                println!("detach: {n} overlays removed. Relinking after successful git work.");
            }
            if failed > 0 {
                if hydrate {
                    bail!("unlink failed for {failed} path(s)");
                } else {
                    bail!("detach failed for {failed} path(s)");
                }
            }
            Ok(())
        }
    }

    #[cfg(unix)]
    fn write_unlinked_marker(root: &Path, operation: &str, paths: &[String]) -> Result<()> {
        let started = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        let mut text = format!(
            "operation: {operation}\nmode: no-submodules\nstarted_unix: {started}\npaths:\n"
        );
        for path in paths {
            text.push_str(&format!("- {path}\n"));
        }
        std::fs::write(root.join(".repoverse.unlinked"), text)?;
        Ok(())
    }

    #[cfg(unix)]
    fn clear_unlinked_marker(root: &Path) {
        let _ = std::fs::remove_file(root.join(".repoverse.unlinked"));
    }

    /// `rv link`: re-establish every shared-dep symlink from config. Run
    /// after a fresh clone of the workspace. In a workspace, providers come
    /// from the root config's `provides:`/projects; with no provider the
    /// dep is left as a normal submodule (standalone/CI path).
    pub fn relink() -> Result<()> {
        #[cfg(not(unix))]
        {
            let ws = Workspace::discover()?;
            if !has_overlay_config(&ws)? {
                return Ok(());
            }
            bail!("`rv link` is unix-only here; run in CI via rv on Windows");
        }
        #[cfg(unix)]
        {
            let ws = Workspace::discover()?;
            // provider lookup by repo name -> absolute checkout dir
            let provider_dir = |name: &str| -> Option<std::path::PathBuf> {
                ws.config
                    .projects
                    .iter()
                    .find(|p| p.name == name)
                    .map(|p| ws.project_dir(p))
                    .filter(|d| d.exists())
            };
            // find every consumer `.repoverse.yaml` under the workspace
            // (consumers are nested in submodules, not root projects)
            let mut consumers: Vec<std::path::PathBuf> = Vec::new();
            for p in &ws.config.projects {
                let d = ws.project_dir(p);
                if d != ws.root && d.join(crate::config::CONFIG_FILE).is_file() {
                    consumers.push(d);
                }
            }
            let mut stack = vec![ws.root.clone()];
            while let Some(d) = stack.pop() {
                let Ok(rd) = std::fs::read_dir(&d) else {
                    continue;
                };
                for e in rd.flatten() {
                    let p = e.path();
                    let name = e.file_name();
                    let name = name.to_string_lossy();
                    if p.is_symlink() {
                        continue;
                    }
                    if p.is_dir() {
                        if name == ".git"
                            || name == "node_modules"
                            || name == "target"
                            || name == "repos"
                            || name.starts_with('.') && name != "."
                        {
                            continue;
                        }
                        stack.push(p);
                    } else if name == crate::config::CONFIG_FILE
                        && d != ws.root
                        && !consumers.iter().any(|c| c == &d)
                    {
                        consumers.push(d.clone());
                    }
                }
            }
            // nearest git repo ancestor of `at` = owning superproject
            let owning = |at: &str| -> Option<(std::path::PathBuf, String)> {
                let full = ws.root.join(at);
                let mut cur = full.parent();
                while let Some(d) = cur {
                    if d.join(".git").exists() {
                        let rel = full
                            .strip_prefix(d)
                            .ok()
                            .map(|p| p.to_string_lossy().replace('\\', "/"))?;
                        return Some((d.to_path_buf(), rel));
                    }
                    if d == ws.root {
                        break;
                    }
                    cur = d.parent();
                }
                None
            };

            let mut linked = 0;
            let mut skipped = 0;

            let mut root_links: Vec<_> = ws.config.links.iter().collect();
            root_links.sort_by_key(|l| l.at.matches('/').count());
            // 1. Root/container links first, so nested workspaces are reachable.
            for l in root_links
                .iter()
                .copied()
                .filter(|l| l.at.matches('/').count() <= 1)
            {
                let Some(pd) = provider_dir(&l.repo) else {
                    eprintln!("  skip {} (no provider for {})", l.at, l.repo);
                    skipped += 1;
                    continue;
                };
                let Some((consumer, sub_rel)) = owning(&l.at) else {
                    eprintln!("  skip {} (no owning repo)", l.at);
                    skipped += 1;
                    continue;
                };
                let sub_name = sub_rel.rsplit('/').next().unwrap_or(&sub_rel);
                establish_link(&consumer, &sub_rel, sub_name, &ws.root.join(&l.at), &pd)?;
                println!("  linked {} -> shared ({})", l.at, l.repo);
                linked += 1;
            }

            // 2. nested consumer/override configs (nearest-config-wins:
            //    processed last so an override re-points the link)
            for cdir in consumers {
                let ccfg = Config::load(&cdir.join(crate::config::CONFIG_FILE))?;
                let nested_owning = |at: &str| -> Option<(std::path::PathBuf, String)> {
                    let full = cdir.join(at);
                    let mut cur = full.parent();
                    while let Some(d) = cur {
                        if d.join(".git").exists() {
                            let rel = full
                                .strip_prefix(d)
                                .ok()
                                .map(|p| p.to_string_lossy().replace('\\', "/"))?;
                            return Some((d.to_path_buf(), rel));
                        }
                        if d == cdir {
                            break;
                        }
                        cur = d.parent();
                    }
                    Some((cdir.clone(), at.to_string()))
                };
                let nested_provider_dir = |name: &str| -> Option<std::path::PathBuf> {
                    provider_dir(name).or_else(|| {
                        ccfg.projects
                            .iter()
                            .find(|p| p.name == name)
                            .map(|p| {
                                if p.path == "." {
                                    cdir.clone()
                                } else {
                                    cdir.join(&p.path)
                                }
                            })
                            .filter(|d| d.exists())
                    })
                };
                let mut nested_links: Vec<_> = ccfg.links.iter().collect();
                nested_links.sort_by_key(|l| l.at.matches('/').count());
                for l in nested_links {
                    let Some(pd) = nested_provider_dir(&l.repo) else {
                        eprintln!(
                            "  skip {} (no provider for {}) — left as submodule",
                            l.at, l.repo
                        );
                        skipped += 1;
                        continue;
                    };
                    let Some((consumer, sub_rel)) = nested_owning(&l.at) else {
                        eprintln!("  skip {} (no owning repo)", l.at);
                        skipped += 1;
                        continue;
                    };
                    let link_abs = consumer.join(&sub_rel);
                    let sub_name = sub_rel.rsplit('/').next().unwrap_or(&sub_rel);
                    establish_link(&consumer, &sub_rel, sub_name, &link_abs, &pd)?;
                    println!(
                        "  linked {}/{} -> shared ({})",
                        cdir.strip_prefix(&ws.root).unwrap_or(&cdir).display(),
                        l.at,
                        l.repo
                    );
                    linked += 1;
                }
                for cp in &ccfg.projects {
                    for dep in &cp.shared {
                        let Some(pd) = nested_provider_dir(&dep.name) else {
                            eprintln!(
                                "  skip {} (no provider for {}) — left as submodule",
                                dep.path, dep.name
                            );
                            skipped += 1;
                            continue;
                        };
                        let link_abs = cdir.join(&dep.path);
                        let sub_name = dep.path.rsplit('/').next().unwrap_or(&dep.path);
                        establish_link(&cdir, &dep.path, sub_name, &link_abs, &pd)?;
                        println!(
                            "  linked {}/{} -> shared",
                            cdir.strip_prefix(&ws.root).unwrap_or(&cdir).display(),
                            dep.path
                        );
                        linked += 1;
                    }
                }
            }
            // 3. Deeper root links after nested parent overlays exist.
            for l in root_links
                .into_iter()
                .filter(|l| l.at.matches('/').count() > 1)
            {
                let Some(pd) = provider_dir(&l.repo) else {
                    eprintln!("  skip {} (no provider for {})", l.at, l.repo);
                    skipped += 1;
                    continue;
                };
                let Some((consumer, sub_rel)) = owning(&l.at) else {
                    eprintln!("  skip {} (no owning repo)", l.at);
                    skipped += 1;
                    continue;
                };
                let sub_name = sub_rel.rsplit('/').next().unwrap_or(&sub_rel);
                establish_link(&consumer, &sub_rel, sub_name, &ws.root.join(&l.at), &pd)?;
                println!("  linked {} -> shared ({})", l.at, l.repo);
                linked += 1;
            }
            println!("relink: {linked} linked, {skipped} left as submodule");
            clear_unlinked_marker(&ws.root);
            Ok(())
        }
    }

    /// Relative path from the *parent* of `link` to `target`.
    #[cfg(unix)]
    fn pathdiff_rel(link: &Path, target: &Path) -> std::path::PathBuf {
        let from = link.parent().unwrap_or(Path::new("."));
        let f: Vec<_> = from.components().collect();
        let t: Vec<_> = target.components().collect();
        let mut i = 0;
        while i < f.len() && i < t.len() && f[i] == t[i] {
            i += 1;
        }
        let mut out = std::path::PathBuf::new();
        for _ in i..f.len() {
            out.push("..");
        }
        for c in &t[i..] {
            out.push(c.as_os_str());
        }
        out
    }

    /// Canonical branch to record as intent: explicit normalize value →
    /// the unique .gitmodules label across consumers → a remote branch that
    /// contains the pinned SHA → None (inherits workspace default).
    #[cfg(unix)]
    fn canonical_branch(
        dest: &Path,
        occ: &[Occurrence],
        normalize_branch: Option<&str>,
        sha: &str,
    ) -> Option<String> {
        if let Some(b) = normalize_branch {
            return Some(b.to_string());
        }
        let labels: std::collections::BTreeSet<&String> =
            occ.iter().filter_map(|o| o.branch.as_ref()).collect();
        if labels.len() == 1 {
            return Some(labels.into_iter().next().unwrap().clone());
        }
        // ask git which remote branch(es) contain the pinned commit
        let out = crate::git::run(
            dest,
            &[
                "branch",
                "-r",
                "--contains",
                sha,
                "--format=%(refname:short)",
            ],
        )
        .ok()?;
        let mut remotes: Vec<String> = out
            .stdout
            .lines()
            .map(|l| l.trim().trim_start_matches("origin/").to_string())
            .filter(|b| !b.is_empty() && !b.starts_with("HEAD"))
            .collect();
        // prefer a branch that matches one of the (conflicting) labels
        if let Some(m) = remotes
            .iter()
            .find(|b| occ.iter().any(|o| o.branch.as_deref() == Some(b.as_str())))
            .cloned()
        {
            return Some(m);
        }
        remotes.sort();
        remotes.into_iter().next()
    }

    #[cfg(unix)]
    fn write_shared_config(
        root: &Path,
        node: &crate::submodule::Node,
        occ: &[Occurrence],
        dest_rel: &str,
        branch: Option<&str>,
        standalone: bool,
    ) -> Result<()> {
        // workspace provides:
        let cfg_path = root.join(crate::config::CONFIG_FILE);
        let mut cfg = if cfg_path.is_file() {
            Config::load(&cfg_path)?
        } else {
            Config {
                version: 1,
                defaults: crate::config::Defaults {
                    remote: "github".into(),
                    main_branch: None,
                    revision: "main".into(),
                    scheme: Scheme::Ssh,
                },
                remotes: {
                    let mut m = std::collections::BTreeMap::new();
                    m.insert(
                        "github".into(),
                        crate::config::Remote {
                            host: "github.com".into(),
                        },
                    );
                    m
                },
                setup: None,
                lint: None,
                test: None,
                projects: vec![],
                provides: vec![],
                links: vec![],
            }
        };
        let owner_repo = node
            .id
            .split_once('/')
            .map(|x| x.1.to_string())
            .unwrap_or_else(|| node.id.clone());
        if !cfg.provides.contains(&owner_repo) {
            cfg.provides.push(owner_repo.clone());
        }
        if !cfg.projects.iter().any(|p| p.path == dest_rel) {
            cfg.projects.push(Project {
                name: owner_repo.clone(),
                path: dest_rel.to_string(),
                main_branch: None,
                revision: branch.map(|s| s.to_string()),
                remote: None,
                upstream: None,
                submodules: Default::default(),
                consumes: vec![],
                ci: None,
                setup: None,
                lint: None,
                test: None,
                shared: vec![],
            });
        }
        // Central links table at the workspace root (source of truth) —
        // one entry per symlinked location, full workspace-relative path.
        for o in occ {
            if !cfg.links.iter().any(|l| l.at == o.ws_rel) {
                cfg.links.push(crate::config::Link {
                    repo: owner_repo.clone(),
                    at: o.ws_rel.clone(),
                    branch: branch.map(|s| s.to_string()),
                });
            }
        }
        std::fs::write(&cfg_path, serde_yaml::to_string(&cfg)?)?;
        println!(
            "  recorded provides:{owner_repo} + {} link(s) in {}",
            occ.len(),
            cfg_path.display()
        );

        // Per-consumer .repoverse.yaml only when explicitly opted-in
        // (repo is built/cloned standalone). Default: none — no sprawl.
        if standalone {
            for o in occ {
                let cpath = o.consumer.join(crate::config::CONFIG_FILE);
                let mut ccfg = if cpath.is_file() {
                    Config::load(&cpath)?
                } else {
                    Config {
                        version: 1,
                        defaults: Default::default(),
                        remotes: cfg.remotes.clone(),
                        setup: None,
                        lint: None,
                        test: None,
                        projects: vec![],
                        provides: vec![],
                        links: vec![],
                    }
                };
                let dep = crate::config::SharedDep {
                    name: owner_repo.clone(),
                    path: o.sub_rel.clone(),
                    revision: branch.map(|s| s.to_string()),
                };
                if let Some(self_p) = ccfg.projects.iter_mut().find(|p| p.path == ".") {
                    if !self_p.shared.iter().any(|s| s.path == dep.path) {
                        self_p.shared.push(dep);
                    }
                } else {
                    ccfg.projects.push(Project {
                        name: "self".into(),
                        path: ".".into(),
                        main_branch: None,
                        revision: None,
                        remote: None,
                        upstream: None,
                        submodules: Default::default(),
                        consumes: vec![],
                        ci: None,
                        setup: None,
                        lint: None,
                        test: None,
                        shared: vec![dep],
                    });
                }
                std::fs::write(&cpath, serde_yaml::to_string(&ccfg)?)?;
                println!("  + standalone consumer config: {}", cpath.display());
            }
        }
        Ok(())
    }

    /// `rv adopt --plan`: recursively scan submodules, print leaf-up order.
    pub fn plan(json: bool) -> Result<()> {
        let root = std::env::current_dir()?;
        if !root.join(".gitmodules").is_file() {
            bail!("no .gitmodules here — nothing to scan");
        }
        let root_id = git_origin_name(&root)
            .map(|n| crate::submodule::normalize_url(&n))
            .unwrap_or_else(|| "workspace/root".into());
        let plan = crate::submodule::scan(&root, &root_id);

        // enrich each path with on-disk branch/dirty status
        let status_of = |rel: &str| -> Option<(String, bool)> {
            let d = root.join(rel);
            if !crate::git::is_repo(&d) {
                return None;
            }
            let br = crate::git::current_branch(&d).unwrap_or_else(|_| "?".into());
            let dirty = crate::git::is_dirty(&d).unwrap_or(false);
            Some((br, dirty))
        };
        let sha_of = |rel: &str| checked_out_or_gitlink_sha(&root, rel);

        if json {
            let enriched: Vec<_> = plan
                .order
                .iter()
                .map(|n| {
                    let locs: Vec<_> = n
                        .paths
                        .iter()
                        .map(|p| {
                            let s = status_of(p);
                            serde_json::json!({
                                "path": p,
                                "branch": s.as_ref().map(|x| x.0.clone()),
                                "dirty": s.as_ref().map(|x| x.1),
                                "present": s.is_some(),
                            })
                        })
                        .collect();
                    serde_json::json!({
                        "id": n.id, "fan_in": n.fan_in,
                        "has_submodules": n.has_submodules,
                        "recommendation": n.recommendation,
                        "branch_conflicts": n.branch_conflicts,
                        "locations": locs,
                    })
                })
                .collect();
            println!("{}", serde_json::to_string_pretty(&enriched)?);
            return Ok(());
        }
        println!(
            "{}",
            color::dim("conversion plan — convert top-to-bottom (leaves first)")
        );
        for (i, n) in plan.order.iter().enumerate() {
            let num = color::dim(&format!("{:>3}.", i + 1));
            // pad the plain id first so ANSI codes don't break alignment
            let padded = format!("{:<38}", n.id);
            let (tag, name) = match n.recommendation {
                "lift-shared" => (
                    color::cyan(&format!("⇧ lift-shared ×{}", n.fan_in)),
                    color::cyan(&padded),
                ),
                "leaf-convert" => (color::green("leaf"), padded),
                "convert" => (color::yellow("convert"), padded),
                _ => (color::dim("root"), color::dim(&padded)),
            };
            println!("{num} {} {}", name, tag);
            for p in &n.paths {
                match status_of(p) {
                    Some((br, dirty)) => {
                        let dot = if dirty {
                            color::yellow("●")
                        } else {
                            color::green("●")
                        };
                        println!("       {} {:<48} {}", dot, p, color::dim(&br));
                    }
                    None => println!(
                        "       {} {}",
                        color::dim("○"),
                        color::dim(&format!("{p}  (not checked out)"))
                    ),
                }
            }
            if !n.branch_conflicts.is_empty() {
                println!(
                    "       {}",
                    color::red(&format!(
                        "⚠ .gitmodules branch= labels differ: {}",
                        n.branch_conflicts.join(", ")
                    ))
                );
            }
            // the conflict that actually blocks a clean lift: copies pinned
            // to different commits
            if n.paths.len() > 1 {
                let shas: std::collections::BTreeSet<String> =
                    n.paths.iter().filter_map(|p| sha_of(p)).collect();
                if shas.len() > 1 {
                    let short: Vec<String> = shas
                        .iter()
                        .map(|s| s[..12.min(s.len())].to_string())
                        .collect();
                    println!(
                        "       {}",
                        color::red(&format!(
                            "⚠ COMMIT DIVERGENCE — copies on {} different commits: {} \
(converge before lifting)",
                            shas.len(),
                            short.join(", ")
                        ))
                    );
                }
            }
        }
        println!(
            "{}",
            color::dim(
                "  legend: cyan=lift-shared(≥2 parents→1 copy) green=leaf \
yellow=convert  ● clean ● dirty  red=conflict"
            )
        );
        println!(
            "{}",
            color::dim("  `rv adopt --step <repo>` lifts one (dry-run unless --yes)")
        );
        Ok(())
    }

    fn checked_out_or_gitlink_sha(root: &Path, rel: &str) -> Option<String> {
        let full = root.join(rel);
        if crate::git::is_repo(&full) {
            return crate::git::head_sha(&full).ok();
        }

        let mut cur = full.parent();
        while let Some(repo) = cur {
            if repo.join(".git").exists() {
                let sub_rel = full
                    .strip_prefix(repo)
                    .ok()
                    .map(|p| p.to_string_lossy().replace('\\', "/"))?;
                return crate::git::gitlink_sha(repo, &sub_rel);
            }
            if repo == root {
                break;
            }
            cur = repo.parent();
        }
        None
    }

    /// Parse a `.gitmodules` into (path, url) pairs.
    fn parse_gitmodules(text: &str) -> Vec<(String, String)> {
        let mut out = Vec::new();
        let mut path = None;
        let mut url = None;
        for line in text.lines() {
            let l = line.trim();
            if l.starts_with("[submodule") {
                path = None;
                url = None;
            } else if let Some(v) = l.strip_prefix("path = ") {
                path = Some(v.trim().to_string());
            } else if let Some(v) = l.strip_prefix("url = ") {
                url = Some(v.trim().to_string());
            }
            if let (Some(p), Some(u)) = (&path, &url) {
                out.push((p.clone(), u.clone()));
                path = None;
                url = None;
            }
        }
        out
    }

    fn name_from_url(url: &str) -> String {
        let s = url
            .trim_end_matches(".git")
            .trim_start_matches("https://")
            .trim_start_matches("ssh://")
            .trim_start_matches("git@");
        // host:owner/repo or host/owner/repo -> owner/repo
        let s = s.replacen(':', "/", 1);
        let parts: Vec<&str> = s.split('/').collect();
        if parts.len() >= 3 {
            format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
        } else {
            s.to_string()
        }
    }

    pub fn run(_yes: bool, dry_run: bool) -> Result<()> {
        let root = std::env::current_dir()?;
        let gm = root.join(".gitmodules");
        if !gm.is_file() {
            bail!("no .gitmodules here — nothing to adopt");
        }
        let subs = parse_gitmodules(&std::fs::read_to_string(&gm)?);
        // de-duplicate by name (default yes); conflicting urls kept separate
        let mut by_name: BTreeMap<String, (String, String)> = BTreeMap::new();
        let mut projects = Vec::new();
        for (path, url) in &subs {
            let name = name_from_url(url);
            match by_name.get(&name) {
                Some((_, u)) if u == url => {
                    println!("  dedupe {name} ({path} collapses to existing)");
                    continue;
                }
                _ => {}
            }
            by_name.insert(name.clone(), (path.clone(), url.clone()));
            projects.push(Project {
                name,
                path: path.clone(),
                main_branch: None,
                revision: None,
                remote: None,
                upstream: None,
                submodules: Default::default(),
                consumes: vec![],
                ci: None,
                setup: None,
                lint: None,
                test: None,
                shared: vec![],
            });
        }
        // root repo consumes all flattened submodules
        let consumes: Vec<String> = projects.iter().map(|p| p.path.clone()).collect();
        let root_name = git_origin_name(&root).unwrap_or_else(|| "root/repo".into());
        projects.push(Project {
            name: root_name,
            path: ".".into(),
            main_branch: None,
            revision: None,
            remote: None,
            upstream: None,
            submodules: Default::default(),
            consumes,
            ci: None,
            setup: None,
            lint: None,
            test: None,
            shared: vec![],
        });
        let mut remotes = BTreeMap::new();
        remotes.insert(
            "github".into(),
            Remote {
                host: "github.com".into(),
            },
        );
        let cfg = Config {
            version: 1,
            defaults: Defaults {
                remote: "github".into(),
                main_branch: None,
                revision: "main".into(),
                scheme: Scheme::Ssh,
            },
            remotes,
            setup: None,
            lint: None,
            test: None,
            projects,
            provides: vec![],
            links: vec![],
        };
        cfg.validate()?;
        let yaml = serde_yaml::to_string(&cfg)?;
        if dry_run {
            println!("--- {} (dry-run) ---\n{yaml}", crate::config::CONFIG_FILE);
        } else {
            std::fs::write(crate::config::CONFIG_FILE, &yaml)?;
            println!(
                "wrote {} ({} projects). Review, then `rv init`/`rv pin`.",
                crate::config::CONFIG_FILE,
                cfg.projects.len()
            );
        }
        Ok(())
    }

    fn git_origin_name(root: &Path) -> Option<String> {
        let o = crate::git::run(root, &["remote", "get-url", "origin"]).ok()?;
        if o.ok() {
            Some(name_from_url(o.stdout.trim()))
        } else {
            None
        }
    }
}

pub mod plan {
    use super::*;
    use crate::git;
    use crate::graph::Graph;

    fn node_status(ws: &Workspace, path: &str) -> String {
        let Some(p) = ws.config.projects.iter().find(|p| p.path == path) else {
            return "unknown".into();
        };
        let dir = ws.project_dir(p);
        if !git::is_repo(&dir) {
            return "missing".into();
        }
        if git::is_dirty(&dir).unwrap_or(false) {
            return "dirty".into();
        }
        match git::ahead_behind(&dir).ok().flatten() {
            Some((a, _)) if a > 0 => "ahead".into(),
            _ => "clean".into(),
        }
    }

    pub fn run(json: bool) -> Result<()> {
        let ws = Workspace::discover()?;
        let g = Graph::from_config(&ws.config);
        let order = g.topo_order()?;
        let nodes: Vec<_> = order
            .iter()
            .map(|p| serde_json::json!({"path": p, "status": node_status(&ws, p)}))
            .collect();
        if json {
            println!("{}", serde_json::to_string_pretty(&nodes)?);
        } else {
            println!("rollup order (dependencies first):");
            for n in &nodes {
                println!(
                    "  {:<24} {}",
                    n["path"].as_str().unwrap(),
                    n["status"].as_str().unwrap()
                );
            }
        }
        Ok(())
    }

    pub fn next(json: bool) -> Result<()> {
        let ws = Workspace::discover()?;
        let g = Graph::from_config(&ws.config);
        let order = g.topo_order()?;
        for path in &order {
            let st = node_status(&ws, path);
            if st == "dirty" || st == "ahead" {
                let blocked: Vec<_> = g
                    .deps
                    .get(path)
                    .into_iter()
                    .flatten()
                    .filter(|d| {
                        let s = node_status(&ws, d);
                        s == "dirty" || s == "ahead"
                    })
                    .cloned()
                    .collect();
                let out = serde_json::json!({
                    "next": {
                        "repo": path,
                        "status": st,
                        "blocked_by": blocked,
                        "commands": [format!("rv commit -m \"...\" {path}"),
                                     format!("rv rollup --step {path}")],
                    }
                });
                if json {
                    println!("{}", serde_json::to_string_pretty(&out)?);
                } else {
                    println!("next: {path} ({st}); then `rv rollup --step {path}`");
                }
                return Ok(());
            }
        }
        if json {
            println!(
                "{}",
                serde_json::to_string_pretty(&serde_json::json!({"next": null, "done": true}))?
            );
        } else {
            println!("nothing to roll up — workspace clean");
        }
        Ok(())
    }
}

pub mod rollup {
    use super::*;
    use crate::git;
    use crate::graph::Graph;

    pub fn run(
        dry_run: bool,
        direct: bool,
        step: bool,
        from: Option<&str>,
        slug: Option<&str>,
    ) -> Result<()> {
        let ws = Workspace::discover()?;
        let g = Graph::from_config(&ws.config);
        let mut order = g.topo_order()?;
        if let Some(f) = from {
            if let Some(i) = order.iter().position(|p| p == f) {
                order = order[i..].to_vec();
            }
        }
        let slug = slug
            .map(|s| s.to_string())
            .unwrap_or_else(|| default_slug(&ws));

        let changed: Vec<String> = order
            .iter()
            .filter(|p| {
                let pr = ws.config.projects.iter().find(|x| &&x.path == p);
                pr.map(|pr| {
                    let d = ws.project_dir(pr);
                    git::is_repo(&d)
                        && (git::is_dirty(&d).unwrap_or(false)
                            || matches!(git::ahead_behind(&d).ok().flatten(), Some((a, _)) if a > 0))
                })
                .unwrap_or(false)
            })
            .cloned()
            .collect();

        if dry_run {
            println!("rollup plan (slug rv/{slug}):");
            for p in &changed {
                println!("  - {p}");
            }
            if changed.is_empty() {
                println!("  (nothing changed)");
            }
            return Ok(());
        }

        if !direct {
            if !crate::gh::available() {
                bail!("PR-mode rollup needs `gh`; install it or use `rv rollup --direct`");
            }
            // PR-mode cascade requires CI-wait/auto-merge orchestration;
            // not safe to fake. Surface clearly rather than do half a job.
            bail!(
                "PR-mode rollup (gh auto-merge cascade) not yet implemented in \
this build — use `rv rollup --direct` for the local fast-path"
            );
        }

        // --direct: per changed repo, leaves first.
        for path in &changed {
            let p = ws.config.projects.iter().find(|x| &x.path == path).unwrap();
            let dir = ws.project_dir(p);
            println!("→ {path}");
            git::checkout_new_branch(&dir, &format!("rv/{slug}"))?;
            for k in [
                crate::tasks::Kind::Setup,
                crate::tasks::Kind::Lint,
                crate::tasks::Kind::Test,
            ] {
                if let Some(r) = crate::tasks::resolve(p, &dir, k)? {
                    let ok = std::process::Command::new("bash")
                        .args(["-eo", "pipefail", "-c", &r.script])
                        .current_dir(&dir)
                        .status()?
                        .success();
                    if !ok {
                        bail!("{} {} failed; stopping rollup", path, k.as_str());
                    }
                }
            }
            if git::is_dirty(&dir)? {
                git::check(&dir, &["add", "-A"])?;
                git::check(&dir, &["commit", "-m", &format!("rollup: {slug}")])?;
            }
            git::check(&dir, &["push", "-u", "origin", &format!("rv/{slug}")])?;
            let sha = git::head_sha(&dir)?;
            for consumer in g.consumers_of(path) {
                bump_consumer(&ws, &consumer, path, &sha)?;
            }
            if step {
                println!("(--step: stopped after {path})");
                break;
            }
        }
        println!("rollup complete (--direct)");
        Ok(())
    }

    fn default_slug(ws: &Workspace) -> String {
        // reuse a non-main branch from the first changed repo, else timestamp
        for p in &ws.config.projects {
            let d = ws.project_dir(p);
            if git::is_repo(&d) {
                if let Ok(b) = git::current_branch(&d) {
                    if b != "main" && b != "master" && !b.starts_with("rv/") {
                        return b;
                    }
                }
            }
        }
        format!(
            "rollup-{:x}",
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_secs()
        )
    }

    /// Bump a consumer to `dep`'s new `sha`: gitlink commit if it's a real
    /// submodule, else rewrite the lock entry.
    fn bump_consumer(ws: &Workspace, consumer: &str, dep: &str, sha: &str) -> Result<()> {
        let cp = ws
            .config
            .projects
            .iter()
            .find(|x| x.path == consumer)
            .unwrap();
        let cdir = ws.project_dir(cp);
        let sub = cdir.join(dep);
        if cdir.join(".gitmodules").is_file()
            && (sub.join(".git").exists() || git::gitlink_sha(&cdir, dep).is_some())
        {
            // plumbing stamp — works whether `dep` is a real checkout or a
            // shared symlink overlay (deinited submodule). No checkout needed.
            git::set_gitlink(&cdir, dep, sha)?;
            println!("  bumped gitlink {consumer}:{dep} -> {}", &sha[..12]);
        } else {
            let mut lock = ws.lock()?;
            if let Some(e) = lock.projects.iter_mut().find(|e| e.path == dep) {
                e.sha = sha.to_string();
            } else {
                lock.projects.push(crate::lock::LockedProject {
                    path: dep.to_string(),
                    name: ws
                        .config
                        .projects
                        .iter()
                        .find(|x| x.path == dep)
                        .map(|x| x.name.clone())
                        .unwrap_or_default(),
                    revision: "main".into(),
                    sha: sha.to_string(),
                });
            }
            lock.save(&ws.lock_path)?;
            println!("  bumped lock {dep} -> {}", &sha[..12]);
        }
        Ok(())
    }
}

pub mod pr {
    use super::*;
    use crate::git;
    use crate::graph::Graph;
    use std::process::Command;

    #[derive(Debug, Clone)]
    struct PrPlan {
        path: String,
        repo: String,
        base: String,
        head: String,
        is_root: bool,
    }

    pub fn run(dry_run: bool, json: bool) -> Result<()> {
        let ws = Workspace::discover()?;
        let plan = build_plan(&ws, !json)?;
        if dry_run || json {
            print_plan(&plan, json)?;
            return Ok(());
        }

        if plan.is_empty() {
            println!("no PRs to open");
            return Ok(());
        }

        open_prs(&plan)
    }

    fn build_plan(ws: &Workspace, announce_skips: bool) -> Result<Vec<PrPlan>> {
        let g = Graph::from_config(&ws.config);
        let order = g.topo_order()?; // sub-repos first, root last
        let mut plan = Vec::new();
        for path in &order {
            let p = ws.config.projects.iter().find(|x| &x.path == path).unwrap();
            let dir = ws.project_dir(p);
            if !git::is_repo(&dir) {
                continue;
            }
            let head = git::current_branch(&dir)?;
            let base = ws.config.project_main_branch(p).to_string();
            if head == base {
                if announce_skips {
                    println!("  skip {path}: current branch is configured main_branch `{base}`");
                }
                continue;
            }
            if head.starts_with("rv/") {
                if announce_skips {
                    println!("  skip {path}: `{head}` is a generated rollup branch");
                }
                continue;
            }
            let origin_url = git::check(&dir, &["remote", "get-url", "origin"])?;
            let repo = repo_name_from_url(origin_url.trim());
            plan.push(PrPlan {
                path: path.clone(),
                repo,
                base,
                head,
                is_root: path == ".",
            });
        }
        Ok(plan)
    }

    fn print_plan(plan: &[PrPlan], json: bool) -> Result<()> {
        if json {
            let items: Vec<_> = plan
                .iter()
                .map(|p| {
                    serde_json::json!({
                        "path": p.path,
                        "repo": p.repo,
                        "base": p.base,
                        "head": p.head,
                        "remote": "origin",
                    })
                })
                .collect();
            println!("{}", serde_json::to_string_pretty(&items)?);
        } else {
            println!("PR plan:");
            for p in plan {
                println!("  {}: {} {}...{}", p.path, p.repo, p.base, p.head);
            }
            if plan.is_empty() {
                println!("  (nothing to open)");
            }
        }
        Ok(())
    }

    fn open_prs(plan: &[PrPlan]) -> Result<()> {
        if !crate::gh::available() {
            eprintln!("gh not found — push your branches and open PRs manually:");
            for p in plan {
                println!(
                    "  https://github.com/{}/compare/{}...{}?expand=1  ({})",
                    p.repo, p.base, p.head, p.path
                );
            }
            return Ok(());
        }
        let mut links: Vec<String> = Vec::new();
        for p in plan {
            let mut body = String::from("Opened by `rv pr`.");
            if p.is_root && !links.is_empty() {
                body.push_str("\n\nChild PRs:\n");
                for l in &links {
                    body.push_str(&format!("- {l}\n"));
                }
            }
            let out = Command::new("gh")
                .args([
                    "pr", "create", "--repo", &p.repo, "--base", &p.base, "--head", &p.head,
                    "--fill", "--body", &body,
                ])
                .output()?;
            if out.status.success() {
                let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
                println!("  PR {}: {url}", p.path);
                if !p.is_root {
                    links.push(url);
                }
            } else {
                eprintln!(
                    "  FAIL pr {}: {}",
                    p.path,
                    String::from_utf8_lossy(&out.stderr).trim()
                );
            }
        }
        Ok(())
    }

    fn repo_name_from_url(url: &str) -> String {
        let s = url
            .trim()
            .trim_end_matches(".git")
            .trim_start_matches("https://")
            .trim_start_matches("ssh://")
            .trim_start_matches("git@");
        let s = s.replacen(':', "/", 1);
        let parts: Vec<&str> = s.split('/').collect();
        if parts.len() >= 3 {
            format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
        } else {
            s.to_string()
        }
    }

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

        #[test]
        fn parses_origin_repo_names() {
            assert_eq!(
                repo_name_from_url("git@github.com:acme/lib.git"),
                "acme/lib"
            );
            assert_eq!(
                repo_name_from_url("https://github.com/acme/app.git"),
                "acme/app"
            );
            assert_eq!(
                repo_name_from_url("ssh://git@github.com/acme/root.git"),
                "acme/root"
            );
        }
    }
}

pub mod release {
    use super::*;
    use crate::git;

    pub fn run(tag: Option<&str>) -> Result<()> {
        super::pin::run()?;
        let ws = Workspace::discover()?;
        if ws.config.projects.iter().any(|p| p.path == ".") {
            let root = &ws.root;
            git::check(root, &["add", crate::lock::LOCK_FILE])?;
            // commit only if the lock actually changed
            if !git::run(root, &["diff", "--cached", "--quiet"])?.ok() {
                git::check(root, &["commit", "-m", "release: pin lock"])?;
                println!("committed lock");
            }
            if let Some(t) = tag {
                git::check(root, &["tag", t])?;
                println!("tagged {t}");
            }
        }
        Ok(())
    }
}
