use crate::{config, crosshub, gitcmd, repomap, repos, transport};
use anyhow::Result;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
struct Candidate {
path: PathBuf,
shorthand: Option<String>,
root_sha: Option<String>,
}
#[derive(Default)]
pub(crate) struct Report {
pub(crate) mapped: Vec<(String, PathBuf)>,
pub(crate) unmatched: Vec<(String, Option<String>)>,
}
fn default_roots() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Ok(home) = config::home() {
for d in ["git", "src", "code", "Projects", "dev", "repos"] {
out.push(home.join(d));
}
out.push(home);
}
for hub in crosshub::hub_dirs() {
if let Some(parent) = hub.parent() {
out.push(parent.to_path_buf());
}
}
out
}
fn is_git_repo(dir: &Path) -> bool {
dir.is_dir()
&& gitcmd::output(dir, &["rev-parse", "--git-dir"])
.map(|o| o.status.success())
.unwrap_or(false)
}
fn canonical_origin(dir: &Path) -> Option<String> {
let o = gitcmd::output(dir, &["config", "--get", "remote.origin.url"]).ok()?;
if !o.status.success() {
return None;
}
let url = String::from_utf8_lossy(&o.stdout).trim().to_string();
if url.is_empty() {
return None;
}
transport::parse_remote(&url).shorthand
}
fn scan_root(root: &Path) -> Vec<Candidate> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(root) else {
return out;
};
for e in entries.flatten() {
let p = e.path();
if !is_git_repo(&p) {
continue;
}
out.push(Candidate {
shorthand: canonical_origin(&p),
root_sha: crosshub::root_sha(&p),
path: p,
});
}
out
}
fn scan_all(roots: &[PathBuf]) -> Vec<Candidate> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for root in roots {
let canon = root.canonicalize().unwrap_or_else(|_| root.clone());
if !seen.insert(canon) {
continue;
}
out.extend(scan_root(root));
}
out
}
fn same_dir(a: &Path, b: &Path) -> bool {
match (a.canonicalize(), b.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
fn all_hub_dirs() -> Vec<PathBuf> {
let mut dirs = crosshub::hub_dirs();
if let Ok(cur) = config::repo_root() {
if !dirs.iter().any(|d| same_dir(d, &cur)) {
dirs.push(cur);
}
}
dirs
}
fn registered_repos() -> Vec<(String, repos::Repo)> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for hub in all_hub_dirs() {
for (slug, card) in repos::load(&hub) {
if seen.insert(slug.clone()) {
out.push((slug, card));
}
}
}
out
}
fn is_match(card: &repos::Repo, candidate: &Candidate) -> bool {
if let (Some(want), Some(got)) = (&card.root_sha, &candidate.root_sha) {
return want == got;
}
if let (Some(url), Some(got)) = (&card.url, &candidate.shorthand) {
if let Some(want) = transport::parse_remote(url).shorthand {
return &want == got;
}
}
false
}
fn find_match<'a>(card: &repos::Repo, candidates: &'a [Candidate]) -> Option<&'a Candidate> {
candidates.iter().find(|c| is_match(card, c))
}
pub(crate) fn run(roots: &[PathBuf]) -> Result<Report> {
let roots: Vec<PathBuf> = if roots.is_empty() { default_roots() } else { roots.to_vec() };
let candidates = scan_all(&roots);
let mut report = Report::default();
for (slug, card) in registered_repos() {
if repomap::path(&slug).is_some() {
continue; }
match find_match(&card, &candidates) {
Some(c) => {
let abs = repomap::set(&slug, &c.path)?;
report.mapped.push((slug, abs));
}
None => report.unmatched.push((slug, card.url.clone())),
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn card(url: Option<&str>, root_sha: Option<&str>) -> repos::Repo {
repos::Repo {
url: url.map(String::from),
root_sha: root_sha.map(String::from),
..Default::default()
}
}
fn candidate(shorthand: Option<&str>, root_sha: Option<&str>) -> Candidate {
Candidate {
path: PathBuf::from("/tmp/whatever"),
shorthand: shorthand.map(String::from),
root_sha: root_sha.map(String::from),
}
}
#[test]
fn matches_via_canonicalized_url_regardless_of_scheme() {
let c = card(Some("git@github.com:codeshrew/git-conversations.git"), None);
let cand = candidate(Some("codeshrew/git-conversations"), None);
assert!(is_match(&c, &cand));
}
#[test]
fn matches_via_root_sha_even_with_different_owners() {
let c = card(Some("https://example.com/original/fork.git"), Some("abc123"));
let cand = candidate(Some("someone-else/renamed-fork"), Some("abc123"));
assert!(is_match(&c, &cand));
}
#[test]
fn root_sha_mismatch_beats_url_match_being_absent() {
let c = card(None, Some("abc123"));
let cand = candidate(None, Some("def456"));
assert!(!is_match(&c, &cand));
}
#[test]
fn no_shared_signal_is_no_match() {
let c = card(Some("git@github.com:o/foo.git"), None);
let cand = candidate(Some("o/bar"), None);
assert!(!is_match(&c, &cand));
}
#[test]
fn find_match_returns_none_when_nothing_matches() {
let c = card(Some("git@github.com:o/foo.git"), None);
let candidates = vec![candidate(Some("o/bar"), None), candidate(None, Some("zzz"))];
assert!(find_match(&c, &candidates).is_none());
}
}