use std::env;
use std::process::Command;
use url::Url;
#[derive(Debug, Clone, Default)]
pub struct GitContext {
pub workspace: Option<String>,
pub repo_slug: Option<String>,
}
pub fn redact_url(url_str: &str) -> String {
redact_url_credentials(url_str)
}
fn redact_url_credentials(url_str: &str) -> String {
if let Ok(mut url) = Url::parse(url_str) {
if url.username() != "" || url.password().is_some() {
let _ = url.set_username("");
let _ = url.set_password(None);
return url.as_str().replace("://", "://[REDACTED]@");
}
return url.to_string();
}
url_str.to_string()
}
fn try_remote(name: &str) -> Option<GitContext> {
let output = Command::new("git")
.args(["remote", "get-url", name])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8(output.stdout).ok()?.trim().to_string();
if url.is_empty() {
return None;
}
let (workspace, repo_slug) = parse_git_remote(&url)?;
tracing::debug!(
workspace = %workspace,
repo_slug = %repo_slug,
remote = name,
"Detected git context from remote"
);
Some(GitContext {
workspace: Some(workspace),
repo_slug: Some(repo_slug),
})
}
fn get_remote_names() -> Vec<String> {
let output = match Command::new("git").arg("remote").output() {
Ok(output) if output.status.success() => output,
_ => return vec![],
};
String::from_utf8(output.stdout)
.unwrap_or_default()
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
}
pub fn detect_git_context(preferred_remote: Option<&str>) -> GitContext {
if let Some(remote_name) = preferred_remote {
if let Some(ctx) = try_remote(remote_name) {
return ctx;
}
tracing::debug!(
remote = remote_name,
"Configured remote not found or not Bitbucket, trying all remotes"
);
}
let remote_names = get_remote_names();
if remote_names.is_empty() {
tracing::debug!("No git remotes found");
return GitContext::default();
}
if remote_names.iter().any(|n| n == "origin") {
if let Some(ctx) = try_remote("origin") {
return ctx;
}
}
for name in &remote_names {
if name == "origin" {
continue; }
if let Some(ctx) = try_remote(name) {
return ctx;
}
}
tracing::debug!(
count = remote_names.len(),
"No Bitbucket remote found among remotes"
);
GitContext::default()
}
pub fn parse_git_remote(url: &str) -> Option<(String, String)> {
if url.starts_with("git@bitbucket.org:") {
let path = url.strip_prefix("git@bitbucket.org:")?;
return parse_path_segments(path);
}
if let Ok(parsed) = Url::parse(url) {
if parsed.host_str() == Some("bitbucket.org") {
let path = parsed.path().trim_start_matches('/');
return parse_path_segments(path);
}
}
None
}
fn parse_path_segments(path: &str) -> Option<(String, String)> {
let parts: Vec<&str> = path.split('/').collect();
if parts.len() >= 2 {
let workspace = parts[0];
let repo = parts[1].strip_suffix(".git").unwrap_or(parts[1]);
if !workspace.is_empty() && !repo.is_empty() {
return Some((workspace.to_string(), repo.to_string()));
}
}
None
}
pub fn get_all_remotes() -> Vec<(String, String)> {
let output = match Command::new("git").args(["remote", "-v"]).output() {
Ok(output) if output.status.success() => output,
_ => {
tracing::debug!("Failed to get git remotes");
return vec![];
}
};
let output_str = match String::from_utf8(output.stdout) {
Ok(s) => s,
Err(_) => {
tracing::debug!("Invalid UTF-8 in git remotes output");
return vec![];
}
};
let mut remotes = Vec::new();
let mut seen = std::collections::HashSet::new();
for line in output_str.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let name = parts[0].to_string();
let url = parts[1].to_string();
let key = (name.clone(), url.clone());
if seen.insert(key) {
remotes.push((name, url));
}
}
}
remotes
}
pub fn get_current_directory() -> String {
env::current_dir()
.ok()
.and_then(|p| p.to_str().map(String::from))
.unwrap_or_else(|| "unknown".to_string())
}
pub fn detect_current_branch() -> Option<String> {
let output = Command::new("git")
.args(["branch", "--show-current"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let branch = String::from_utf8(output.stdout).ok()?.trim().to_string();
if branch.is_empty() {
None
} else {
Some(branch)
}
}
pub fn get_current_commit_sha() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let sha = String::from_utf8(output.stdout).ok()?.trim().to_string();
if sha.is_empty() {
None
} else {
Some(sha)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_https_git_remote() {
assert_eq!(
parse_git_remote("https://bitbucket.org/ntuclink/blueparrot_ai.git"),
Some(("ntuclink".to_string(), "blueparrot_ai".to_string()))
);
}
#[test]
fn test_parse_https_without_git_suffix() {
assert_eq!(
parse_git_remote("https://bitbucket.org/myworkspace/myrepo"),
Some(("myworkspace".to_string(), "myrepo".to_string()))
);
}
#[test]
fn test_parse_ssh_git_remote() {
assert_eq!(
parse_git_remote("git@bitbucket.org:ntuclink/blueparrot_ai.git"),
Some(("ntuclink".to_string(), "blueparrot_ai".to_string()))
);
}
#[test]
fn test_parse_ssh_without_git_suffix() {
assert_eq!(
parse_git_remote("git@bitbucket.org:myworkspace/myrepo"),
Some(("myworkspace".to_string(), "myrepo".to_string()))
);
}
#[test]
fn test_parse_github_remote() {
assert_eq!(parse_git_remote("https://github.com/user/repo.git"), None);
}
#[test]
fn test_parse_non_bitbucket_ssh() {
assert_eq!(parse_git_remote("git@github.com:user/repo.git"), None);
}
#[test]
fn test_parse_invalid_url() {
assert_eq!(parse_git_remote("not-a-url"), None);
}
#[test]
fn test_parse_empty_url() {
assert_eq!(parse_git_remote(""), None);
}
#[test]
fn test_parse_bitbucket_with_nested_path() {
assert_eq!(
parse_git_remote("https://bitbucket.org/workspace/repo/extra/path.git"),
Some(("workspace".to_string(), "repo".to_string()))
);
}
}