use serde::Serialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(test, derive(serde::Deserialize))]
#[cfg_attr(test, serde(deny_unknown_fields))]
pub(super) struct RepoEntry {
pub path: String,
pub owner: Option<String>,
pub name: String,
}
pub(super) fn describe_repos(paths: Vec<String>) -> Vec<RepoEntry> {
paths
.into_iter()
.map(|path| {
let owner_name = origin_url(Path::new(&path))
.as_deref()
.and_then(github_owner_name);
let (owner, name) = match owner_name {
Some((owner, name)) => (Some(owner), name),
None => (None, basename(Path::new(&path))),
};
RepoEntry { path, owner, name }
})
.collect()
}
fn basename(p: &Path) -> String {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}
fn origin_url(repo: &Path) -> Option<String> {
let text = std::fs::read_to_string(git_dir(repo)?.join("config")).ok()?;
parse_origin_url(&text)
}
fn git_dir(repo: &Path) -> Option<PathBuf> {
let dot_git = repo.join(".git");
if dot_git.is_dir() {
return Some(dot_git);
}
let pointer = std::fs::read_to_string(&dot_git).ok()?;
let target = pointer.trim().strip_prefix("gitdir:")?.trim();
let worktree_dir = repo.join(target);
let common = std::fs::read_to_string(worktree_dir.join("commondir")).ok()?;
Some(worktree_dir.join(common.trim()))
}
fn parse_origin_url(config: &str) -> Option<String> {
let mut in_origin = false;
for line in config.lines() {
let line = line.trim();
if line.starts_with('[') {
in_origin = line
.replace(' ', "")
.eq_ignore_ascii_case("[remote\"origin\"]");
} else if in_origin {
if let Some(rest) = line.strip_prefix("url") {
if let Some(value) = rest.trim_start().strip_prefix('=') {
return Some(value.trim().to_string());
}
}
}
}
None
}
fn split_authority(url: &str) -> Option<(&str, &str)> {
match url.split_once("://") {
Some((_scheme, rest)) => rest.split_once('/'),
None => url.split_once(':'),
}
}
fn host_of(authority: &str) -> &str {
let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
host.split_once(':').map_or(host, |(h, _)| h)
}
fn github_owner_name(url: &str) -> Option<(String, String)> {
let (authority, path) = split_authority(url.trim())?;
if !host_of(authority).eq_ignore_ascii_case("github.com") {
return None;
}
let mut segments = path.trim_end_matches('/').split('/');
let owner = segments.next()?;
let name = segments.next()?;
if segments.next().is_some() || owner.is_empty() || name.is_empty() {
return None;
}
let name = name.strip_suffix(".git").unwrap_or(name);
if name.is_empty() {
return None;
}
Some((owner.to_string(), name.to_string()))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn github_urls_parse_to_owner_and_name() {
let cases = [
("git@github.com:konjoai/lopi.git", Some(("konjoai", "lopi"))),
(
"https://github.com/konjoai/lopi.git",
Some(("konjoai", "lopi")),
),
("https://github.com/konjoai/lopi", Some(("konjoai", "lopi"))),
(
"https://github.com/konjoai/lopi/",
Some(("konjoai", "lopi")),
),
(
"ssh://git@github.com/konjoai/lopi.git",
Some(("konjoai", "lopi")),
),
(
"ssh://git@github.com:22/konjoai/lopi.git",
Some(("konjoai", "lopi")),
),
(
"git://github.com/konjoai/lopi.git",
Some(("konjoai", "lopi")),
),
(
"https://user:p@ss@github.com/konjoai/lopi.git",
Some(("konjoai", "lopi")),
),
(
"https://GitHub.com/konjoai/lopi.git",
Some(("konjoai", "lopi")),
),
(
"https://github.com/wesleyscholl/wesleyscholl.github.io.git",
Some(("wesleyscholl", "wesleyscholl.github.io")),
),
(
"https://huggingface.co/datasets/roneneldan/TinyStories",
None,
),
("https://github.com.evil.test/konjoai/lopi.git", None),
("https://notgithub.com/konjoai/lopi.git", None),
("https://github.com/konjoai", None),
("https://github.com/a/b/c", None),
("https://github.com", None),
("", None),
];
for (url, want) in cases {
let got = github_owner_name(url);
let want = want.map(|(o, n)| (o.to_string(), n.to_string()));
assert_eq!(got, want, "parsing {url:?}");
}
}
#[test]
fn origin_section_wins_over_other_remotes() {
let config = r#"
[core]
bare = false
[remote "upstream"]
url = https://github.com/upstream-org/thing.git
fetch = +refs/heads/*:refs/remotes/upstream/*
[remote "origin"]
url = https://github.com/wesleyscholl/thing.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
remote = origin
"#;
assert_eq!(
parse_origin_url(config).as_deref(),
Some("https://github.com/wesleyscholl/thing.git")
);
}
#[test]
fn a_key_merely_starting_with_url_is_not_the_url() {
let config = "[remote \"origin\"]\n\turlx = https://github.com/a/b.git\n";
assert_eq!(parse_origin_url(config), None);
}
#[test]
fn missing_origin_section_yields_none() {
let config =
"[core]\n\tbare = false\n[remote \"upstream\"]\n\turl = https://github.com/o/n.git\n";
assert_eq!(parse_origin_url(config), None);
}
#[test]
fn unlabelled_repos_survive_with_a_basename() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("no-origin-repo");
std::fs::create_dir_all(repo.join(".git")).unwrap();
std::fs::write(repo.join(".git").join("config"), "[core]\n\tbare = false\n").unwrap();
let got = describe_repos(vec![repo.display().to_string()]);
assert_eq!(got.len(), 1, "the repo is still listed");
assert_eq!(got[0].owner, None);
assert_eq!(
got[0].name, "no-origin-repo",
"falls back to the directory name"
);
assert_eq!(
got[0].path,
repo.display().to_string(),
"the path is untouched"
);
}
#[test]
fn golden_fixture_matches_the_wire_shape() {
let raw = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/repo_menu_golden.json"
))
.expect("golden fixture is readable");
let doc: serde_json::Value = serde_json::from_str(&raw).expect("golden fixture is JSON");
let repos_json = doc.get("repos").expect("fixture has a `repos` array");
let entries: Vec<RepoEntry> =
serde_json::from_value(repos_json.clone()).expect("`repos` decodes as RepoEntry");
assert_eq!(
serde_json::to_value(&entries).unwrap(),
*repos_json,
"the fixture round-trips through RepoEntry unchanged"
);
assert!(
entries.iter().any(|e| e.owner.is_none()),
"the fixture must keep covering a repo with no GitHub identity"
);
}
#[test]
fn linked_worktree_resolves_its_owner_through_commondir() {
let tmp = tempfile::tempdir().unwrap();
let main = tmp.path().join("squish");
let wt_meta = main.join(".git").join("worktrees").join("feature");
std::fs::create_dir_all(&wt_meta).unwrap();
std::fs::write(
main.join(".git").join("config"),
"[remote \"origin\"]\n\turl = https://github.com/konjoai/squish.git\n",
)
.unwrap();
std::fs::write(wt_meta.join("commondir"), "../..\n").unwrap();
let worktree = tmp.path().join("squish-feature");
std::fs::create_dir_all(&worktree).unwrap();
std::fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", wt_meta.display()),
)
.unwrap();
let got = describe_repos(vec![worktree.display().to_string()]);
assert_eq!(got[0].owner.as_deref(), Some("konjoai"));
assert_eq!(got[0].name, "squish");
assert_eq!(got[0].path, worktree.display().to_string());
}
}