use std::path::PathBuf;
use std::time::Duration;
pub const GH_PROBE_TIMEOUT: Duration = Duration::from_millis(250);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GhAccountStatus {
pub active: Option<String>,
pub logged_in: Vec<String>,
}
impl GhAccountStatus {
pub fn is_ambiguous(&self) -> bool {
self.logged_in.len() > 1
}
}
fn gh_config_dir() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("GH_CONFIG_DIR") {
let dir = dir.trim();
if !dir.is_empty() {
return Some(PathBuf::from(dir));
}
}
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
let xdg = xdg.trim();
if !xdg.is_empty() {
return Some(PathBuf::from(xdg).join("gh"));
}
}
dirs::home_dir().map(|h| h.join(".config").join("gh"))
}
fn gh_hosts_yml_path() -> Option<PathBuf> {
Some(gh_config_dir()?.join("hosts.yml"))
}
pub fn parse_active_account_from_hosts_yml(yaml: &str) -> Option<String> {
let doc: serde_yaml::Value = serde_yaml::from_str(yaml).ok()?;
let user = doc.get("github.com")?.get("user")?.as_str()?.trim();
if user.is_empty() {
None
} else {
Some(user.to_string())
}
}
pub fn parse_gh_account_status_from_hosts_yml(yaml: &str) -> Option<GhAccountStatus> {
let doc: serde_yaml::Value = serde_yaml::from_str(yaml).ok()?;
let host = doc.get("github.com")?;
let active = host
.get("user")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let mut logged_in: Vec<String> = Vec::new();
if let Some(users) = host.get("users").and_then(|v| v.as_mapping()) {
for key in users.keys() {
if let Some(name) = key.as_str() {
let name = name.trim();
if !name.is_empty() && !logged_in.iter().any(|e| e == name) {
logged_in.push(name.to_string());
}
}
}
}
if logged_in.is_empty()
&& let Some(a) = &active
{
logged_in.push(a.clone());
}
if active.is_none() && logged_in.is_empty() {
return None;
}
Some(GhAccountStatus { active, logged_in })
}
pub fn parse_logged_in_accounts(auth_status: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for line in auth_status.lines() {
let Some((_, rest)) = line.split_once("Logged in to") else {
continue;
};
if let Some(login) = extract_login_token(rest)
&& !out.iter().any(|e| e == &login)
{
out.push(login);
}
}
out
}
fn extract_login_token(rest: &str) -> Option<String> {
let tokens: Vec<&str> = rest.split_whitespace().collect();
for keyword in ["account", "as"] {
if let Some(pos) = tokens.iter().position(|t| *t == keyword)
&& let Some(raw) = tokens.get(pos + 1)
{
let login = raw.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_');
if !login.is_empty() {
return Some(login.to_string());
}
}
}
None
}
pub fn gh_account_status_local() -> Option<GhAccountStatus> {
let path = gh_hosts_yml_path()?;
let contents = std::fs::read_to_string(&path).ok()?;
parse_gh_account_status_from_hosts_yml(&contents)
}
pub(crate) fn run_bounded<T, F>(timeout: Duration, f: F) -> Option<T>
where
T: Send + 'static,
F: FnOnce() -> Option<T> + Send + 'static,
{
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
rx.recv_timeout(timeout).ok().flatten()
}
pub fn active_gh_account() -> Option<String> {
if let Some(status) = gh_account_status_local()
&& let Some(active) = status.active
{
return Some(active);
}
run_bounded(GH_PROBE_TIMEOUT, || {
let out = std::process::Command::new("gh")
.args(["auth", "status", "--active"])
.output()
.ok()?;
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push('\n');
text.push_str(&String::from_utf8_lossy(&out.stderr));
parse_logged_in_accounts(&text).into_iter().next()
})
}
pub fn logged_in_gh_accounts() -> Vec<String> {
run_bounded(GH_PROBE_TIMEOUT, || {
let out = std::process::Command::new("gh")
.args(["auth", "status"])
.output()
.ok()?;
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push('\n');
text.push_str(&String::from_utf8_lossy(&out.stderr));
Some(parse_logged_in_accounts(&text))
})
.unwrap_or_default()
}
const GH_ENFORCE_TIMEOUT: Duration = Duration::from_secs(5);
#[path = "gh_account_enforce.rs"]
mod enforce;
pub use enforce::{
GH_CONFIG_DIR_ENV, configured_account_pair, ensure_gh_account_for_project,
ensure_gh_account_in_dir,
};
pub const GH_TOKEN_ENV_VAR: &str = "GH_TOKEN";
pub const GH_USER_ENV_VAR: &str = "GH_USER";
pub fn gh_token_via_cli(account: &str) -> Result<String, String> {
let owned = account.to_string();
run_bounded(GH_ENFORCE_TIMEOUT, move || {
let out = std::process::Command::new("gh")
.args(["auth", "token", "-u", &owned])
.output()
.ok()?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Some(Err(format!("`gh auth token -u {owned}` failed: {stderr}")));
}
let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
if token.is_empty() {
return Some(Err(format!(
"`gh auth token -u {owned}` returned an empty token"
)));
}
Some(Ok(token))
})
.unwrap_or_else(|| {
Err(format!(
"`gh auth token -u {account}` did not respond within {GH_ENFORCE_TIMEOUT:?} \
(is `gh` installed and on PATH?)"
))
})
}
pub fn resolve_gh_account_env_with(
gh_account: Option<&str>,
resolve_token: impl FnOnce(&str) -> Result<String, String>,
) -> Option<Result<Vec<(String, String)>, String>> {
let account = gh_account.map(str::trim).filter(|s| !s.is_empty())?;
Some(resolve_token(account).map(|token| {
vec![
(GH_TOKEN_ENV_VAR.to_string(), token),
(GH_USER_ENV_VAR.to_string(), account.to_string()),
]
}))
}
pub fn resolve_gh_account_env(
gh_account: Option<&str>,
) -> Option<Result<Vec<(String, String)>, String>> {
resolve_gh_account_env_with(gh_account, gh_token_via_cli)
}
pub async fn resolve_gh_account_env_for_registry(
registry: &crate::project::ProjectRegistry,
cwd: &std::path::Path,
) -> Vec<(String, String)> {
let cwd_for_origin = cwd.to_path_buf();
let origin = tokio::task::spawn_blocking(move || {
crate::daemon::managed_routes::inproject::get_origin_url(&cwd_for_origin)
})
.await
.unwrap_or(None);
let Some(origin) = origin else {
return Vec::new();
};
let Some(gh_account) = find_pinned_gh_account(registry, &origin).await else {
return Vec::new();
};
let cwd_for_log = cwd.to_path_buf();
tokio::task::spawn_blocking(move || match resolve_gh_account_env(Some(&gh_account)) {
None => Vec::new(),
Some(Ok(vars)) => vars,
Some(Err(msg)) => {
tracing::warn!(
cwd = %cwd_for_log.display(),
"gh_account token resolution failed; spawning without GH_TOKEN: {msg}"
);
Vec::new()
}
})
.await
.unwrap_or_default()
}
async fn find_pinned_gh_account(
registry: &crate::project::ProjectRegistry,
origin: &str,
) -> Option<String> {
let projects = registry.list().await.ok()?;
projects
.iter()
.find(|p| crate::project::record::repo_url_matches(&p.repo_url, origin))
.and_then(|p| p.gh_account.clone())
}
#[cfg(test)]
#[path = "gh_account_spawn_env_tests.rs"]
mod spawn_env_tests;
#[cfg(test)]
mod tests {
use super::*;
const MULTI_HOSTS_YML: &str = "\
github.com:
git_protocol: https
users:
bobmatnyc:
bob-duetto:
user: bob-duetto
";
const SINGLE_HOSTS_YML: &str = "\
github.com:
git_protocol: https
user: bobmatnyc
";
const MULTI_AUTH_STATUS: &str = "\
github.com
✓ Logged in to github.com account bob-duetto (keyring)
- Active account: true
- Git operations protocol: https
- Token: gho_************************************
- Token scopes: 'admin:org', 'gist', 'project', 'repo', 'workflow'
✓ Logged in to github.com account bobmatnyc (keyring)
- Active account: false
- Git operations protocol: https
- Token: gho_************************************
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
";
const SINGLE_AUTH_STATUS: &str = "\
github.com
✓ Logged in to github.com account bobmatnyc (keyring)
- Active account: true
- Git operations protocol: https
- Token: gho_************************************
";
#[test]
fn parse_active_account_present() {
assert_eq!(
parse_active_account_from_hosts_yml(MULTI_HOSTS_YML).as_deref(),
Some("bob-duetto")
);
assert_eq!(
parse_active_account_from_hosts_yml(SINGLE_HOSTS_YML).as_deref(),
Some("bobmatnyc")
);
}
#[test]
fn parse_active_account_absent() {
assert_eq!(parse_active_account_from_hosts_yml(""), None);
assert_eq!(
parse_active_account_from_hosts_yml("other.host:\n user: x"),
None
);
assert_eq!(
parse_active_account_from_hosts_yml("github.com:\n git_protocol: https"),
None
);
assert_eq!(parse_active_account_from_hosts_yml(": : ["), None);
}
#[test]
fn parse_gh_account_status_single() {
let status = parse_gh_account_status_from_hosts_yml(SINGLE_HOSTS_YML).expect("some");
assert_eq!(status.active.as_deref(), Some("bobmatnyc"));
assert_eq!(status.logged_in, vec!["bobmatnyc".to_string()]);
assert!(!status.is_ambiguous());
}
#[test]
fn parse_gh_account_status_multi_from_users_map() {
let status = parse_gh_account_status_from_hosts_yml(MULTI_HOSTS_YML).expect("some");
assert_eq!(status.active.as_deref(), Some("bob-duetto"));
assert!(status.logged_in.contains(&"bobmatnyc".to_string()));
assert!(status.logged_in.contains(&"bob-duetto".to_string()));
assert_eq!(status.logged_in.len(), 2);
assert!(status.is_ambiguous());
}
#[test]
fn parse_gh_account_status_empty() {
assert_eq!(parse_gh_account_status_from_hosts_yml(""), None);
assert_eq!(
parse_gh_account_status_from_hosts_yml("enterprise.example.com:\n user: x"),
None
);
}
#[test]
fn parse_logged_in_single() {
assert_eq!(
parse_logged_in_accounts(SINGLE_AUTH_STATUS),
vec!["bobmatnyc".to_string()]
);
}
#[test]
fn parse_logged_in_multiple() {
assert_eq!(
parse_logged_in_accounts(MULTI_AUTH_STATUS),
vec!["bob-duetto".to_string(), "bobmatnyc".to_string()]
);
}
#[test]
fn parse_logged_in_older_as_phrasing() {
let text = "✓ Logged in to github.com as octocat (oauth_token)";
assert_eq!(parse_logged_in_accounts(text), vec!["octocat".to_string()]);
}
#[test]
fn parse_logged_in_empty() {
assert!(parse_logged_in_accounts("").is_empty());
assert!(parse_logged_in_accounts("You are not logged into any GitHub hosts.").is_empty());
}
#[test]
fn is_ambiguous_boundary() {
let none = GhAccountStatus::default();
assert!(!none.is_ambiguous());
let one = GhAccountStatus {
active: Some("a".into()),
logged_in: vec!["a".into()],
};
assert!(!one.is_ambiguous());
let two = GhAccountStatus {
active: Some("a".into()),
logged_in: vec!["a".into(), "b".into()],
};
assert!(two.is_ambiguous());
}
}