wsb 0.258.6441

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! `wsb worktree` — make and remove a project's worktrees, submodules and
//! all. See [`crate::worktree`].

use crate::output::record::Record;
use crate::output::emit;
use crate::worktree::{Checkout, Target};

pub const USAGE: &str = "\
Usage: wsb worktree <command>

  add <project>/<name> [--branch B] [--from REF]
                     make $PRJ/<project>/<name>-worktree: the superproject on
                     B (default card/<name>, or $PRJ_BRANCH_PREFIX<name>), and
                     every submodule a worktree of main-worktree's repository
                     for it, on the same branch name
  rm <project>/<name> [--delete-branch]
                     remove it, bottom-up; refused unless every repository in
                     it is clean, untracked files included, and pushed
  ls [<project>]     every worktree, and whether its submodules are shared
                     with main or separate clones
  migrate <project>/<name> | --all
                     make a worktree's separate submodule clones worktrees of
                     main's repositories, on the same commits and branches;
                     the clones' branches are kept under refs/salvage/
  snapshot <project>/<name> [--of <worktree>] [--working]
                     take $PRJ/<project>/<name>-worktree as a temporary
                     worktree: every repository <worktree> (default main) has
                     checked out, detached at that commit, with its working
                     changes over them given --working; taken again, it is
                     refreshed in place

Every command speaks --jsonl.";

pub fn dispatch(args: &[String]) -> u8 {
    let verb = args.get(2).map(String::as_str);
    let rest = if args.len() > 3 { &args[3..] } else { &[] };
    let result = match verb {
        Some("add") => add(rest),
        Some("rm") => remove(rest),
        None | Some("ls") => list(rest),
        Some("migrate") => migrate(rest),
        Some("snapshot") => snapshot(rest),
        Some("help") | Some("--help") | Some("-h") => {
            crate::outln!("{USAGE}");
            return 0;
        }
        Some(other) => {
            crate::errln!("wsb worktree: unknown command '{other}'\n\n{USAGE}");
            return 2;
        }
    };
    match result {
        Ok(()) => 0,
        Err(message) => {
            crate::errln!("wsb worktree: {message}");
            1
        }
    }
}

fn prj() -> Result<wsb_prj::Prj, String> {
    wsb_prj::Prj::from_env().map_err(|e| e.to_string())
}

/// The arguments after the verb: one target, and the options named.
struct Parsed {
    target: Option<String>,
    options: std::collections::BTreeMap<String, Option<String>>,
}

fn parse(rest: &[String], valued: &[&str], flags: &[&str]) -> Result<Parsed, String> {
    let mut parsed = Parsed {
        target: None,
        options: Default::default(),
    };
    let mut arguments = rest.iter();
    while let Some(argument) = arguments.next() {
        if valued.contains(&argument.as_str()) {
            let value = arguments
                .next()
                .ok_or_else(|| format!("{argument} needs a value\n\n{USAGE}"))?;
            parsed.options.insert(argument.clone(), Some(value.clone()));
        } else if flags.contains(&argument.as_str()) {
            parsed.options.insert(argument.clone(), None);
        } else if argument.starts_with('-') {
            return Err(format!("unknown option {argument}\n\n{USAGE}"));
        } else if parsed.target.replace(argument.clone()).is_some() {
            return Err(format!("name one worktree\n\n{USAGE}"));
        }
    }
    Ok(parsed)
}

fn report(action: &str, target: &Target, checkouts: &[Checkout]) {
    for checkout in checkouts {
        emit(Record::WorktreeCheckout {
            action: action.to_string(),
            worktree: target.to_string(),
            repository: checkout.path.clone(),
            branch: checkout.branch.clone(),
            commit: checkout.commit.clone(),
        });
        let shown = if checkout.path.is_empty() { ".".to_string() } else { checkout.path.clone() };
        crate::outln!(
            "  {:<40} {}  {}",
            shown,
            checkout.branch.as_deref().unwrap_or("(detached)"),
            &checkout.commit[..checkout.commit.len().min(12)]
        );
    }
}

fn add(rest: &[String]) -> Result<(), String> {
    let parsed = parse(rest, &["--branch", "--from"], &[])?;
    let prj = prj()?;
    let target = Target::parse(&prj, parsed.target.as_deref().ok_or_else(|| format!("name the worktree\n\n{USAGE}"))?)?;
    let option = |name: &str| parsed.options.get(name).cloned().flatten();
    let checkouts = crate::worktree::add(&target, option("--branch").as_deref(), option("--from").as_deref())?;
    crate::outln!("made worktree {target} at {}", target.path().display());
    report("added", &target, &checkouts);
    Ok(())
}

fn remove(rest: &[String]) -> Result<(), String> {
    let parsed = parse(rest, &[], &["--delete-branch"])?;
    let prj = prj()?;
    let target = Target::parse(&prj, parsed.target.as_deref().ok_or_else(|| format!("name the worktree\n\n{USAGE}"))?)?;
    let checkouts = crate::worktree::remove(&target, parsed.options.contains_key("--delete-branch"))?;
    crate::outln!("removed worktree {target}");
    report("removed", &target, &checkouts);
    Ok(())
}

fn list(rest: &[String]) -> Result<(), String> {
    let parsed = parse(rest, &[], &[])?;
    let prj = prj()?;
    let projects = match parsed.target {
        Some(name) => vec![prj.project(&name).map_err(|e| e.to_string())?],
        None => prj.projects().map_err(|e| e.to_string())?,
    };
    for project in projects {
        for listed in crate::worktree::list(&project)? {
            emit(Record::WorktreeLayout {
                project: listed.project.clone(),
                worktree: listed.name.clone(),
                path: listed.path.clone(),
                branch: listed.branch.clone(),
                shared: listed.shared.clone(),
                cloned: listed.cloned.clone(),
                missing: listed.missing.clone(),
                broken: listed.broken.clone(),
                snapshot_of: listed.temporary.as_ref().map(|t| t.of.clone()),
                working: listed.temporary.as_ref().map(|t| t.working),
            });
            let mut layout = format!("{} shared", listed.shared.len());
            if !listed.cloned.is_empty() {
                layout.push_str(&format!(", {} cloned (migrate them)", listed.cloned.len()));
            }
            if !listed.missing.is_empty() {
                layout.push_str(&format!(", {} not checked out", listed.missing.len()));
            }
            if !listed.broken.is_empty() {
                layout.push_str(&format!(", {} broken (migrate repairs them)", listed.broken.len()));
            }
            if let Some(temporary) = &listed.temporary {
                layout.push_str(&format!(
                    "; temporary, a snapshot of {}{}",
                    temporary.of,
                    if temporary.working { " with its working changes" } else { "" }
                ));
            }
            crate::outln!(
                "{}/{:<20} {:<28} submodules: {layout}",
                listed.project,
                listed.name,
                listed.branch.as_deref().unwrap_or("(detached)"),
            );
        }
    }
    Ok(())
}

fn snapshot(rest: &[String]) -> Result<(), String> {
    let parsed = parse(rest, &["--of"], &["--working"])?;
    let prj = prj()?;
    let target = Target::parse(&prj, parsed.target.as_deref().ok_or_else(|| format!("name the worktree\n\n{USAGE}"))?)?;
    let of = parsed.options.get("--of").cloned().flatten().unwrap_or_else(|| wsb_prj::MAIN.to_string());
    let working = parsed.options.contains_key("--working");
    let checkouts = crate::worktree::snapshot(&target, &of, working)?;
    crate::outln!(
        "took {target} at {} as a snapshot of {}/{of}{}",
        target.path().display(),
        target.project.name(),
        if working { " with its working changes" } else { "" }
    );
    report("taken", &target, &checkouts);
    Ok(())
}

fn migrate(rest: &[String]) -> Result<(), String> {
    let parsed = parse(rest, &[], &["--all"])?;
    let prj = prj()?;
    let targets: Vec<Target> = match (parsed.target, parsed.options.contains_key("--all")) {
        (Some(one), false) => vec![Target::parse(&prj, &one)?],
        (None, true) => {
            let mut all = Vec::new();
            for project in prj.projects().map_err(|e| e.to_string())? {
                for worktree in project.worktrees().map_err(|e| e.to_string())? {
                    if worktree.name != wsb_prj::MAIN {
                        all.push(Target {
                            project: project.clone(),
                            name: worktree.name,
                        });
                    }
                }
            }
            all
        }
        _ => return Err(format!("name one worktree, or --all\n\n{USAGE}")),
    };
    let mut failed = Vec::new();
    for target in targets {
        match crate::worktree::migrate(&target) {
            Ok(checkouts) if checkouts.is_empty() => crate::outln!("{target}: nothing to migrate"),
            Ok(checkouts) => {
                crate::outln!("migrated {target}");
                report("migrated", &target, &checkouts);
            }
            Err(error) => {
                crate::errln!("{target}: {error}");
                failed.push(target.to_string());
            }
        }
    }
    if failed.is_empty() {
        Ok(())
    } else {
        Err(format!("not migrated: {}", failed.join(", ")))
    }
}