use anyhow::{Context, Result, anyhow};
use git_url_parse::GitUrl;
use git_url_parse::types::provider::GenericProvider;
use tracing::info;
use crate::cmd::Cmd;
pub fn list_remotes() -> Result<Vec<String>> {
let output = Cmd::new("git")
.arg("remote")
.run_and_capture_stdout()
.context("Failed to list git remotes")?;
Ok(output
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.map(|line| line.to_string())
.collect())
}
pub fn remote_exists(remote: &str) -> Result<bool> {
Ok(list_remotes()?.into_iter().any(|name| name == remote))
}
pub fn fetch_remote(remote: &str) -> Result<()> {
Cmd::new("git")
.args(&["fetch", remote])
.run()
.with_context(|| format!("Failed to fetch from remote '{}'", remote))?;
Ok(())
}
pub fn fetch_prune() -> Result<()> {
Cmd::new("git")
.args(&["fetch", "--prune"])
.run()
.context("Failed to fetch with prune")?;
Ok(())
}
pub fn fetch_refspec(remote: &str, refspec: &str) -> Result<()> {
Cmd::new("git")
.args(&["fetch", remote, refspec])
.run()
.with_context(|| {
format!(
"Failed to fetch refspec '{}' from remote '{}'",
refspec, remote
)
})?;
Ok(())
}
pub fn add_remote(name: &str, url: &str) -> Result<()> {
Cmd::new("git")
.args(&["remote", "add", name, url])
.run()
.with_context(|| format!("Failed to add remote '{}' with URL '{}'", name, url))?;
Ok(())
}
pub fn set_remote_url(name: &str, url: &str) -> Result<()> {
Cmd::new("git")
.args(&["remote", "set-url", name, url])
.run()
.with_context(|| format!("Failed to set URL for remote '{}' to '{}'", name, url))?;
Ok(())
}
pub fn get_remote_url(remote: &str) -> Result<String> {
Cmd::new("git")
.args(&["config", "--get", &format!("remote.{}.url", remote)])
.run_and_capture_stdout()
.with_context(|| format!("Failed to get URL for remote '{}'", remote))
}
fn find_remote_for_repo(target: &RepoIdentity) -> Result<Option<String>> {
for remote_name in list_remotes()? {
let url = match get_remote_url(&remote_name) {
Ok(u) => u,
Err(_) => continue,
};
if let Some(id) = parse_repo_identity_from_git_url(&url)
&& id.host.eq_ignore_ascii_case(&target.host)
&& id.owner.eq_ignore_ascii_case(&target.owner)
&& id.repo.eq_ignore_ascii_case(&target.repo)
{
return Ok(Some(remote_name));
}
}
Ok(None)
}
pub fn ensure_fork_remote(fork_owner: &str) -> Result<String> {
let origin_url = get_remote_url("origin")?;
let origin_parsed = GitUrl::parse(&origin_url).with_context(|| {
format!(
"Failed to parse origin URL for fork remote construction: {}",
origin_url
)
})?;
let origin_host = origin_parsed.host().unwrap_or("github.com").to_string();
let scheme = origin_parsed.scheme().unwrap_or("ssh");
let origin_provider: GenericProvider = origin_parsed
.provider_info()
.with_context(|| "Failed to extract provider info from origin URL")?;
let repo_name = origin_provider.repo();
let current_owner = origin_provider.owner();
if !current_owner.is_empty() && fork_owner.eq_ignore_ascii_case(current_owner) {
return Ok("origin".to_string());
}
let target_identity = RepoIdentity {
host: origin_host.clone(),
owner: fork_owner.to_string(),
repo: repo_name.to_string(),
};
if let Some(existing) = find_remote_for_repo(&target_identity)? {
info!(remote = %existing, "git:reusing existing remote for fork");
return Ok(existing);
}
let remote_name = format!("fork-{}", fork_owner);
let fork_url = match scheme {
"https" => format!("https://{}/{}/{}.git", origin_host, fork_owner, repo_name),
"http" => format!("http://{}/{}/{}.git", origin_host, fork_owner, repo_name),
_ => {
format!("git@{}:{}/{}.git", origin_host, fork_owner, repo_name)
}
};
if remote_exists(&remote_name)? {
let current_url = get_remote_url(&remote_name)?;
if current_url != fork_url {
info!(remote = %remote_name, url = %fork_url, "git:updating fork remote URL");
set_remote_url(&remote_name, &fork_url)
.with_context(|| format!("Failed to update remote for fork '{}'", fork_owner))?;
}
} else {
info!(remote = %remote_name, url = %fork_url, "git:adding fork remote");
add_remote(&remote_name, &fork_url)
.with_context(|| format!("Failed to add remote for fork '{}'", fork_owner))?;
}
Ok(remote_name)
}
#[derive(Debug, PartialEq, Eq)]
pub struct RepoIdentity {
pub host: String,
pub owner: String,
pub repo: String,
}
fn parse_repo_identity_from_git_url(url: &str) -> Option<RepoIdentity> {
let parsed = GitUrl::parse(url).ok()?;
let host = parsed.host()?.to_string();
let provider: GenericProvider = parsed.provider_info().ok()?;
Some(RepoIdentity {
host,
owner: provider.owner().to_string(),
repo: provider.repo().to_string(),
})
}
fn parse_owner_from_git_url(url: &str) -> Option<&str> {
if let Some(https_part) = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
{
https_part.split('/').nth(1)
} else if url.starts_with("git@") {
url.split(':')
.nth(1)
.and_then(|path| path.split('/').next())
} else {
None
}
}
pub fn get_repo_owner() -> Result<String> {
let url = get_remote_url("origin")?;
parse_owner_from_git_url(&url)
.ok_or_else(|| anyhow!("Could not parse repository owner from origin URL: {}", url))
.map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::parse_owner_from_git_url;
#[test]
fn test_parse_repo_owner_https_github_com() {
assert_eq!(
parse_owner_from_git_url("https://github.com/owner/repo.git"),
Some("owner")
);
}
#[test]
fn test_parse_repo_owner_https_github_com_no_git_suffix() {
assert_eq!(
parse_owner_from_git_url("https://github.com/owner/repo"),
Some("owner")
);
}
#[test]
fn test_parse_repo_owner_http_github_com() {
assert_eq!(
parse_owner_from_git_url("http://github.com/owner/repo.git"),
Some("owner")
);
}
#[test]
fn test_parse_repo_owner_ssh_github_com() {
assert_eq!(
parse_owner_from_git_url("git@github.com:owner/repo.git"),
Some("owner")
);
}
#[test]
fn test_parse_repo_owner_ssh_github_com_no_git_suffix() {
assert_eq!(
parse_owner_from_git_url("git@github.com:owner/repo"),
Some("owner")
);
}
#[test]
fn test_parse_repo_owner_https_github_enterprise() {
assert_eq!(
parse_owner_from_git_url("https://github.enterprise.com/owner/repo.git"),
Some("owner")
);
}
#[test]
fn test_parse_repo_owner_ssh_github_enterprise() {
assert_eq!(
parse_owner_from_git_url("git@github.enterprise.net:org/project.git"),
Some("org")
);
}
#[test]
fn test_parse_repo_owner_https_github_enterprise_subdomain() {
assert_eq!(
parse_owner_from_git_url("https://github.company.internal/team/project.git"),
Some("team")
);
}
#[test]
fn test_parse_repo_owner_with_nested_path() {
assert_eq!(
parse_owner_from_git_url("https://github.com/owner/repo/subpath"),
Some("owner")
);
}
#[test]
fn test_parse_repo_owner_ssh_with_nested_path() {
assert_eq!(
parse_owner_from_git_url("git@github.com:owner/repo/subpath"),
Some("owner")
);
}
#[test]
fn test_parse_repo_owner_invalid_format() {
assert_eq!(parse_owner_from_git_url("not-a-valid-url"), None);
}
#[test]
fn test_parse_repo_owner_local_path() {
assert_eq!(parse_owner_from_git_url("/local/path/to/repo"), None);
}
#[test]
fn test_parse_repo_owner_file_protocol() {
assert_eq!(parse_owner_from_git_url("file:///local/path/to/repo"), None);
}
}