use anyhow::bail;
use crate::git::Git;
use crate::model::config::GitHubConfig;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitHubRepo {
pub owner: String,
pub repo: String,
}
impl GitHubRepo {
pub fn new(owner: impl Into<String>, repo: impl Into<String>) -> anyhow::Result<Self> {
let owner = owner.into();
let repo = repo.into();
Self::validate_identifier(&owner, "owner")?;
Self::validate_identifier(&repo, "repo")?;
Ok(Self { owner, repo })
}
fn validate_identifier(value: &str, field: &str) -> anyhow::Result<()> {
if value.is_empty()
|| !value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
anyhow::bail!("Invalid GitHub {field}: {value:?}");
}
Ok(())
}
fn parse_url(url: &str) -> Option<Self> {
let url = url.trim();
let path = if let Some(rest) = url.strip_prefix("https://github.com") {
let rest = strip_optional_port(rest)?;
rest.strip_prefix('/')?
} else if let Some(rest) = url.strip_prefix("ssh://") {
let rest = rest.split_once('@').map_or(rest, |(_, after)| after);
let rest = rest.strip_prefix("github.com")?;
let rest = strip_optional_port(rest)?;
rest.strip_prefix('/')?
} else {
url.strip_prefix("git@github.com:")?
};
let path = path.strip_suffix(".git").unwrap_or(path);
let (owner, repo) = path.split_once('/')?;
GitHubRepo::new(owner, repo).ok()
}
pub(crate) async fn detect_in(git: &dyn Git) -> anyhow::Result<Option<Self>> {
match git.remote_origin_url().await? {
Some(url) => Ok(Self::parse_url(&url)),
None => Ok(None),
}
}
pub async fn resolve(github_config: &GitHubConfig, git: &dyn Git) -> anyhow::Result<Self> {
match (github_config.owner(), github_config.repo()) {
(Some(owner), Some(repo)) => {
return GitHubRepo::new(owner, repo);
}
(Some(_), None) | (None, Some(_)) => bail!(
"[github].owner and [github].repo must be set together; \
set both or omit both for auto-detection."
),
(None, None) => {}
}
match Self::detect_in(git).await? {
Some(gh_repo) => Ok(gh_repo),
None => bail!(
"Could not determine GitHub repository. Set [github] owner and repo in config, \
or ensure the git remote 'origin' points to a GitHub repository."
),
}
}
}
fn strip_optional_port(s: &str) -> Option<&str> {
let Some(after_colon) = s.strip_prefix(':') else {
return Some(s);
};
let digit_end = after_colon
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(after_colon.len());
if digit_end == 0 {
return None;
}
Some(&after_colon[digit_end..])
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::command::CommandRunner;
use crate::command::test_support::RecordingCommandRunner;
use crate::git::GitWorkdir;
fn workdir() -> crate::path::AbsolutePath {
crate::path::AbsolutePath::new("/tmp").unwrap()
}
#[tokio::test]
async fn parse_https_with_git_suffix() {
let result = GitHubRepo::parse_url("https://github.com/owner/repo.git");
assert_eq!(result, Some(GitHubRepo::new("owner", "repo").unwrap()));
}
#[tokio::test]
async fn parse_https_without_git_suffix() {
let result = GitHubRepo::parse_url("https://github.com/owner/repo");
assert_eq!(result, Some(GitHubRepo::new("owner", "repo").unwrap()));
}
#[tokio::test]
async fn parse_ssh_with_git_suffix() {
let result = GitHubRepo::parse_url("git@github.com:owner/repo.git");
assert_eq!(result, Some(GitHubRepo::new("owner", "repo").unwrap()));
}
#[tokio::test]
async fn parse_ssh_without_git_suffix() {
let result = GitHubRepo::parse_url("git@github.com:owner/repo");
assert_eq!(result, Some(GitHubRepo::new("owner", "repo").unwrap()));
}
#[tokio::test]
async fn parse_non_github_https_returns_none() {
assert!(GitHubRepo::parse_url("https://gitlab.com/owner/repo.git").is_none());
}
#[tokio::test]
async fn parse_non_github_ssh_returns_none() {
assert!(GitHubRepo::parse_url("git@gitlab.com:owner/repo.git").is_none());
}
#[tokio::test]
async fn parse_empty_returns_none() {
assert!(GitHubRepo::parse_url("").is_none());
}
#[tokio::test]
async fn parse_malformed_returns_none() {
assert!(GitHubRepo::parse_url("not-a-url").is_none());
}
#[tokio::test]
async fn parse_extra_path_segments_returns_none() {
assert!(GitHubRepo::parse_url("https://github.com/owner/repo/extra").is_none());
}
#[tokio::test]
async fn parse_ssh_extra_path_segments_returns_none() {
assert!(GitHubRepo::parse_url("git@github.com:owner/repo/extra").is_none());
}
#[tokio::test]
async fn parse_trailing_slash_returns_none() {
assert!(GitHubRepo::parse_url("https://github.com/owner/repo/").is_none());
}
#[tokio::test]
async fn parse_ssh_url_with_git_suffix() {
let result = GitHubRepo::parse_url("ssh://git@github.com/owner/repo.git");
assert_eq!(result, Some(GitHubRepo::new("owner", "repo").unwrap()));
}
#[tokio::test]
async fn parse_ssh_url_without_git_suffix() {
let result = GitHubRepo::parse_url("ssh://git@github.com/owner/repo");
assert_eq!(result, Some(GitHubRepo::new("owner", "repo").unwrap()));
}
#[tokio::test]
async fn parse_ssh_url_without_user() {
let result = GitHubRepo::parse_url("ssh://github.com/owner/repo.git");
assert_eq!(result, Some(GitHubRepo::new("owner", "repo").unwrap()));
}
#[tokio::test]
async fn parse_ssh_url_with_port() {
let result = GitHubRepo::parse_url("ssh://git@github.com:22/owner/repo.git");
assert_eq!(result, Some(GitHubRepo::new("owner", "repo").unwrap()));
}
#[tokio::test]
async fn parse_https_with_port() {
let result = GitHubRepo::parse_url("https://github.com:443/owner/repo.git");
assert_eq!(result, Some(GitHubRepo::new("owner", "repo").unwrap()));
}
#[tokio::test]
async fn parse_https_with_port_no_git_suffix() {
let result = GitHubRepo::parse_url("https://github.com:8080/owner/repo");
assert_eq!(result, Some(GitHubRepo::new("owner", "repo").unwrap()));
}
#[tokio::test]
async fn parse_https_colon_no_digits_returns_none() {
assert!(GitHubRepo::parse_url("https://github.com:/owner/repo").is_none());
}
#[tokio::test]
async fn parse_ssh_url_non_github_returns_none() {
assert!(GitHubRepo::parse_url("ssh://git@gitlab.com/owner/repo.git").is_none());
}
#[tokio::test]
async fn parse_trims_whitespace() {
let result = GitHubRepo::parse_url(" https://github.com/owner/repo.git\n");
assert_eq!(result, Some(GitHubRepo::new("owner", "repo").unwrap()));
}
#[tokio::test]
async fn detect_returns_repo_for_https_remote() {
let runner = Arc::new(
RecordingCommandRunner::new(0)
.with_stdout(b"https://github.com/acme/app.git\n".to_vec()),
);
let wd = workdir();
let git = GitWorkdir::new(Arc::clone(&runner) as Arc<dyn CommandRunner>, wd.clone());
let result = GitHubRepo::detect_in(&git).await.unwrap();
assert_eq!(result, Some(GitHubRepo::new("acme", "app").unwrap()));
let invocations = runner.invocations();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].program, "git");
assert_eq!(invocations[0].args, ["remote", "get-url", "origin"]);
}
#[tokio::test]
async fn detect_returns_repo_for_ssh_remote() {
let runner = Arc::new(
RecordingCommandRunner::new(0).with_stdout(b"git@github.com:acme/app.git\n".to_vec()),
);
let wd = workdir();
let git = GitWorkdir::new(Arc::clone(&runner) as Arc<dyn CommandRunner>, wd.clone());
let result = GitHubRepo::detect_in(&git).await.unwrap();
assert_eq!(result, Some(GitHubRepo::new("acme", "app").unwrap()));
}
#[tokio::test]
async fn detect_returns_none_when_git_fails() {
let runner = Arc::new(RecordingCommandRunner::new(1));
let wd = workdir();
let git = GitWorkdir::new(Arc::clone(&runner) as Arc<dyn CommandRunner>, wd.clone());
let result = GitHubRepo::detect_in(&git).await.unwrap();
assert_eq!(result, None);
}
#[tokio::test]
async fn detect_returns_none_for_non_github_url() {
let runner = Arc::new(
RecordingCommandRunner::new(0)
.with_stdout(b"https://gitlab.com/owner/repo.git\n".to_vec()),
);
let wd = workdir();
let git = GitWorkdir::new(Arc::clone(&runner) as Arc<dyn CommandRunner>, wd.clone());
let result = GitHubRepo::detect_in(&git).await.unwrap();
assert_eq!(result, None);
}
fn make_github_config(owner: Option<&str>, repo: Option<&str>) -> GitHubConfig {
let mut config = GitHubConfig::enabled_config();
if let Some(o) = owner {
config = config.with_owner(o.to_string());
}
if let Some(r) = repo {
config = config.with_repo(r.to_string());
}
config
}
#[tokio::test]
async fn resolve_github_repo_uses_config_when_set() {
let config = make_github_config(Some("acme"), Some("app"));
let runner = Arc::new(RecordingCommandRunner::new(0));
let wd = workdir();
let git = GitWorkdir::new(Arc::clone(&runner) as Arc<dyn CommandRunner>, wd.clone());
let gh_repo = GitHubRepo::resolve(&config, &git).await.unwrap();
assert_eq!(gh_repo.owner, "acme");
assert_eq!(gh_repo.repo, "app");
assert!(runner.invocations().is_empty());
}
#[tokio::test]
async fn resolve_github_repo_falls_back_to_git_remote() {
let config = make_github_config(None, None);
let runner = Arc::new(
RecordingCommandRunner::new(0)
.with_stdout(b"https://github.com/myorg/myapp.git\n".to_vec()),
);
let wd = workdir();
let git = GitWorkdir::new(Arc::clone(&runner) as Arc<dyn CommandRunner>, wd.clone());
let gh_repo = GitHubRepo::resolve(&config, &git).await.unwrap();
assert_eq!(gh_repo.owner, "myorg");
assert_eq!(gh_repo.repo, "myapp");
}
#[tokio::test]
async fn resolve_github_repo_errors_when_neither_config_nor_remote() {
let config = make_github_config(None, None);
let runner = Arc::new(RecordingCommandRunner::new(1)); let wd = workdir();
let git = GitWorkdir::new(Arc::clone(&runner) as Arc<dyn CommandRunner>, wd.clone());
let result = GitHubRepo::resolve(&config, &git).await;
assert!(result.is_err());
let msg = format!("{:#}", result.unwrap_err());
assert!(
msg.contains("Could not determine GitHub repository"),
"Expected repo detection error, got: {msg}"
);
}
#[tokio::test]
async fn resolve_github_repo_errors_when_only_owner_set() {
let config = make_github_config(Some("acme"), None);
let runner = Arc::new(RecordingCommandRunner::new(0));
let wd = workdir();
let git = GitWorkdir::new(Arc::clone(&runner) as Arc<dyn CommandRunner>, wd.clone());
let result = GitHubRepo::resolve(&config, &git).await;
assert!(result.is_err());
let msg = format!("{:#}", result.unwrap_err());
assert!(
msg.contains("must be set together"),
"Expected partial config error, got: {msg}"
);
}
#[tokio::test]
async fn resolve_github_repo_errors_when_only_repo_set() {
let config = make_github_config(None, Some("app"));
let runner = Arc::new(RecordingCommandRunner::new(0));
let wd = workdir();
let git = GitWorkdir::new(Arc::clone(&runner) as Arc<dyn CommandRunner>, wd.clone());
let result = GitHubRepo::resolve(&config, &git).await;
assert!(result.is_err());
let msg = format!("{:#}", result.unwrap_err());
assert!(
msg.contains("must be set together"),
"Expected partial config error, got: {msg}"
);
}
#[tokio::test]
async fn new_accepts_valid_names() {
assert!(GitHubRepo::new("acme", "my-repo").is_ok());
assert!(GitHubRepo::new("my-org", "my_repo.js").is_ok());
assert!(GitHubRepo::new("Org123", "repo").is_ok());
}
#[tokio::test]
async fn new_rejects_invalid_owner() {
assert!(GitHubRepo::new("", "repo").is_err());
assert!(GitHubRepo::new("a/b", "repo").is_err());
assert!(GitHubRepo::new("../evil", "repo").is_err());
assert!(GitHubRepo::new("a b", "repo").is_err());
}
#[tokio::test]
async fn new_rejects_invalid_repo() {
assert!(GitHubRepo::new("owner", "").is_err());
assert!(GitHubRepo::new("owner", "a/b").is_err());
assert!(GitHubRepo::new("owner", "../evil").is_err());
}
}