use std::fs;
use std::path::Path;
use super::error::AuthError;
pub fn derive_repo_full_name(workspace_root: &Path) -> Result<String, AuthError> {
let config_path = workspace_root.join(".git").join("config");
if !config_path.is_file() {
return Err(AuthError::Malformed(format!(
"no .git/config at {} — pass `--repo <owner/repo>` to scope the token explicitly",
config_path.display()
)));
}
let raw = fs::read_to_string(&config_path)
.map_err(|e| AuthError::Malformed(format!("read {}: {e}", config_path.display())))?;
let url = extract_origin_url(&raw).ok_or_else(|| {
AuthError::Malformed(format!(
"no `[remote \"origin\"]` url in {} — pass `--repo <owner/repo>`",
config_path.display()
))
})?;
parse_github_url(&url).ok_or_else(|| {
AuthError::Malformed(format!(
"remote.origin.url `{url}` doesn't look like a GitHub URL — pass `--repo <owner/repo>`"
))
})
}
fn extract_origin_url(config: &str) -> Option<String> {
let mut in_origin = false;
for raw_line in config.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') {
let inside = line.trim_start_matches('[').trim_end_matches(']').trim();
in_origin = inside.eq_ignore_ascii_case("remote \"origin\"");
continue;
}
if in_origin {
if let Some((key, value)) = line.split_once('=') {
if key.trim().eq_ignore_ascii_case("url") {
return Some(value.trim().to_string());
}
}
}
}
None
}
fn parse_github_url(url: &str) -> Option<String> {
if let Some(rest) = url
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))
{
return parse_owner_slash_repo(rest);
}
if let Some(rest) = url.strip_prefix("git@github.com:") {
return parse_owner_slash_repo(rest);
}
if let Some(rest) = url.strip_prefix("ssh://git@github.com/") {
return parse_owner_slash_repo(rest);
}
None
}
fn parse_owner_slash_repo(rest: &str) -> Option<String> {
let cleaned = rest.trim_end_matches('/').trim_end_matches(".git");
let mut parts = cleaned.splitn(3, '/');
let owner = parts.next()?.trim();
let repo = parts.next()?.trim();
if owner.is_empty() || repo.is_empty() {
return None;
}
if owner.chars().any(|c| c.is_whitespace()) || repo.chars().any(|c| c.is_whitespace()) {
return None;
}
Some(format!("{owner}/{repo}"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn extract_origin_url_finds_url_in_origin_section() {
let config = r#"
[core]
repositoryformatversion = 0
[remote "origin"]
url = https://github.com/foo/bar.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
remote = origin
"#;
assert_eq!(
extract_origin_url(config).as_deref(),
Some("https://github.com/foo/bar.git")
);
}
#[test]
fn extract_origin_url_ignores_other_remote_sections() {
let config = r#"
[remote "upstream"]
url = https://github.com/upstream/fork.git
[remote "origin"]
url = https://github.com/me/repo.git
"#;
assert_eq!(
extract_origin_url(config).as_deref(),
Some("https://github.com/me/repo.git")
);
}
#[test]
fn extract_origin_url_handles_comments_and_blank_lines() {
let config = "# leading comment\n\n[remote \"origin\"]\n; mid comment\n url = git@github.com:o/r.git\n";
assert_eq!(
extract_origin_url(config).as_deref(),
Some("git@github.com:o/r.git")
);
}
#[test]
fn extract_origin_url_returns_none_when_no_origin_section() {
let config = "[core]\n repositoryformatversion = 0\n";
assert_eq!(extract_origin_url(config), None);
}
#[test]
fn parse_github_url_https_form_strips_git_suffix() {
assert_eq!(
parse_github_url("https://github.com/foo/bar.git").as_deref(),
Some("foo/bar")
);
assert_eq!(
parse_github_url("https://github.com/foo/bar").as_deref(),
Some("foo/bar")
);
}
#[test]
fn parse_github_url_ssh_short_form() {
assert_eq!(
parse_github_url("git@github.com:foo/bar.git").as_deref(),
Some("foo/bar")
);
assert_eq!(
parse_github_url("git@github.com:foo/bar").as_deref(),
Some("foo/bar")
);
}
#[test]
fn parse_github_url_ssh_url_form() {
assert_eq!(
parse_github_url("ssh://git@github.com/foo/bar.git").as_deref(),
Some("foo/bar")
);
}
#[test]
fn parse_github_url_non_github_returns_none() {
assert_eq!(parse_github_url("https://gitlab.com/foo/bar.git"), None);
assert_eq!(parse_github_url("https://example.com/foo/bar.git"), None);
assert_eq!(parse_github_url("https://bitbucket.org/foo/bar"), None);
}
#[test]
fn parse_github_url_rejects_short_paths() {
assert_eq!(parse_github_url("https://github.com/foo"), None);
assert_eq!(parse_github_url("git@github.com:foo"), None);
}
#[test]
fn derive_repo_full_name_reads_workspace_dot_git_config() {
let tmp = TempDir::new().unwrap();
let git_dir = tmp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
std::fs::write(
git_dir.join("config"),
r#"
[core]
repositoryformatversion = 0
[remote "origin"]
url = https://github.com/owner/repo.git
"#,
)
.unwrap();
let r = derive_repo_full_name(tmp.path()).expect("derive ok");
assert_eq!(r, "owner/repo");
}
#[test]
fn derive_repo_full_name_without_dot_git_yields_helpful_error() {
let tmp = TempDir::new().unwrap();
let err = derive_repo_full_name(tmp.path()).expect_err("should fail");
match err {
AuthError::Malformed(m) => {
assert!(m.contains("no .git/config"), "got: {m}");
assert!(m.contains("--repo"), "got: {m}");
}
other => panic!("expected Malformed, got {other:?}"),
}
}
#[test]
fn derive_repo_full_name_with_non_github_remote_yields_helpful_error() {
let tmp = TempDir::new().unwrap();
let git_dir = tmp.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
std::fs::write(
git_dir.join("config"),
r#"
[remote "origin"]
url = https://gitlab.com/foo/bar.git
"#,
)
.unwrap();
let err = derive_repo_full_name(tmp.path()).expect_err("should fail");
match err {
AuthError::Malformed(m) => {
assert!(m.contains("doesn't look like a GitHub URL"), "got: {m}");
assert!(m.contains("--repo"), "got: {m}");
}
other => panic!("expected Malformed, got {other:?}"),
}
}
}