wsb 0.291.8591

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
  repair <project>   after the project's folder moved, reconnect each
                     worktree's superproject and submodules, recursively,
                     with main's repositories

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("repair") => repair(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 workshop() -> Result<wsb_prj::Workshop, String> {
    wsb_prj::Workshop::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)]
        );
    }
}

/// Make every submodule config of main's its own before a worktree shares
/// its repositories, and say what changed.
fn keep_mains_own(target: &Target) -> Result<(), String> {
    for line in crate::worktree::keep_main_worktrees_own(&target.main())? {
        crate::outln!("{}/main {line}", target.workspace.name());
    }
    Ok(())
}

fn add(rest: &[String]) -> Result<(), String> {
    let parsed = parse(rest, &["--branch", "--from"], &[])?;
    let prj = workshop()?;
    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();
    keep_mains_own(&target)?;
    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 = workshop()?;
    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 = workshop()?;
    let projects = match parsed.target {
        Some(name) => vec![prj.workspace(&name).map_err(|e| e.to_string())?],
        None => prj.workspaces().map_err(|e| e.to_string())?,
    };
    let mut rows = Vec::new();
    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 { "" }
                ));
            }
            rows.push([
                format!("{}/{}", listed.project, listed.name),
                listed.branch.unwrap_or_else(|| "(detached)".to_string()),
                layout,
            ]);
        }
    }
    for line in table(&rows) {
        crate::outln!("{line}");
    }
    Ok(())
}

/// Worktree rows in columns: each column as wide as its widest entry, so the
/// branch starts in the same place whatever the project is called.
fn table(rows: &[[String; 3]]) -> Vec<String> {
    let width = |column: usize| rows.iter().map(|row| row[column].chars().count()).max().unwrap_or(0);
    let (name, branch) = (width(0), width(1));
    rows.iter()
        .map(|[worktree, on, layout]| format!("{worktree:<name$}  {on:<branch$}  submodules: {layout}"))
        .collect()
}

fn snapshot(rest: &[String]) -> Result<(), String> {
    let parsed = parse(rest, &["--of"], &["--working"])?;
    let prj = workshop()?;
    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");
    keep_mains_own(&target)?;
    let checkouts = crate::worktree::snapshot(&target, &of, working)?;
    crate::outln!(
        "took {target} at {} as a snapshot of {}/{of}{}",
        target.path().display(),
        target.workspace.name(),
        if working { " with its working changes" } else { "" }
    );
    report("taken", &target, &checkouts);
    Ok(())
}

fn repair(rest: &[String]) -> Result<(), String> {
    let [name] = rest else {
        return Err(format!("name the project\n\n{USAGE}"));
    };
    let project = workshop()?.workspace(name).map_err(|e| e.to_string())?;
    let repaired = crate::worktree::repair_links(&project)?;
    if repaired.is_empty() {
        crate::outln!("{name}: every worktree is connected");
    }
    for line in repaired {
        crate::outln!("{line}");
    }
    Ok(())
}

fn migrate(rest: &[String]) -> Result<(), String> {
    let parsed = parse(rest, &[], &["--all"])?;
    let prj = workshop()?;
    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.workspaces().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 {
                            workspace: 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 {
        if let Err(error) = keep_mains_own(&target) {
            crate::errln!("{target}: {error}");
            failed.push(target.to_string());
            continue;
        }
        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(", ")))
    }
}

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

    fn row(worktree: &str, branch: &str, layout: &str) -> [String; 3] {
        [worktree.to_string(), branch.to_string(), layout.to_string()]
    }

    /// TEST20208: the branch and submodule columns start at one place in every row.
    #[test]
    fn test20208_worktree_rows_align_whatever_the_project_is_called() {
        let lines = table(&[
            row("wsb/main", "trunk", "0 shared"),
            row("machinefabric/sdx-overhaul", "feature/a-much-longer-branch-name", "12 shared"),
            row("x/ü", "(detached)", "1 shared, 1 cloned (migrate them)"),
        ]);
        let column = |line: &str, text: &str| line[..line.find(text).expect("in the row")].chars().count();
        assert_eq!(column(&lines[0], "trunk"), column(&lines[1], "feature/"));
        assert_eq!(column(&lines[0], "trunk"), column(&lines[2], "(detached)"));
        let submodules: Vec<usize> = lines.iter().map(|line| column(line, "submodules:")).collect();
        assert!(submodules.iter().all(|at| *at == submodules[0]), "{lines:#?}");
        assert!(lines[1].starts_with("machinefabric/sdx-overhaul  feature/"), "{lines:#?}");
    }
}