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())
}
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(", ")))
}
}