Something went wrong. Try again.
Evergrowing NixOS flake of my setup.
Something went wrong. Try again.
15 kB · 537 lines
Rust
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538use anyhow::{bail, Context, Result};use clap::Parser;use console::{style, Color};use indicatif::{ProgressBar, ProgressStyle};use std::path::{Path, PathBuf};use std::process::{Command, Stdio};use std::thread;use std::time::{Duration, Instant};
const SUBDIVISIONS: u64 = 100;const MIN_STEP_DURATION: Duration = Duration::from_millis(450);const ANIMATE_DURATION: Duration = Duration::from_millis(350);const ANIMATE_FRAMES: u32 = 30;
const ICON_PULL: &str = "";const ICON_REMOTE: &str = "";const ICON_LOCAL: &str = "";const ICON_UPDATE: &str = "";const ICON_FORMAT: &str = "";const ICON_LINT: &str = "";const ICON_CHECK: &str = "";const ICON_PAUSE: &str = "";const ICON_REBUILD: &str = "";
const ICON_CLOCK: &str = "";const FILL_CHAR: &str = "█";const EMPTY_CHAR: &str = "░";const ICON_BRACKET_OPEN: &str = "";const ICON_BRACKET_CLOSE: &str = "";const TICK_FRAMES: &[&str] = &["", "", "", "", "", "", ""];
#[derive(Parser)]#[command( name = "flake-runner", about = "Pull/update/format/rebuild pipeline for a NixOS flake repo")]struct Args { /// jj git fetch, hard gate on unresolved conflicts #[arg(short, long)] pull: bool,
/// nix flake update --flake <repo> #[arg(short, long)] update: bool,
/// format .nix files, then statix + deadnix (hard gates) #[arg(short, long)] format: bool,
/// nh os switch, prints resulting generation #[arg(short, long)] rebuild: bool,
/// Absolute path to the flake repo #[arg(long)] repo: PathBuf,
/// Hostname passed to `nh os switch -H` #[arg(long)] host: String,
/// Use pedantix (per-file alejandra wrapper) instead of plain alejandra #[arg(long)] pedantix: bool,}
struct Stage<'a> { name: &'a str, run: fn(&Args, &ProgressBar) -> Result<()>,}
fn stage_color(name: &str) -> Color { match name { "pull" => Color::Blue, "update" => Color::Magenta, "format" => Color::Yellow, "rebuild" => Color::Green, _ => Color::Cyan, }}
fn main() -> Result<()> { let args = Args::parse();
let mut stages: Vec<Stage> = Vec::new(); if args.pull { stages.push(Stage { name: "pull", run: stage_pull, }); } if args.update { stages.push(Stage { name: "update", run: stage_update, }); } if args.format { stages.push(Stage { name: "format", run: stage_format, }); } if args.rebuild { stages.push(Stage { name: "rebuild", run: stage_rebuild, }); }
if stages.is_empty() { println!("⚠️ No stage flags given — nothing to do. Try --help."); return Ok(()); } let total = stages.len();
let bracket_open = style(ICON_BRACKET_OPEN).cyan(); let bracket_close = style(ICON_BRACKET_CLOSE).cyan(); let template = format!( "{{spinner:.cyan.bold}} {bracket_open}{{bar:40.cyan/black}}{bracket_close} {{percent:>3}}% {{msg}} ( {{elapsed_precise}})" );
let pb = ProgressBar::new((total as u64) * SUBDIVISIONS); pb.set_style( ProgressStyle::with_template(&template) .unwrap() .tick_strings(TICK_FRAMES) .progress_chars(&format!("{FILL_CHAR}{EMPTY_CHAR}")), ); pb.enable_steady_tick(Duration::from_millis(80));
let mut current_units: u64 = 0;
for (idx, stage) in stages.iter().enumerate() { announce_stage(&pb, stage.name)?;
pb.set_message(format!("{} ({}/{total})", stage.name, idx + 1));
let start = Instant::now(); if let Err(e) = (stage.run)(&args, &pb) { pb.abandon_with_message(format!("❌ {} failed", stage.name)); return Err(e); }
let elapsed = start.elapsed(); if elapsed < MIN_STEP_DURATION { thread::sleep(MIN_STEP_DURATION - elapsed); }
let target = ((idx + 1) as u64) * SUBDIVISIONS; animate_to(&pb, &mut current_units, target, ANIMATE_DURATION); }
pb.finish_with_message("🏁"); Ok(())}
fn format_elapsed(d: Duration) -> String { let secs = d.as_secs(); format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)}
fn frozen_bar_line(pb: &ProgressBar, icon: &str, msg: &str) -> String { let width = 40usize; let pos = pb.position(); let len = pb.length().unwrap_or(1).max(1); let ratio = (pos as f64 / len as f64).clamp(0.0, 1.0); let filled = (ratio * width as f64).round() as usize; let bar_str = format!( "{}{}", FILL_CHAR.repeat(filled), EMPTY_CHAR.repeat(width - filled) ); let pct = (ratio * 100.0).round() as u64;
format!( "{} {}{}{} {:>3}% {} ({ICON_CLOCK} {})", style(icon).yellow().bold(), style(ICON_BRACKET_OPEN).cyan(), style(bar_str).cyan(), style(ICON_BRACKET_CLOSE).cyan(), pct, msg, format_elapsed(pb.elapsed()) )}
fn announce_stage(pb: &ProgressBar, name: &str) -> Result<()> { let color = stage_color(name); let rule = style("─".repeat(50)).fg(color).dim(); pb.println(rule.to_string());
let output = Command::new("figlet") .args(["-f", "cybermedium", &name.to_uppercase()]) .output() .context("failed to run figlet")?;
for line in String::from_utf8_lossy(&output.stdout).lines() { pb.println(style(line).fg(color).bold().to_string()); } Ok(())}
fn animate_to(pb: &ProgressBar, current: &mut u64, target: u64, duration: Duration) { let start_val = *current as f64; let delta = target as f64 - start_val; let frame_time = duration / ANIMATE_FRAMES;
for i in 1..=ANIMATE_FRAMES { let t = i as f64 / ANIMATE_FRAMES as f64; let eased = 1.0 - (1.0 - t).powi(3); pb.set_position((start_val + delta * eased).round() as u64); thread::sleep(frame_time); }
*current = target; pb.set_position(target);}
fn run_command_capture(program: &str, cmd_args: &[&str], cwd: &Path) -> Result<String> { let output = Command::new(program) .args(cmd_args) .current_dir(cwd) .output() .with_context(|| format!("failed to spawn `{program}`"))?;
if !output.status.success() { bail!( "`{program} {}` exited with {}", cmd_args.join(" "), output.status ); } Ok(String::from_utf8_lossy(&output.stdout).to_string())}
fn run_command_capture_combined(program: &str, cmd_args: &[&str], cwd: &Path) -> Result<String> { let output = Command::new(program) .args(cmd_args) .current_dir(cwd) .output() .with_context(|| format!("failed to spawn `{program}`"))?;
if !output.status.success() { bail!( "`{program} {}` exited with {}", cmd_args.join(" "), output.status ); }
let mut combined = String::from_utf8_lossy(&output.stdout).to_string(); combined.push_str(&String::from_utf8_lossy(&output.stderr)); Ok(combined)}
fn run_tool( pb: &ProgressBar, icon: &str, name: &str, detail: Option<&str>, program: &str, cmd_args: &[&str], cwd: &Path,) -> Result<()> { let plain_label = match detail { Some(d) => format!("{name} {d}"), None => name.to_string(), };
pb.set_message(format!("{icon} {plain_label}"));
let output = Command::new(program) .args(cmd_args) .current_dir(cwd) .output() .with_context(|| format!("failed to spawn `{program}`"))?;
if !output.status.success() { let combined = format!( "{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); if !combined.trim().is_empty() { for line in combined.trim_end().lines() { pb.println(format!(" {}", style(line).red())); } } bail!( "`{program} {}` exited with {}", cmd_args.join(" "), output.status ); }
let colored = match detail { Some(d) => format!("{} {}", style(name).green(), style(d).dim()), None => style(name).green().to_string(), }; pb.println(format!( " {} {colored}", style(ICON_CHECK).green().bold() )); Ok(())}
fn stage_pull(args: &Args, pb: &ProgressBar) -> Result<()> { let repo = args.repo.to_str().context("repo path is not valid UTF-8")?;
let remotes = run_command_capture("jj", &["-R", repo, "git", "remote", "list"], &args.repo)?; for line in remotes.lines().filter(|l| !l.trim().is_empty()) { pb.println(format!( " {ICON_REMOTE} Pulling newest changes from {}", style(line.trim()).cyan().bold() )); }
pb.set_message(format!("{ICON_PULL} fetching"));
let fetch_output = run_command_capture_combined("jj", &["-R", repo, "git", "fetch"], &args.repo)?; for line in fetch_output.lines() { let line = line.trim(); if line.is_empty() { continue; } if line == "Nothing changed." { pb.println(format!( " {ICON_CHECK} {}", style("no remote changes to pull").green() )); } else { pb.println(format!(" {}", style(line).yellow())); } }
let local_head = run_command_capture( "jj", &[ "-R", repo, "log", "-r", "@-", "--no-graph", "-T", "change_id.shortest(8) ++ \" \" ++ commit_id.shortest(8) ++ \" \" ++ description.first_line()", ], &args.repo, )?; pb.println(format!( " {ICON_LOCAL} {} {}", style("Local Head:").blue().bold(), style(local_head.trim()).white() ));
let bookmark_status = run_command_capture( "jj", &["-R", repo, "bookmark", "list", "--all-remotes"], &args.repo, )?; if !bookmark_status.trim().is_empty() { pb.println(format!( " {ICON_REMOTE} {}", style("Bookmark Status:").blue().bold() )); for line in bookmark_status.lines().filter(|l| !l.trim().is_empty()) { pb.println(format!(" {}", style(line).dim())); } }
let conflicted = run_command_capture( "jj", &[ "-R", repo, "log", "-r", "conflicts()", "--no-graph", "-T", "change_id ++ \"\\n\"", ], &args.repo, )?; if !conflicted.trim().is_empty() { bail!( "jj fetch produced conflicts, resolve before continuing:\n{}", conflicted.trim() ); } Ok(())}
fn stage_update(args: &Args, pb: &ProgressBar) -> Result<()> { let repo = args.repo.to_str().context("repo path is not valid UTF-8")?; pb.set_message(format!("{ICON_UPDATE} nix flake update"));
let output = run_command_capture_combined("nix", &["flake", "update", "--flake", repo], &args.repo)?;
let mut any_changed = false; for line in output.lines() { let line = line.trim(); if line.is_empty() { continue; } if line.starts_with('•') || line.contains("Updated input") { any_changed = true; pb.println(format!(" {}", style(line).yellow())); } else { pb.println(format!(" {}", style(line).dim())); } }
if any_changed { pb.println(format!( " {ICON_CHECK} {}", style("flake.lock updated").green().bold() )); } else { pb.println(format!( " {ICON_CHECK} {}", style("flake.lock already up to date").green() )); }
Ok(())}
fn stage_format(args: &Args, pb: &ProgressBar) -> Result<()> { let repo = args.repo.to_str().context("repo path is not valid UTF-8")?;
if args.pedantix { pb.println(format!( " {ICON_FORMAT} {} {} {}", style("Formatter:").blue().bold(), style("PEDANTIX").cyan(), style("(per-file pedantic alejandra wrapper)").dim() )); let files = run_command_capture("fd", &[".nix", repo], &args.repo)?; for file in files.lines().filter(|l| !l.trim().is_empty()) { run_tool( pb, ICON_FORMAT, "pedantix", Some(file), "pedantix", &[file], &args.repo, )?; } } else { pb.println(format!( " {ICON_FORMAT} {} {}", style("Formatter:").blue().bold(), style("ALEJANDRA").cyan() )); run_tool( pb, ICON_FORMAT, "alejandra", None, "alejandra", &[repo], &args.repo, )?; }
pb.println(format!( " {ICON_LINT} {}", style("Running lints:").blue().bold() )); run_tool( pb, ICON_LINT, "statix check", None, "statix", &["check", repo], &args.repo, )?; run_tool( pb, ICON_LINT, "deadnix --fail", None, "deadnix", &["--fail", repo], &args.repo, )?; Ok(())}
fn stage_rebuild(args: &Args, pb: &ProgressBar) -> Result<()> { let repo = args.repo.to_str().context("repo path is not valid UTF-8")?; pb.println(format!( " {ICON_REBUILD} {}", style("Handing over to nh for rebuild...").blue().bold() )); pb.println(frozen_bar_line(pb, ICON_PAUSE, "nh os switch"));
pb.println(format!("{}", style("".repeat(50)).blue().dim())); let status = pb.suspend(|| { Command::new("nh") .args([ "os", "switch", repo, "-H", &args.host, "--quiet", "--elevation-strategy", "sudo", ]) .current_dir(&args.repo) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status() .context("failed to spawn `nh`") })?; pb.println(format!("{}", style("".repeat(50)).blue().dim()));
if !status.success() { bail!("`nh os switch` exited with {status}"); }
let generations = run_command_capture("nixos-rebuild", &["list-generations"], &args.repo)?; let latest = generations .lines() .nth(1) .and_then(|l| l.split_whitespace().next()) .unwrap_or("unknown");
pb.println(format!( " {} {}", style("Successfully built Generation").blue().bold(), style(latest).blue().bold() )); Ok(())}