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> {
WalkDir::new(base)
.max_depth(max_depth)
.follow_links(false)
.into_iter()
.filter_map(|entry| entry.ok()) .filter(|entry| entry.file_name() == ".git" && entry.file_type().is_dir())
.filter_map(|entry| entry.path().parent().map(Path::to_path_buf)) .collect()
}
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 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::short_remote;
#[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/projects/sluuz/test-playground/crates/vibox.git"),
"…/crates/vibox"
);
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(""), "");
}
}