pub mod load;
use std::path::{Path, PathBuf};
use std::process::Command;
use walkdir::WalkDir;
pub const SEP: char = '\u{1f}';
pub fn find_repos(base: &Path, max_depth: usize) -> Vec<PathBuf> {
let found: Vec<PathBuf> = WalkDir::new(base)
.max_depth(max_depth)
.follow_links(false)
.into_iter()
.filter_map(|entry| entry.ok()) .filter(|entry| entry.file_name() == ".git")
.filter_map(|entry| entry.path().parent().map(Path::to_path_buf))
.collect();
if !found.is_empty() {
return found;
}
base.to_str()
.and_then(|b| git_capture(b, &["rev-parse", "--show-toplevel"]))
.map(PathBuf::from)
.into_iter()
.collect()
}
pub fn repos_or_exit(base: &Path, max_depth: usize) -> Vec<PathBuf> {
let repos = find_repos(base, max_depth);
if repos.is_empty() {
eprintln!("{}", no_repos(base, max_depth));
std::process::exit(1);
}
repos
}
pub fn no_repos(base: &Path, max_depth: usize) -> String {
format!(
"no git repos in or under `{}` (looked {max_depth} levels down) - run it inside a repo, or point it at a folder that holds some",
base.display()
)
}
pub struct RepoStatus {
pub name: String,
pub path: String,
pub branch: String,
pub has_upstream: bool,
pub dirty: usize,
pub ahead: usize,
pub behind: usize,
pub origin: String,
}
impl RepoStatus {
pub fn needs_attention(&self) -> bool {
self.dirty > 0 || self.ahead > 0 || self.behind > 0
}
pub fn is_dirty(&self) -> bool {
self.dirty > 0
}
pub fn is_unpushed(&self) -> bool {
self.ahead > 0
}
}
pub fn repo_status(repo: &Path) -> RepoStatus {
let path = repo.to_string_lossy().into_owned();
let out = git_capture(&path, &["status", "--porcelain=2", "--branch"]).unwrap_or_default();
let mut branch = "(detached)".to_string();
let mut has_upstream = false;
let mut ahead = 0usize;
let mut behind = 0usize;
let mut dirty = 0usize;
for line in out.lines() {
if let Some(rest) = line.strip_prefix("# branch.head ") {
branch = rest.to_string();
} else if line.starts_with("# branch.upstream ") {
has_upstream = true;
} else if let Some(rest) = line.strip_prefix("# branch.ab ") {
for tok in rest.split_whitespace() {
if let Some(a) = tok.strip_prefix('+') {
ahead = a.parse().unwrap_or(0);
} else if let Some(b) = tok.strip_prefix('-') {
behind = b.parse().unwrap_or(0);
}
}
} else if !line.starts_with('#') && !line.is_empty() {
dirty += 1;
}
}
let origin = git_capture(&path, &["config", "--get", "remote.origin.url"])
.map(|u| short_remote(&u))
.unwrap_or_default();
RepoStatus {
name: display_name(repo),
path,
branch,
has_upstream,
dirty,
ahead,
behind,
origin,
}
}
pub fn short_remote(url: &str) -> String {
let url = url.trim();
let url = url.strip_suffix('/').unwrap_or(url);
let stripped = url.strip_suffix(".git").unwrap_or(url);
if let Some((_, rest)) = stripped.split_once("://") {
let rest = rest.rsplit('@').next().unwrap_or(rest); let Some((hostport, path)) = rest.split_once('/') else {
return stripped.to_string();
};
let host = hostport.split(':').next().unwrap_or(hostport);
return format!("{host}:{path}");
}
if let Some((hostpart, path)) = stripped.split_once(':')
&& !path.starts_with('\\')
&& !path.starts_with('/')
&& hostpart.contains('.')
{
let host = hostpart.rsplit('@').next().unwrap_or(hostpart);
return format!("{host}:{path}");
}
let parts: Vec<&str> = stripped
.split(['/', '\\'])
.filter(|p| !p.is_empty() && *p != ".")
.collect();
if parts.len() > 2 {
return format!("…/{}", parts[parts.len() - 2..].join("/"));
}
stripped.to_string()
}
pub fn display_name(repo: &Path) -> String {
repo.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| repo.to_string_lossy().into_owned())
}
pub fn git_capture(repo: &str, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn git_capture_raw(repo: &str, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn prunes_on_fetch(repo: &str) -> bool {
for key in ["remote.origin.prune", "fetch.prune"] {
if let Some(v) = git_capture(repo, &["config", "--get", "--type=bool", key]) {
return v == "true";
}
}
false
}
pub fn first_line(s: &str) -> String {
s.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("")
.to_string()
}
const GIT_WORDS: usize = 5;
pub fn git_words(said: &str) -> String {
let mut lines: Vec<&str> = said
.lines()
.filter(|l| !l.trim().is_empty() && !l.starts_with("[GNUPG:]"))
.collect();
lines.dedup();
lines.truncate(GIT_WORDS);
lines.join("\n")
}
pub fn git_run(repo: &str, args: &[&str]) -> (bool, String) {
match Command::new("git").arg("-C").arg(repo).args(args).output() {
Ok(output) => {
let mut combined = String::from_utf8_lossy(&output.stdout).into_owned();
combined.push_str(&String::from_utf8_lossy(&output.stderr));
(output.status.success(), combined.trim().to_string())
}
Err(e) => (false, e.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::{find_repos, git_capture, git_words, prunes_on_fetch, short_remote};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
struct TempRepo(PathBuf);
impl TempRepo {
fn new(tag: &str) -> TempRepo {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!("sluuz-{tag}-{stamp}"));
fs::create_dir_all(&dir).expect("temp dir");
git_capture(dir.to_str().expect("utf-8 temp path"), &["init", "-q"]).expect("git init");
TempRepo(dir)
}
fn path(&self) -> &str {
self.0.to_str().expect("utf-8 temp path")
}
}
impl Drop for TempRepo {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn set(repo: &str, key: &str, value: &str) {
git_capture(repo, &["config", key, value]).expect("git config");
}
#[test]
fn a_remotes_own_prune_setting_beats_the_general_one() {
let dir = TempRepo::new("prune");
let repo = dir.path();
set(repo, "fetch.prune", "false");
set(repo, "remote.origin.prune", "true");
assert!(prunes_on_fetch(repo), "the remote's own setting wins");
set(repo, "fetch.prune", "true");
set(repo, "remote.origin.prune", "false");
assert!(!prunes_on_fetch(repo), "and it wins in the other direction");
}
#[test]
fn the_ssh_form_git_prints_by_default() {
assert_eq!(
short_remote("git@github.com:safteinzz/sluuz.git"),
"github.com:safteinzz/sluuz"
);
assert_eq!(
short_remote("git@gitlab.com:safteinzz/sluuz.git"),
"gitlab.com:safteinzz/sluuz"
);
}
#[test]
fn https_urls_lose_the_scheme_and_the_dot_git() {
assert_eq!(
short_remote("https://gitlab.com/safteinzz/sluuz.git"),
"gitlab.com:safteinzz/sluuz"
);
assert_eq!(
short_remote("https://github.com/safteinzz/sluuz"),
"github.com:safteinzz/sluuz"
);
}
#[test]
fn a_url_loses_its_credentials_and_its_port() {
assert_eq!(
short_remote("https://token@github.com/o/r.git"),
"github.com:o/r"
);
assert_eq!(
short_remote("ssh://git@host.example:2222/o/r.git"),
"host.example:o/r"
);
}
#[test]
fn a_self_hosted_path_keeps_every_segment() {
assert_eq!(
short_remote("https://git.example.com/team/group/sub/repo.git"),
"git.example.com:team/group/sub/repo"
);
}
#[test]
fn a_local_remote_is_named_by_its_tail() {
assert_eq!(
short_remote("/home/me/mirrors/team/crates/widget.git"),
"…/crates/widget"
);
assert_eq!(short_remote("../mirror/repo.git"), "…/mirror/repo");
assert_eq!(short_remote("/srv/repo.git"), "/srv/repo");
}
#[test]
fn something_unparseable_comes_back_as_it_went_in() {
assert_eq!(short_remote("weird"), "weird");
assert_eq!(short_remote(""), "");
}
#[test]
fn a_scan_from_a_subfolder_finds_the_enclosing_repo() {
let dir = TempRepo::new("subfolder");
let deep = dir.0.join("app").join("src");
fs::create_dir_all(&deep).expect("subfolder");
let found: Vec<PathBuf> = find_repos(&deep, 3)
.iter()
.map(|p| p.canonicalize().expect("found repo exists"))
.collect();
let root = dir.0.canonicalize().expect("temp repo exists");
assert_eq!(
found,
[root],
"a scan from two folders down should find the repo above"
);
}
#[test]
fn a_worktree_counts_as_a_repo() {
let dir = TempRepo::new("worktree");
let repo = dir.path();
let author = [
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"-c",
"commit.gpgsign=false",
];
let mut commit = author.to_vec();
commit.extend(["commit", "-q", "--allow-empty", "-m", "one"]);
git_capture(repo, &commit).expect("commit");
let wt = dir.0.join("wt");
let wt_str = wt.to_str().expect("utf-8 temp path");
git_capture(repo, &["worktree", "add", "-q", "-b", "side", wt_str]).expect("worktree");
let found = find_repos(&dir.0, 3);
assert!(
found.contains(&wt),
"the worktree should be listed, got {found:?}"
);
assert!(
found.contains(&dir.0),
"and so should the repo it belongs to, got {found:?}"
);
}
#[test]
fn a_box_shows_at_most_five_lines_of_git_and_no_gpg_status() {
let said = "error: gpg failed to sign the data:\n\
[GNUPG:] KEY_CONSIDERED 0123456789ABCDEF0123456789ABCDEF01234567 2\n\
[GNUPG:] BEGIN_SIGNING H10\n\
gpg: signing failed: Operation cancelled\n\
[GNUPG:] FAILURE sign 83886179\n\
gpg: signing failed: Operation cancelled\n\
\n\
fatal: failed to write commit object";
let words = git_words(said);
assert_eq!(
words.lines().collect::<Vec<_>>(),
[
"error: gpg failed to sign the data:",
"gpg: signing failed: Operation cancelled",
"fatal: failed to write commit object",
],
"gpg's status lines, the blank and the repeat should go, and git's last line stay"
);
let long: Vec<String> = (1..=9).map(|n| format!("remote: line {n}")).collect();
let words = git_words(&long.join("\n"));
assert_eq!(
words.lines().count(),
5,
"a long complaint should be cut to five lines, got:\n{words}"
);
}
}