use std::path::Path;
use std::process::Command;
use crate::github_path::{self, GithubPath};
const CONTENT_HASH_PREFIX: &str = "content:";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepoIdentity {
GitHub(GithubPath),
ContentHash(String),
}
impl RepoIdentity {
pub fn derive(dir: &Path) -> Option<RepoIdentity> {
if let Some(gp) = github_path::derive_github_path(dir) {
return Some(RepoIdentity::GitHub(gp));
}
root_commit_sha(dir).map(RepoIdentity::ContentHash)
}
pub fn canonical(&self) -> String {
match self {
RepoIdentity::GitHub(gp) => gp.rel_path(),
RepoIdentity::ContentHash(sha) => format!("{CONTENT_HASH_PREFIX}{sha}"),
}
}
pub fn parse(s: &str) -> Option<RepoIdentity> {
let trimmed = s.trim();
if trimmed.is_empty() {
return None;
}
if let Some(rest) = trimmed.strip_prefix(CONTENT_HASH_PREFIX) {
let sha = rest.trim();
return (!sha.is_empty()).then(|| RepoIdentity::ContentHash(sha.to_string()));
}
github_path::parse_owner_repo(trimmed).map(RepoIdentity::GitHub)
}
}
impl std::fmt::Display for RepoIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.canonical())
}
}
fn root_commit_sha(dir: &Path) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-list", "--max-parents=0", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_round_trips() {
let gh = RepoIdentity::GitHub(GithubPath {
owner: "bobmatnyc".into(),
repo: "trusty-tools".into(),
});
assert_eq!(gh.canonical(), "bobmatnyc/trusty-tools");
assert_eq!(RepoIdentity::parse(&gh.canonical()), Some(gh));
let ch = RepoIdentity::ContentHash("abc123def".into());
assert_eq!(ch.canonical(), "content:abc123def");
assert_eq!(RepoIdentity::parse(&ch.canonical()), Some(ch));
}
#[test]
fn parse_normalises_case() {
assert_eq!(
RepoIdentity::parse("BobMatNyc/Trusty_Tools").map(|r| r.canonical()),
Some("bobmatnyc/trusty-tools".to_string())
);
}
#[test]
fn parse_empty_is_none() {
assert_eq!(RepoIdentity::parse(""), None);
assert_eq!(RepoIdentity::parse(" "), None);
assert_eq!(RepoIdentity::parse("content:"), None);
assert_eq!(RepoIdentity::parse("content: "), None);
}
#[test]
fn derive_uses_github_remote() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let dir = tmp.path();
if !git_ok(dir, &["init"]) {
return; }
let _ = git_ok(
dir,
&[
"remote",
"add",
"origin",
"git@github.com:bobmatnyc/trusty-tools.git",
],
);
assert_eq!(
RepoIdentity::derive(dir).map(|r| r.canonical()),
Some("bobmatnyc/trusty-tools".to_string())
);
}
#[test]
fn derive_falls_back_to_content_hash() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let dir = tmp.path();
if !git_ok(dir, &["init"]) {
return;
}
let _ = git_ok(dir, &["config", "user.email", "t@t.test"]);
let _ = git_ok(dir, &["config", "user.name", "t"]);
std::fs::write(dir.join("README.md"), "hi").unwrap();
let _ = git_ok(dir, &["add", "."]);
if !git_ok(dir, &["commit", "-m", "init"]) {
return; }
match RepoIdentity::derive(dir) {
Some(RepoIdentity::ContentHash(sha)) => {
assert!(!sha.is_empty(), "root-commit sha must be non-empty");
assert!(!sha.contains('/'), "content hash must have no slash");
}
other => panic!("expected ContentHash fallback, got {other:?}"),
}
}
#[test]
fn derive_none_outside_repo() {
let tmp = tempfile::TempDir::new().expect("tempdir");
assert_eq!(RepoIdentity::derive(tmp.path()), None);
}
fn git_ok(dir: &Path, args: &[&str]) -> bool {
Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
}