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(target) = trusty_mpm::core::gh_account::configured_account_pair(selected) {
trusty_mpm::core::gh_account::ensure_gh_account_in_dir(
&target.account,
&target.config_dir,
&target.host,
)?;
}
resolve_gh_env_anyhow(selected)
}
pub(crate) fn load_gh_env() -> anyhow::Result<GhEnv> {
match std::env::current_dir() {
Ok(cwd) => load_gh_env_for(&cwd),
Err(_) => load_gh_env_for(std::path::Path::new(".")),
}
}
pub(crate) fn load_gh_env_for(dir: &std::path::Path) -> anyhow::Result<GhEnv> {
let config = TrustyToolsConfig::load();
let origin_url = trusty_mpm::daemon::managed_routes::inproject::get_origin_url(dir)
.inspect_err(|e| tracing::warn!("cannot read git origin remote for gh identity: {e}"))
.ok()
.flatten();
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());
}
use trusty_mpm::core::gh_scoped_stub::GhScopedStub;
fn fake_gh_lock() -> std::sync::MutexGuard<'static, ()> {
crate::test_support::lock_path_env()
}
#[test]
fn gh_path_override_and_the_tmux_fixture_share_one_lock() {
let _g = fake_gh_lock();
assert!(
crate::test_support::path_env_mutex().try_lock().is_err(),
"`fake_gh_lock` must hold the same mutex `test_support::lock_path_env` hands \
the tmux fixture (#7996)"
);
}
type AmbientTokens = (Option<std::ffi::OsString>, Option<std::ffi::OsString>);
fn strip_ambient_tokens() -> AmbientTokens {
let prior = (
std::env::var_os("GH_TOKEN"),
std::env::var_os("GITHUB_TOKEN"),
);
unsafe {
std::env::remove_var("GH_TOKEN");
std::env::remove_var("GITHUB_TOKEN");
}
prior
}
fn restore_ambient_tokens(prior: AmbientTokens) {
unsafe {
match prior.0 {
Some(v) => std::env::set_var("GH_TOKEN", v),
None => std::env::remove_var("GH_TOKEN"),
}
match prior.1 {
Some(v) => std::env::set_var("GITHUB_TOKEN", v),
None => std::env::remove_var("GITHUB_TOKEN"),
}
}
}
#[test]
fn resolve_project_aware_enforces_paired_account() {
let _g = fake_gh_lock();
let config_dir = tempfile::tempdir().expect("config tempdir");
let (stub, _stub) = GhScopedStub::logged_in_as("wrong-account").install();
let prior_tokens = strip_ambient_tokens();
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_ambient_tokens(prior_tokens);
assert!(result.is_ok(), "expected self-heal to succeed: {result:?}");
assert_eq!(
stub.active_account(),
"bobmatnyc",
"the switch must have taken effect"
);
}
#[test]
fn resolve_project_aware_fails_closed_on_switch_failure() {
let _g = fake_gh_lock();
let config_dir = tempfile::tempdir().expect("config tempdir");
let (_stub_handle, _stub) = GhScopedStub::logged_in_as("wrong-account")
.with_failing_switch()
.install();
let prior_tokens = strip_ambient_tokens();
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_ambient_tokens(prior_tokens);
let err = result.expect_err("mismatched account with a failed switch must be an Err");
assert!(err.to_string().contains("bobmatnyc"), "err: {err}");
}
#[test]
fn resolve_project_aware_skips_enforcement_when_account_unset() {
let _g = fake_gh_lock();
let config_dir = tempfile::tempdir().expect("config tempdir");
let (_stub_handle, _stub) = GhScopedStub::logged_in_as("irrelevant")
.with_failing_api()
.with_failing_switch()
.install();
let config = cfg_of(
Some(gh(&config_dir.path().display().to_string())),
Vec::new(),
);
let result = resolve_project_aware(&config, None);
assert!(
result.is_ok(),
"no account paired with config_dir must skip enforcement entirely: {result:?}"
);
}
}