sluuz 0.10.6

git, but it sleuths - a git superset with cross-repo search, secret scanning, and multi-repo management
//! `slu sync` - fetch (and optionally fast-forward) every repo under a path in
//! parallel.
//!
//! By default it only fetches + prunes, which never touches your working tree.
//! With `--pull` it also fast-forwards the current branch to its upstream
//! (`load::fast_forward`), which refuses rather than merging, so it can never
//! create a merge commit or a conflict.

use crate::git::{display_name, first_line, git_run, repos_or_exit, with_remote_limit};
use colored::Colorize;
use rayon::prelude::*;
use std::path::PathBuf;

#[derive(clap::Args)]
pub struct Args {
    /// Base directory to search for repos (defaults to current directory)
    #[arg(default_value = ".")]
    pub path: PathBuf,

    /// How many directory levels deep to look for repos
    #[arg(short, long, default_value_t = 3)]
    pub depth: usize,

    /// After fetching, fast-forward the current branch (git merge --ff-only)
    #[arg(long)]
    pub pull: bool,
}

enum State {
    Ok,
    Skip,
    Fail,
}

struct Outcome {
    name: String,
    state: State,
    note: String,
}

pub fn run(args: Args) {
    let repos = repos_or_exit(&args.path, args.depth);

    outln!(
        "{} {} repo(s)…\n",
        if args.pull {
            "Fetching + pulling"
        } else {
            "Fetching"
        }
        .dimmed(),
        repos.len()
    );

    let mut outcomes: Vec<Outcome> = with_remote_limit(|| {
        repos
            .par_iter()
            .filter_map(|repo| {
                let name = display_name(repo);
                let repo_str = repo.to_str()?;
                Some(process(name, repo_str, args.pull))
            })
            .collect()
    });

    outcomes.sort_by(|a, b| a.name.cmp(&b.name));

    let name_w = outcomes.iter().map(|o| o.name.len()).max().unwrap_or(0);
    let mut failed = 0usize;

    for o in &outcomes {
        let mark = match o.state {
            State::Ok => "✓".green(),
            State::Skip => "–".dimmed(),
            State::Fail => {
                failed += 1;
                "✗".red()
            }
        };
        outln!("  {}  {:name_w$}  {}", mark, o.name.bold(), o.note.dimmed());
    }

    outln!();
    let summary = format!("{} repos · {} failed", outcomes.len(), failed);
    if failed > 0 {
        outln!("{}", summary.red());
    } else {
        outln!("{}", summary.dimmed());
    }
}

fn process(name: String, repo: &str, pull: bool) -> Outcome {
    let (ok, out) = git_run(repo, &["fetch", "--all", "--prune"]);
    if !ok {
        return Outcome {
            name,
            state: State::Fail,
            note: first_line(&out),
        };
    }

    if !pull {
        // Fetch output is empty when nothing new arrived.
        let note = if out.is_empty() {
            "up to date"
        } else {
            "fetched"
        };
        return Outcome {
            name,
            state: State::Ok,
            note: note.to_string(),
        };
    }

    let (state, note) = match crate::git::load::fast_forward(repo) {
        Ok(Some(0)) => (State::Ok, "up to date".to_string()),
        Ok(Some(_)) => (State::Ok, "fast-forwarded".to_string()),
        // A missing upstream isn't a failure - there's just nothing to pull.
        Ok(None) => (State::Skip, "fetched, no upstream to pull".to_string()),
        Err(words) => (State::Fail, first_line(&words)),
    };
    Outcome { name, state, note }
}