use std::path::Path;
use anyhow::{Context, anyhow, bail};
use dolly_cli::{
binary, build, git, paths,
recipe::{Recipe, RecipeError},
ui,
};
use owo_colors::OwoColorize;
const MAX_COMMITS_SHOWN: usize = 10;
pub fn handle(repo: Option<&str>, dry_run: bool) -> anyhow::Result<()> {
match repo {
Some(r) => handle_repo(r, dry_run),
None => handle_all(dry_run),
}
}
fn handle_repo(repo: &str, dry_run: bool) -> anyhow::Result<()> {
let repo_path = paths::repositories_dir()?.join(repo);
if !repo_path.exists() {
bail!("repo `{repo}` is not cloned")
}
let recipe = match Recipe::find(repo) {
Ok(r) => Some(r),
Err(RecipeError::NotFound(_)) => None,
Err(e) => {
return Err(anyhow::Error::from(e).context(format!("loading recipe for `{repo}`")));
}
};
let old = git::head_commit(&repo_path).with_context(|| format!("reading HEAD for `{repo}`"))?;
if dry_run {
git::fetch(&repo_path).with_context(|| format!("fetching `{repo}`"))?;
let upstream = git::upstream_commit(&repo_path)
.with_context(|| format!("reading upstream HEAD for `{repo}`"))?;
if upstream != old {
ui::status("Would update", &format!("{repo} ({old} -> {upstream})"));
} else {
ui::status("Up-to-date", repo);
}
return Ok(());
}
ui::status("Pulling", repo);
git::pull(&repo_path).with_context(|| format!("pulling `{repo}`"))?;
let new = git::head_commit(&repo_path).with_context(|| format!("reading HEAD for `{repo}`"))?;
if new == old {
ui::status("Up-to-date", repo);
return Ok(());
}
ui::status("Updated", &format!("{repo} ({old} -> {new})"));
let summary = collect_summary(&repo_path, &old, &new)
.with_context(|| format!("summarizing changes in `{repo}`"))?;
print_summary(&summary);
let Some(recipe) = recipe else {
return Ok(());
};
ui::status("Building", repo);
build::run(&recipe.build.steps, &repo_path).with_context(|| format!("building `{repo}`"))?;
let binary_path = repo_path.join(&recipe.build.output);
let bin_name = binary_path
.file_name()
.ok_or_else(|| anyhow!("`build.output` has no filename"))?;
let install_path = paths::default_install_dir()?.join(bin_name);
binary::place(&binary_path, &install_path).with_context(|| format!("installing `{repo}`"))?;
binary::make_executable(&install_path)
.with_context(|| format!("setting executable bit on {}", install_path.display()))?;
ui::status("Installed", repo);
Ok(())
}
struct UpdateSummary {
subjects: Vec<String>,
files_changed: usize,
insertions: usize,
deletions: usize,
}
fn collect_summary(repo_path: &Path, from: &str, to: &str) -> Result<UpdateSummary, git::GitError> {
let subjects = git::log_between(repo_path, from, to)?;
let (files_changed, insertions, deletions) = git::diffstat(repo_path, from, to)?;
Ok(UpdateSummary {
subjects,
files_changed,
insertions,
deletions,
})
}
fn print_summary(summary: &UpdateSummary) {
let n = summary.subjects.len();
if n == 0 && summary.files_changed == 0 {
return;
}
let commit_word = if n == 1 { "commit" } else { "commits" };
let file_word = if summary.files_changed == 1 {
"file"
} else {
"files"
};
ui::detail(&format!(
"{n} new {commit_word}, {} {file_word} changed ({} {})",
summary.files_changed,
format!("+{}", summary.insertions).green(),
format!("-{}", summary.deletions).red(),
));
for subject in summary.subjects.iter().take(MAX_COMMITS_SHOWN) {
ui::detail(subject);
}
if n > MAX_COMMITS_SHOWN {
let line = format!("...and {} more", n - MAX_COMMITS_SHOWN);
ui::detail(&line.dimmed().to_string());
}
}
fn handle_all(dry_run: bool) -> anyhow::Result<()> {
let repositories_dir = paths::repositories_dir()?;
if !repositories_dir.exists() {
return Ok(());
}
let mut repos: Vec<String> = Vec::new();
for entry in repositories_dir
.read_dir()
.with_context(|| format!("reading {}", repositories_dir.display()))?
{
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
repos.push(entry.file_name().to_string_lossy().into_owned());
}
repos.sort();
for repo in repos {
handle_repo(&repo, dry_run)?;
}
Ok(())
}