Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

ci/verbs/src/deps.rs

//! `ci-verbs deps-update [--checkout <dir>] [--out <dir>] --author "<name> <email>"
//! [--date <yyyymmdd>]` (OSSF-NEXT): what a Renovate or Dependabot bot does, done by the
//! dwarves forge, which has no such bot (git.gunnar.rs runs no Actions and no app).
//!
//! * Every workspace lock of the checkout ([`lockfiles`]: no test fixture, no vendored
//!   crate, no target) gets a plain `cargo update` (semver-compatible moves only: a major
//!   bump is a person's change).
//! * A lock cargo cannot update fails the step and nothing is proposed (every lock is put
//!   back as it was).
//! * If any lock moved, the moved locks are committed in the checkout (the author is
//!   `--author`, the message lists every move and carries NO trailer: git.gunnar.rs
//!   refuses AI trailers, and a bot is not a co-author), and `propose.json` names the
//!   commit and a NEW branch `deps/update-<date>-<sha8>`.
//! * The pipeline's next step is the repository's own CI on that very tree. The forge
//!   pushes the proposal ([`crate::deps`] writes it; dwarves' forge_ci pushes it) only
//!   when the whole run went green, as a new branch, never forced, never merged: a person
//!   merges it. A red run is the ordinary red line, and the page lists what moved.

use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::path::{Path, PathBuf};

/// Directories that hold no workspace lock of the repository's own.
const SKIP_DIRS: &[&str] = &["target", ".git", "fixtures", "testdata", "vendor", "node_modules"];

/// **The workspace locks** of a checkout, sorted.
pub fn lockfiles(root: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    let mut stack = vec![root.to_path_buf()];
    while let Some(d) = stack.pop() {
        let Ok(rd) = std::fs::read_dir(&d) else { continue };
        for e in rd.flatten() {
            let p = e.path();
            let n = e.file_name().to_string_lossy().into_owned();
            if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
                if !SKIP_DIRS.contains(&n.as_str()) {
                    stack.push(p);
                }
            } else if n == "Cargo.lock" {
                out.push(p);
            }
        }
    }
    out.sort();
    out
}

/// Every crate of a lock and its versions.
pub fn lock_versions(lock: &str) -> BTreeMap<String, BTreeSet<String>> {
    let mut m: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    let mut name: Option<String> = None;
    for line in lock.lines() {
        let l = line.trim();
        if l == "[[package]]" {
            name = None;
        } else if let Some(v) = l.strip_prefix("name = ") {
            name = Some(v.trim_matches('"').to_string());
        } else if let Some(v) = l.strip_prefix("version = ") {
            if let Some(n) = name.take() {
                m.entry(n).or_default().insert(v.trim_matches('"').to_string());
            }
        }
    }
    m
}

/// One crate that moved.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Bump {
    /// The crate.
    pub name: String,
    /// The versions it left.
    pub from: Vec<String>,
    /// The versions it took.
    pub to: Vec<String>,
}

impl fmt::Display for Bump {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let side = |v: &[String], none: &str| if v.is_empty() { none.to_string() } else { v.join(", ") };
        write!(f, "{} {} → {}", self.name, side(&self.from, "(new)"), side(&self.to, "(gone)"))
    }
}

/// **What moved** between two locks, by crate name.
pub fn bumps(before: &BTreeMap<String, BTreeSet<String>>, after: &BTreeMap<String, BTreeSet<String>>) -> Vec<Bump> {
    let empty = BTreeSet::new();
    let names: BTreeSet<&String> = before.keys().chain(after.keys()).collect();
    names
        .into_iter()
        .filter_map(|n| {
            let b = before.get(n).unwrap_or(&empty);
            let a = after.get(n).unwrap_or(&empty);
            let from: Vec<String> = b.difference(a).cloned().collect();
            let to: Vec<String> = a.difference(b).cloned().collect();
            (!from.is_empty() || !to.is_empty()).then(|| Bump { name: n.clone(), from, to })
        })
        .collect()
}

/// The proposal's branch: new every time (the date and the commit).
pub fn branch_name(date: &str, sha: &str) -> String {
    format!("deps/update-{date}-{}", sha.get(..8).unwrap_or(sha))
}

fn plural(n: usize, one: &str, many: &str) -> String {
    format!("{n} {}", if n == 1 { one } else { many })
}

/// **The commit message**: a count line, then every move per lock. No trailer.
pub fn commit_message(per_lock: &[(String, Vec<Bump>)]) -> String {
    let moved: Vec<&(String, Vec<Bump>)> = per_lock.iter().filter(|(_, b)| !b.is_empty()).collect();
    let crates: usize = moved.iter().map(|(_, b)| b.len()).sum();
    let mut m = format!(
        "deps: cargo update ({} {} in {})\n\nThe dwarves forge's weekly dependency update: `cargo update` in every workspace lock\n(semver-compatible moves only), tested by the repository's own CI in the same run.\n",
        plural(crates, "crate", "crates"),
        if crates == 1 { "moves" } else { "move" },
        plural(moved.len(), "lock", "locks")
    );
    for (lock, b) in moved {
        m.push_str(&format!("\n{lock}:\n"));
        for x in b {
            m.push_str(&format!("  {x}\n"));
        }
    }
    m
}

/// Today as yyyymmdd (UTC), from the clock alone.
pub fn today() -> String {
    let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;
    let z = secs.div_euclid(86_400) + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = yoe + era * 400 + i64::from(m <= 2);
    format!("{y:04}{m:02}{d:02}")
}

fn env_dir(k: &str) -> Option<PathBuf> {
    std::env::var(k).ok().filter(|v| !v.is_empty()).map(PathBuf::from)
}

fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
    let o = std::process::Command::new("git").arg("-C").arg(dir).args(args).stdin(std::process::Stdio::null()).output().map_err(|e| format!("git: {e}"))?;
    if o.status.success() {
        Ok(String::from_utf8_lossy(&o.stdout).trim().to_string())
    } else {
        Err(format!("git {}: {}", args.join(" "), String::from_utf8_lossy(&o.stderr).trim()))
    }
}

/// The verb.
pub fn verb(args: &[String]) -> i32 {
    let v = |f: &str| crate::value(args, f);
    let (Some(checkout), Some(author)) = (v("--checkout").map(PathBuf::from).or_else(|| env_dir("DWARVES_CI_CHECKOUT")), v("--author")) else {
        eprintln!("usage: ci-verbs deps-update --checkout <dir> --author \"<name> <email>\" [--out <dir>] [--date <yyyymmdd>]");
        return 2;
    };
    let Some((name, email)) = author.rsplit_once(" <").map(|(n, e)| (n.trim().to_string(), e.trim_end_matches('>').to_string())) else {
        eprintln!("ci-verbs deps-update: --author is \"<name> <email>\", not `{author}`");
        return 2;
    };
    let checkout = checkout.canonicalize().unwrap_or(checkout);
    let repo = checkout.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
    let out = v("--out").map(PathBuf::from).or_else(|| env_dir("NORNIR_CI_OUT")).unwrap_or_else(|| PathBuf::from("."));
    if let Err(e) = std::fs::create_dir_all(&out) {
        eprintln!("ci-verbs deps-update: {}: {e}", out.display());
        return 1;
    }
    let date = v("--date").unwrap_or_else(today);
    let base = git(&checkout, &["rev-parse", "HEAD"]).unwrap_or_default();
    let mut per_lock: Vec<(String, Vec<Bump>)> = Vec::new();
    let mut failed: Vec<String> = Vec::new();
    for lock in lockfiles(&checkout) {
        let rel = lock.strip_prefix(&checkout).unwrap_or(&lock).display().to_string();
        let before = std::fs::read_to_string(&lock).unwrap_or_default();
        let dir = lock.parent().unwrap_or(&checkout);
        println!("ci-verbs deps-update: cargo update in {}", dir.display());
        let why = match std::process::Command::new("cargo").arg("update").current_dir(dir).stdin(std::process::Stdio::null()).status() {
            Ok(s) if s.success() => None,
            Ok(s) => Some(format!("{rel}: cargo update exited {s}")),
            Err(e) => Some(format!("{rel}: {e}")),
        };
        if let Some(why) = why {
            // a lock cargo could not update is put back as it was and proposes nothing
            let _ = std::fs::write(&lock, &before);
            failed.push(why);
            continue;
        }
        let after = std::fs::read_to_string(&lock).unwrap_or_default();
        per_lock.push((rel, bumps(&lock_versions(&before), &lock_versions(&after))));
    }
    let moved: Vec<&(String, Vec<Bump>)> = per_lock.iter().filter(|(_, b)| !b.is_empty()).collect();
    let mut md = format!("Dependency update (cargo update, every workspace lock) on `{repo}` at `{}`: ", base.get(..8).unwrap_or(&base));
    if !failed.is_empty() {
        md.push_str("**no proposal**: a lock could not be updated (below); every lock is as it was.\n");
        for (lock, _) in &moved {
            let _ = git(&checkout, &["checkout", "--", lock]);
        }
    } else if moved.is_empty() {
        md.push_str("**nothing to propose**: every lock is current.\n");
    } else {
        let files: Vec<&str> = moved.iter().map(|(l, _)| l.as_str()).collect();
        let mut add = vec!["add", "--"];
        add.extend(files.iter().copied());
        let msg = commit_message(&per_lock);
        let commit = git(&checkout, &add).and_then(|_| {
            let un = format!("user.name={name}");
            let ue = format!("user.email={email}");
            git(&checkout, &["-c", &un, "-c", &ue, "commit", "-q", "--no-verify", "-m", &msg]).and_then(|_| git(&checkout, &["rev-parse", "HEAD"]))
        });
        match commit {
            Ok(sha) => {
                let branch = branch_name(&date, &sha);
                let p = serde_json::json!({ "branch": branch, "commit": sha, "base": base });
                let _ = std::fs::write(out.join("propose.json"), serde_json::to_vec_pretty(&p).unwrap_or_default());
                md.push_str(&format!(
                    "**{} proposed** as `{branch}` (commit `{}`), pushed only if this run's CI step is green.\n",
                    plural(moved.iter().map(|(_, b)| b.len()).sum(), "move", "moves"),
                    sha.get(..8).unwrap_or(&sha)
                ));
            }
            Err(e) => failed.push(format!("commit: {e}")),
        }
        for (lock, b) in &moved {
            md.push_str(&format!("\n`{lock}`:\n\n"));
            for x in b {
                md.push_str(&format!("- {x}\n"));
            }
        }
    }
    for f in &failed {
        md.push_str(&format!("\nFAILED: {f}\n"));
    }
    let _ = std::fs::write(out.join("summary.md"), &md);
    print!("{md}");
    if failed.is_empty() {
        0
    } else {
        1
    }
}