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;
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,
)?;
}
trusty_mpm::core::gh_identity::resolve_tiered_gh_env(project_github, config.github.as_ref())
.map_err(|e: GhIdentityError| anyhow::anyhow!(e))
}
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> {
use trusty_mpm::core::remote_mode::{RemoteMode, remote_mode};
let mode = remote_mode(dir)
.inspect_err(|e| tracing::warn!("cannot read git origin remote for gh identity: {e}"))
.ok();
let inherited = std::env::var("GH_CONFIG_DIR").ok();
let mpm = trusty_mpm::core::config::MpmConfig::load_default();
if let Some(pinned) = local_only_pin(mode.as_ref(), &mpm, inherited.as_deref()) {
return Ok(pinned);
}
let config = TrustyToolsConfig::load();
let origin_url = match mode {
Some(RemoteMode::Origin(url)) => Some(url),
_ => None,
};
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)
}
pub(crate) fn local_only_pin(
mode: Option<&trusty_mpm::core::remote_mode::RemoteMode>,
mpm: &trusty_mpm::core::config::MpmConfig,
inherited_config_dir: Option<&str>,
) -> Option<GhEnv> {
use trusty_mpm::core::remote_mode::{LOCAL_ONLY_GH_CONFIG_DIR, RemoteMode, gh_disabled_for};
if inherited_config_dir == Some(LOCAL_ONLY_GH_CONFIG_DIR) {
return Some(GhEnv::local_only());
}
match mode {
Some(RemoteMode::LocalOnly { root }) if gh_disabled_for(root, mpm) => {
Some(GhEnv::local_only())
}
_ => None,
}
}
#[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_unbound_repo_keeps_the_shell_token_8383() {
let config = cfg_of(
Some(gh("/cfg/global")),
vec![project(
"https://github.com/acme/widget",
Some(gh("/cfg/project")),
)],
);
let unbound = resolve_project_aware(&config, Some("https://github.com/hotstats/reporting"))
.expect("ok");
assert_eq!(
unbound.vars(),
&[("GH_CONFIG_DIR".to_string(), "/cfg/global".to_string())]
);
assert!(unbound.unset_vars().is_empty(), "{unbound:?}");
let bound =
resolve_project_aware(&config, Some("https://github.com/acme/widget")).expect("ok");
assert!(
bound.unset_vars().iter().any(|k| k == "GH_TOKEN"),
"{bound:?}"
);
}
#[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:?}"
);
}
#[test]
fn load_gh_env_for_a_local_only_repo_disables_gh() {
let repo = tempfile::TempDir::new().expect("tempdir");
let init = std::process::Command::new("git")
.args(["init", "-q"])
.arg(repo.path())
.status()
.expect("git init");
assert!(init.success(), "git init failed");
let env = load_gh_env_for(repo.path()).expect("local-only env");
let value = |k: &str| {
env.vars()
.iter()
.find(|(n, _)| n == k)
.map(|(_, v)| v.clone())
};
assert_eq!(
value("GH_CONFIG_DIR").as_deref(),
Some(trusty_mpm::core::remote_mode::LOCAL_ONLY_GH_CONFIG_DIR)
);
assert!(
env.unset_vars().iter().any(|k| k == "GITHUB_TOKEN"),
"{env:?}"
);
}
fn local_only_repo(profile: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let repo = tempfile::TempDir::new().expect("tempdir");
let init = std::process::Command::new("git")
.args(["init", "-q"])
.arg(repo.path())
.status()
.expect("git init");
assert!(init.success(), "git init failed");
let toml = format!("profile = \"{profile}\"\n");
std::fs::write(repo.path().join(".trusty-mpm.toml"), toml).expect("toml");
let root = std::fs::canonicalize(repo.path()).expect("canonical");
(repo, root)
}
fn allowing(dir: &std::path::Path) -> trusty_mpm::core::config::MpmConfig {
let mut config = trusty_mpm::core::config::MpmConfig::default();
config.supervisor.projects = vec![dir.to_path_buf()];
config
}
#[test]
fn an_allow_listed_supervisor_dir_keeps_gh_in_the_cli() {
use trusty_mpm::core::remote_mode::remote_mode;
let (_repo, root) = local_only_repo("supervisor");
let mode = remote_mode(&root).expect("mode");
assert!(local_only_pin(Some(&mode), &allowing(&root), None).is_none());
let unlisted = trusty_mpm::core::config::MpmConfig::default();
assert!(local_only_pin(Some(&mode), &unlisted, None).is_some());
}
#[test]
fn a_pinned_session_keeps_its_pin_outside_the_repo() {
use trusty_mpm::core::remote_mode::{LOCAL_ONLY_GH_CONFIG_DIR, RemoteMode};
let origin = RemoteMode::Origin("https://github.com/o/r.git".into());
let mpm = trusty_mpm::core::config::MpmConfig::default();
let pinned = local_only_pin(Some(&origin), &mpm, Some(LOCAL_ONLY_GH_CONFIG_DIR));
assert!(pinned.is_some(), "the inherited pin must survive `cd`");
assert!(local_only_pin(Some(&origin), &mpm, None).is_none());
}
}