use std::cell::Cell;
use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use crate::checkout;
use crate::edge::Edge;
use crate::git::{self, Git};
use crate::hooks::Hooks;
use crate::install::{install, referenced, resolve_and_bind, InstallError, Summary};
use crate::layout::CloneDir;
use crate::lifecycle::{BaseChange, Engine, Plugins};
use crate::log::{self, Level, Log};
use crate::message::{Message, PROTOCOL};
use crate::op::Phase;
use crate::plugin::Subprocess;
use crate::registry::{PluginRef, Registry};
use crate::safegit::reject_option_like;
use crate::verb::Verb;
use crate::wire::OpContext;
use crate::LANDING_BRANCH;
use args::parse;
pub(crate) type AfterStage<'a> = &'a dyn Fn(&Path) -> io::Result<()>;
pub fn run(edge: &Edge, args: &[String]) -> io::Result<()> {
let opts = parse(args, &edge.default_actor)?;
let clone = edge.xdg.clone_dir(&edge.invocation_path);
let (landing, store) = (clone.landing(), clone.store());
if !landing.join("config").is_dir() {
return Err(io::Error::other("no balls checkout here — run `bl prime` first"));
}
let (binding, level) = checkout::bind(edge, &landing, &store, None, None)?;
let to_landing = opts.to == LANDING_BRANCH;
if !to_landing && opts.to != binding.tasks_branch {
return Err(io::Error::other(format!(
"install: --to must name the landing ({LANDING_BRANCH}) or the configured store branch ({}) — install is purely local in core; a center's config publishes by `git push` from its owning clone (§6)",
binding.tasks_branch
)));
}
let hooks = Hooks::effective(&landing, &edge.xdg.user_config(), &edge.machine_dirs())?;
let reg = Registry::at(&landing);
let log = Log::new(clone.op_log(), level, Verb::Install, log::wall);
let bind_only = !opts.explicit_path && opts.from.is_none() && !opts.bins.is_empty();
let summary = if bind_only {
Summary::default()
} else {
let plugins = Subprocess::new(OpContext::diffless(opts.actor.clone(), binding), &log, edge.depth);
let pre = hooks.resolve(®, Verb::Install.token(), "pre");
let (pre, from) = match opts.from {
Some(ref f) => (pre, f.clone()),
None => (Vec::new(), upstream(&plugins, &pre, &landing)?),
};
let chain = Chain {
plugins: &plugins,
log: &log,
pre,
post: hooks.resolve(®, Verb::Install.token(), "post"),
};
let to = if to_landing { &landing } else { &store };
seal_copy(&clone, &opts.path, &from, to, &chain, &opts.actor, None)?
};
if to_landing {
bind_referenced(&landing, edge, &opts.bins, &log)?;
}
println!("install: {summary}");
Ok(())
}
fn upstream(plugins: &Subprocess, pre: &[PluginRef], landing: &Path) -> io::Result<String> {
for plugin in pre {
plugins.run(plugin, Verb::Install, Phase::Pre, landing, None)?;
}
if git::run(landing, &["rev-parse", "--verify", "--quiet", "FETCH_HEAD"], None).is_err() {
return Err(io::Error::other(
"install: no --from given and no configured upstream offers a balls/config to adopt — pass --from <ref>",
));
}
Ok("FETCH_HEAD".to_string())
}
pub(crate) struct Chain<'a> {
pub(crate) plugins: &'a Subprocess<'a>,
pub(crate) log: &'a Log,
pub(crate) pre: Vec<PluginRef>,
pub(crate) post: Vec<PluginRef>,
}
pub(crate) fn seal_copy(
clone: &CloneDir,
path: &str,
from: &str,
to: &Path,
chain: &Chain,
actor: &str,
after: Option<AfterStage>,
) -> io::Result<Summary> {
reject_option_like(from)?;
let src = source_root(to, clone, from)?;
let base = Copier {
path,
src: &src,
message: Message::checkout(Verb::Install, actor, format!("balls: install {path} --from {from}")).render()?,
copied: Cell::new(Summary::default()),
after,
};
let change = clone.change("install");
let _ = git::run(to, &["worktree", "remove", "--force", &change.to_string_lossy()], None);
let sealed = Engine::new(&Git::at(to), chain.plugins, chain.log)
.seal(&base, Verb::Install, &change, &chain.pre, &chain.post);
let _ = git::run(to, &["worktree", "remove", "--force", &src.to_string_lossy()], None);
sealed.map_err(|e| io::Error::other(e.to_string()))?;
Ok(base.copied.get())
}
fn source_root(repo: &Path, clone: &CloneDir, from: &str) -> io::Result<PathBuf> {
let src = clone.change("install-src");
let s = src.to_string_lossy().into_owned();
let _ = git::run(repo, &["worktree", "remove", "--force", &s], None);
git::run(repo, &["worktree", "add", "--detach", &s, from], None)?;
Ok(src)
}
struct Copier<'a> {
path: &'a str,
src: &'a Path,
message: String,
copied: Cell<Summary>,
after: Option<AfterStage<'a>>,
}
impl BaseChange for Copier<'_> {
fn stage(&self, dir: &Path) -> io::Result<()> {
self.copied.set(install(self.path, self.src, dir)?);
if let Some(after) = self.after {
after(dir)?;
}
Ok(())
}
fn finalize(&self, _dir: &Path) -> io::Result<String> {
Ok(self.message.clone())
}
}
pub(crate) fn bind_referenced(landing: &Path, edge: &Edge, bins: &BTreeMap<String, PathBuf>, log: &Log) -> io::Result<()> {
let worklist = referenced(landing)?;
if let Some(name) = bins.keys().find(|n| !worklist.contains_key(*n)) {
return Err(io::Error::other(format!(
"install: --bin {name}: the landed schedule does not reference that plugin"
)));
}
let hints = Hooks::effective(landing, &edge.xdg.user_config(), &edge.machine_dirs())?;
let registry = Registry::at(landing);
for (name, ops) in worklist {
let source = hints.source(&name).map(|h| format!(" — source: {h}")).unwrap_or_default();
let Some(bin) = bins.get(&name).cloned().or_else(|| locate(&name, edge)) else {
log.record(
Level::Info,
"core",
None,
&format!("install: {name} referenced but not bound (no binary beside bl or on PATH){source} — re-run bl install after acquiring"),
);
continue;
};
resolve_and_bind(®istry, &name, &bin, &ops, PROTOCOL).map_err(|e| match e {
InstallError::Unsupported { .. } => io::Error::other(format!("{e}{source}")),
InstallError::Io(_) => io::Error::other(e.to_string()),
})?;
}
Ok(())
}
fn locate(name: &str, edge: &Edge) -> Option<PathBuf> {
let dirs = edge.exe_dir.iter().chain(edge.path_dirs.iter());
dirs.map(|d| d.join(name)).find(|p| p.is_file())
}
#[path = "install_args.rs"]
mod args;
#[cfg(test)]
#[path = "install_run_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "install_surface_tests.rs"]
mod surface_tests;