use trusty_mpm::core::gh_identity::{GhEnv, GhIdentityError, select_github_config};
use trusty_mpm::core::trusty_tools_config::TrustyToolsConfig;
use trusty_mpm::project::record::repo_url_matches;
pub(crate) use trusty_mpm::core::gh_identity::clone_url;
fn resolve_gh_env_anyhow(
config: Option<&trusty_mpm::core::trusty_tools_config::GithubConfig>,
) -> anyhow::Result<GhEnv> {
trusty_mpm::core::gh_identity::resolve_gh_env(config)
.map_err(|e: GhIdentityError| anyhow::anyhow!(e))
}
pub(crate) fn resolve_project_aware(
config: &TrustyToolsConfig,
origin_url: Option<&str>,
) -> anyhow::Result<GhEnv> {
let project_github = origin_url
.and_then(|url| {
config
.projects
.iter()
.find(|p| repo_url_matches(&p.repo_url, url))
})
.and_then(|p| p.github.as_ref());
let selected = select_github_config(project_github, config.github.as_ref());
if let Some((dir, account)) = trusty_mpm::core::gh_account::configured_account_pair(selected) {
trusty_mpm::core::gh_account::ensure_gh_account_in_dir(&account, &dir)?;
}
resolve_gh_env_anyhow(selected)
}
pub(crate) fn load_gh_env() -> anyhow::Result<GhEnv> {
let config = TrustyToolsConfig::load();
let origin_url = std::env::current_dir()
.ok()
.and_then(|cwd| trusty_mpm::daemon::managed_routes::inproject::get_origin_url(&cwd));
let env = resolve_project_aware(&config, origin_url.as_deref())?;
if !env.is_empty() {
let names: Vec<&str> = env.vars().iter().map(|(k, _)| k.as_str()).collect();
tracing::debug!(overrides = ?names, "applying per-project GitHub identity binding to gh calls");
}
Ok(env)
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_mpm::core::trusty_tools_config::{GithubConfig, ProjectConfig};
fn project(repo_url: &str, github: Option<GithubConfig>) -> ProjectConfig {
ProjectConfig {
name: "p".into(),
repo_url: repo_url.into(),
default_branch: None,
stack_hint: None,
tags: None,
description: None,
gh_user: None,
gh_account: None,
github,
commit_name: None,
commit_email: None,
untracked_sync: None,
worktree: None,
}
}
fn gh(config_dir: &str) -> GithubConfig {
GithubConfig {
config_dir: Some(config_dir.into()),
token_env: None,
account: None,
host: None,
}
}
fn cfg_of(github: Option<GithubConfig>, projects: Vec<ProjectConfig>) -> TrustyToolsConfig {
let mut c = TrustyToolsConfig::default();
c.github = github;
c.projects = projects;
c
}
#[test]
fn resolve_project_aware_project_binding_wins() {
let config = cfg_of(
Some(gh("/cfg/global")),
vec![project(
"https://github.com/acme/widget",
Some(gh("/cfg/project")),
)],
);
let env =
resolve_project_aware(&config, Some("https://github.com/acme/widget.git")).expect("ok");
assert_eq!(
env.vars(),
&[("GH_CONFIG_DIR".to_string(), "/cfg/project".to_string())]
);
}
#[test]
fn resolve_project_aware_falls_back_to_global() {
let config = cfg_of(
Some(gh("/cfg/global")),
vec![project("https://github.com/acme/widget", None)],
);
let env =
resolve_project_aware(&config, Some("https://github.com/acme/widget")).expect("ok");
assert_eq!(
env.vars(),
&[("GH_CONFIG_DIR".to_string(), "/cfg/global".to_string())]
);
}
#[test]
fn resolve_project_aware_no_origin_uses_global() {
let config = cfg_of(Some(gh("/cfg/global")), Vec::new());
let env = resolve_project_aware(&config, None).expect("ok");
assert_eq!(
env.vars(),
&[("GH_CONFIG_DIR".to_string(), "/cfg/global".to_string())]
);
}
#[test]
fn resolve_project_aware_no_match_no_global_is_ambient() {
let config = TrustyToolsConfig::default();
let env =
resolve_project_aware(&config, Some("https://github.com/someone/else")).expect("ok");
assert!(env.is_empty());
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn fake_gh_lock() -> std::sync::MutexGuard<'static, ()> {
ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[cfg(unix)]
fn write_fake_gh(bin_dir: &std::path::Path, initial: &str, switch_fails: bool) {
use std::os::unix::fs::PermissionsExt;
let switch_exit = if switch_fails { 1 } else { 0 };
let script = format!(
r#"#!/bin/sh
STATE="$GH_CONFIG_DIR/.fake_active"
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
if [ -f "$STATE" ]; then ACTIVE=$(cat "$STATE"); else ACTIVE="{initial}"; fi
echo "github.com"
echo " - Logged in to github.com account $ACTIVE (keyring)"
echo " - Active account: true"
exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "switch" ]; then
if [ {switch_exit} -ne 0 ]; then exit 1; fi
target=""
prev=""
for a in "$@"; do
if [ "$prev" = "--user" ]; then target="$a"; fi
prev="$a"
done
echo "$target" > "$STATE"
exit 0
fi
exit 1
"#
);
let path = bin_dir.join("gh");
std::fs::write(&path, script).expect("write fake gh");
let mut perms = std::fs::metadata(&path)
.expect("stat fake gh")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).expect("chmod fake gh");
}
#[cfg(unix)]
fn prepend_path(dir: &std::path::Path) -> Option<String> {
let prior = std::env::var("PATH").ok();
let new_path = match &prior {
Some(p) => format!("{}:{p}", dir.display()),
None => dir.display().to_string(),
};
unsafe { std::env::set_var("PATH", new_path) };
prior
}
#[cfg(unix)]
fn restore_path(prior: Option<String>) {
unsafe {
match prior {
Some(p) => std::env::set_var("PATH", p),
None => std::env::remove_var("PATH"),
}
}
}
#[cfg(unix)]
#[test]
fn resolve_project_aware_enforces_paired_account() {
let _g = fake_gh_lock();
let bin_dir = tempfile::tempdir().expect("bin tempdir");
let config_dir = tempfile::tempdir().expect("config tempdir");
write_fake_gh(bin_dir.path(), "wrong-account", false);
let prior_path = prepend_path(bin_dir.path());
let mut cfg = gh(&config_dir.path().display().to_string());
cfg.account = Some("bobmatnyc".to_string());
let config = cfg_of(Some(cfg), Vec::new());
let result = resolve_project_aware(&config, None);
restore_path(prior_path);
assert!(result.is_ok(), "expected self-heal to succeed: {result:?}");
let state = std::fs::read_to_string(config_dir.path().join(".fake_active"))
.expect("state file written by the fake gh's switch");
assert_eq!(state.trim(), "bobmatnyc");
}
#[cfg(unix)]
#[test]
fn resolve_project_aware_fails_closed_on_switch_failure() {
let _g = fake_gh_lock();
let bin_dir = tempfile::tempdir().expect("bin tempdir");
let config_dir = tempfile::tempdir().expect("config tempdir");
write_fake_gh(bin_dir.path(), "wrong-account", true);
let prior_path = prepend_path(bin_dir.path());
let mut cfg = gh(&config_dir.path().display().to_string());
cfg.account = Some("bobmatnyc".to_string());
let config = cfg_of(Some(cfg), Vec::new());
let result = resolve_project_aware(&config, None);
restore_path(prior_path);
let err = result.expect_err("mismatched account with a failed switch must be an Err");
assert!(err.to_string().contains("bobmatnyc"), "err: {err}");
}
#[cfg(unix)]
#[test]
fn resolve_project_aware_skips_enforcement_when_account_unset() {
let _g = fake_gh_lock();
let bin_dir = tempfile::tempdir().expect("bin tempdir");
let config_dir = tempfile::tempdir().expect("config tempdir");
write_fake_gh(bin_dir.path(), "irrelevant", true);
let prior_path = prepend_path(bin_dir.path());
let config = cfg_of(
Some(gh(&config_dir.path().display().to_string())),
Vec::new(),
);
let result = resolve_project_aware(&config, None);
restore_path(prior_path);
assert!(
result.is_ok(),
"no account paired with config_dir must skip enforcement entirely: {result:?}"
);
}
}