use crate::core::trusty_tools_config::{DEFAULT_GITHUB_HOST, GithubConfig, TrustyToolsConfig};
const ENV_GH_CONFIG_DIR: &str = "GH_CONFIG_DIR";
const ENV_GH_TOKEN: &str = "GH_TOKEN";
const ENV_GH_HOST: &str = "GH_HOST";
pub(crate) const GH_INHERITED_IDENTITY_ENV: &[&str] = &[
ENV_GH_TOKEN,
"GITHUB_TOKEN",
"GH_ENTERPRISE_TOKEN",
"GITHUB_ENTERPRISE_TOKEN",
ENV_GH_CONFIG_DIR,
"GH_USER",
];
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum GhIdentityError {
#[error(
"github.account = '{0}' cannot select a gh identity on its own without \
mutating global gh state (`gh auth switch`). Bind this project's \
identity with `github.config_dir` (a per-account gh config home) or \
`github.token_env` (the NAME of an env var holding a token) instead."
)]
AccountStrategyUnsupported(String),
}
#[derive(Clone, Default, PartialEq, Eq)]
pub struct GhEnv {
vars: Vec<(String, String)>,
unset: Vec<String>,
}
impl std::fmt::Debug for GhEnv {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GhEnv")
.field("vars", &RedactedVars(&self.vars))
.field("unset", &self.unset)
.finish()
}
}
pub(crate) struct RedactedVars<'a>(pub(crate) &'a [(String, String)]);
fn is_token_var(name: &str) -> bool {
name.to_ascii_uppercase().ends_with("TOKEN")
}
impl std::fmt::Debug for RedactedVars<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_list()
.entries(self.0.iter().map(|(name, value)| {
let shown = if is_token_var(name) {
"<redacted>"
} else {
value.as_str()
};
(name.as_str(), shown)
}))
.finish()
}
}
impl GhEnv {
pub(crate) fn from_identity_vars(vars: Vec<(String, String)>) -> Self {
let unset = inherited_identity_to_clear(&vars);
Self { vars, unset }
}
pub fn local_only() -> Self {
Self::from_identity_vars(crate::core::remote_mode::local_only_gh_vars())
}
pub fn vars(&self) -> &[(String, String)] {
&self.vars
}
pub fn unset_vars(&self) -> &[String] {
&self.unset
}
pub fn apply_to(&self, cmd: &mut std::process::Command) {
for key in &self.unset {
cmd.env_remove(key);
}
for (key, value) in &self.vars {
cmd.env(key, value);
}
}
pub fn is_empty(&self) -> bool {
self.vars.is_empty() && self.unset.is_empty()
}
pub fn describe(&self) -> String {
if self.is_empty() {
return "no github: binding resolved — gh inherits the daemon's ambient \
environment"
.to_string();
}
self.vars
.iter()
.map(|(k, v)| {
if is_token_var(k) {
format!("{k}=<redacted>")
} else {
format!("{k}={v}")
}
})
.chain(self.unset.iter().map(|k| format!("-{k}")))
.collect::<Vec<_>>()
.join(", ")
}
}
pub fn select_github_config<'a>(
project: Option<&'a GithubConfig>,
global: Option<&'a GithubConfig>,
) -> Option<&'a GithubConfig> {
project.or(global)
}
pub fn resolve_tiered_gh_env(
project: Option<&GithubConfig>,
global: Option<&GithubConfig>,
) -> Result<GhEnv, GhIdentityError> {
if project.is_some() {
return resolve_gh_env(project);
}
let mut env = resolve_gh_env(global)?;
env.unset.clear();
Ok(env)
}
pub fn select_config_for_origin<'a>(
config: &'a TrustyToolsConfig,
origin_url: Option<&str>,
) -> Option<&'a GithubConfig> {
let project_github = origin_url
.and_then(|url| {
config
.projects
.iter()
.find(|p| crate::project::record::repo_url_matches(&p.repo_url, url))
})
.and_then(|p| p.github.as_ref());
select_github_config(project_github, config.github.as_ref())
}
pub fn resolve_gh_env(config: Option<&GithubConfig>) -> Result<GhEnv, GhIdentityError> {
let Some(cfg) = config else {
return Ok(GhEnv::default());
};
let mut vars: Vec<(String, String)> = Vec::new();
if let Some(dir) = cfg
.config_dir
.as_deref()
.map(|p| p.to_string_lossy().trim().to_string())
.filter(|s| !s.is_empty())
{
vars.push((ENV_GH_CONFIG_DIR.to_string(), dir));
} else if let Some(token) = resolve_token_env(cfg.token_env.as_deref()) {
vars.push((ENV_GH_TOKEN.to_string(), token));
} else if let Some(account) = cfg
.account
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
return Err(GhIdentityError::AccountStrategyUnsupported(
account.to_string(),
));
}
let unset = inherited_identity_to_clear(&vars);
if let Some(host) = cfg.host.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
vars.push((ENV_GH_HOST.to_string(), host.to_string()));
}
Ok(GhEnv { vars, unset })
}
pub fn inherited_identity_to_clear(set_vars: &[(String, String)]) -> Vec<String> {
let selects_identity = set_vars
.iter()
.any(|(k, _)| k == ENV_GH_CONFIG_DIR || k == ENV_GH_TOKEN);
if !selects_identity {
return Vec::new();
}
GH_INHERITED_IDENTITY_ENV
.iter()
.filter(|name| !set_vars.iter().any(|(k, _)| k == *name))
.map(|name| (*name).to_string())
.collect()
}
fn resolve_token_env(token_env: Option<&str>) -> Option<String> {
let name = token_env.map(str::trim).filter(|s| !s.is_empty())?;
let value = std::env::var(name).ok()?;
if value.is_empty() { None } else { Some(value) }
}
pub fn effective_host(config: Option<&GithubConfig>) -> String {
config
.and_then(|c| c.host.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(DEFAULT_GITHUB_HOST)
.to_string()
}
pub fn clone_url(config: Option<&GithubConfig>, repo: &str) -> String {
format!("https://{}/{}", effective_host(config), repo)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
crate::core::trusty_tools_config::env_test_lock()
}
fn cfg() -> GithubConfig {
GithubConfig::default()
}
#[test]
fn resolve_absent_config_is_empty() {
let env = resolve_gh_env(None).expect("none is ok");
assert!(env.is_empty());
assert_eq!(env.vars(), &[]);
}
#[test]
fn resolve_config_dir() {
let c = GithubConfig {
config_dir: Some(PathBuf::from("/home/bob/.config/gh-work")),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
assert_eq!(
env.vars(),
&[(
ENV_GH_CONFIG_DIR.to_string(),
"/home/bob/.config/gh-work".to_string()
)]
);
}
#[test]
fn binding_removes_the_inherited_identity_vars() {
let c = GithubConfig {
config_dir: Some(PathBuf::from("/cfg/duetto")),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
let unset: Vec<&str> = env.unset_vars().iter().map(String::as_str).collect();
assert_eq!(
unset,
vec![
"GH_TOKEN",
"GITHUB_TOKEN",
"GH_ENTERPRISE_TOKEN",
"GITHUB_ENTERPRISE_TOKEN",
"GH_USER",
]
);
assert!(!unset.contains(&ENV_GH_CONFIG_DIR));
}
#[test]
fn an_unbound_repo_keeps_the_callers_token_8383() {
let global = GithubConfig {
config_dir: Some(PathBuf::from("/cfg/global")),
..cfg()
};
let env = resolve_tiered_gh_env(None, Some(&global)).expect("ok");
assert_eq!(
env.vars(),
&[(ENV_GH_CONFIG_DIR.to_string(), "/cfg/global".to_string())]
);
assert!(env.unset_vars().is_empty(), "{env:?}");
}
#[test]
fn a_project_binding_still_clears_the_callers_token_8383() {
let project = GithubConfig {
config_dir: Some(PathBuf::from("/cfg/project")),
..cfg()
};
let global = GithubConfig {
config_dir: Some(PathBuf::from("/cfg/global")),
..cfg()
};
let env = resolve_tiered_gh_env(Some(&project), Some(&global)).expect("ok");
assert_eq!(
env.vars(),
&[(ENV_GH_CONFIG_DIR.to_string(), "/cfg/project".to_string())]
);
assert!(
env.unset_vars().iter().any(|k| k == ENV_GH_TOKEN),
"{env:?}"
);
}
#[test]
fn inherited_identity_to_clear_ignores_an_informational_only_binding() {
assert!(
inherited_identity_to_clear(&[("GH_USER".to_string(), "bobmatnyc".to_string())])
.is_empty()
);
assert!(inherited_identity_to_clear(&[]).is_empty());
let cleared = inherited_identity_to_clear(&[
(ENV_GH_CONFIG_DIR.to_string(), "/cfg".to_string()),
("GH_USER".to_string(), "bobmatnyc".to_string()),
]);
assert!(cleared.contains(&ENV_GH_TOKEN.to_string()), "{cleared:?}");
assert!(!cleared.contains(&"GH_USER".to_string()), "{cleared:?}");
}
#[test]
fn token_env_binding_removes_the_config_dir() {
unsafe { std::env::set_var("TM_TEST_6668_TOKEN", "t") };
let c = GithubConfig {
token_env: Some("TM_TEST_6668_TOKEN".to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
let unset: Vec<&str> = env.unset_vars().iter().map(String::as_str).collect();
assert!(unset.contains(&ENV_GH_CONFIG_DIR), "{unset:?}");
assert!(unset.contains(&"GITHUB_TOKEN"), "{unset:?}");
assert!(!unset.contains(&ENV_GH_TOKEN), "{unset:?}");
unsafe { std::env::remove_var("TM_TEST_6668_TOKEN") };
}
#[test]
fn absent_config_and_host_only_binding_remove_nothing() {
assert!(resolve_gh_env(None).expect("ok").unset_vars().is_empty());
let c = GithubConfig {
host: Some("github.example.com".to_string()),
..cfg()
};
assert!(
resolve_gh_env(Some(&c))
.expect("ok")
.unset_vars()
.is_empty()
);
}
#[test]
fn apply_to_removes_then_sets() {
let c = GithubConfig {
config_dir: Some(PathBuf::from("/cfg/duetto")),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
let mut cmd = std::process::Command::new("true");
env.apply_to(&mut cmd);
let seen: Vec<(String, Option<String>)> = cmd
.get_envs()
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.map(|v| v.to_string_lossy().into_owned()),
)
})
.collect();
assert!(seen.contains(&("GH_TOKEN".to_string(), None)), "{seen:?}");
assert!(
seen.contains(&(
ENV_GH_CONFIG_DIR.to_string(),
Some("/cfg/duetto".to_string())
)),
"{seen:?}"
);
}
#[test]
fn resolve_token_env_present() {
let _g = env_lock();
let name = "TM_TEST_GH_TOKEN_PRESENT_LIB";
unsafe { std::env::set_var(name, "ghp_secret_value") };
let c = GithubConfig {
token_env: Some(name.to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
unsafe { std::env::remove_var(name) };
assert_eq!(
env.vars(),
&[(ENV_GH_TOKEN.to_string(), "ghp_secret_value".to_string())]
);
}
#[test]
fn resolve_token_env_absent_falls_through() {
let _g = env_lock();
let name = "TM_TEST_GH_TOKEN_DEFINITELY_UNSET_LIB";
unsafe { std::env::remove_var(name) };
let c = GithubConfig {
token_env: Some(name.to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
assert!(env.is_empty(), "expected empty, got {:?}", env.vars());
}
#[test]
fn precedence_config_dir_beats_token_env() {
let _g = env_lock();
let name = "TM_TEST_GH_TOKEN_PRECEDENCE_LIB";
unsafe { std::env::set_var(name, "tok") };
let c = GithubConfig {
config_dir: Some(PathBuf::from("/cfg/dir")),
token_env: Some(name.to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
unsafe { std::env::remove_var(name) };
assert_eq!(
env.vars(),
&[(ENV_GH_CONFIG_DIR.to_string(), "/cfg/dir".to_string())]
);
}
#[test]
fn precedence_token_env_beats_account() {
let _g = env_lock();
let name = "TM_TEST_GH_TOKEN_BEATS_ACCOUNT_LIB";
unsafe { std::env::set_var(name, "tok2") };
let c = GithubConfig {
token_env: Some(name.to_string()),
account: Some("bob-work".to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
unsafe { std::env::remove_var(name) };
assert_eq!(
env.vars(),
&[(ENV_GH_TOKEN.to_string(), "tok2".to_string())]
);
}
#[test]
fn account_only_is_refused() {
let c = GithubConfig {
account: Some("bob-work".to_string()),
..cfg()
};
let err = resolve_gh_env(Some(&c)).unwrap_err();
assert_eq!(
err,
GhIdentityError::AccountStrategyUnsupported("bob-work".to_string())
);
let msg = err.to_string();
assert!(msg.contains("config_dir"), "msg: {msg}");
assert!(msg.contains("token_env"), "msg: {msg}");
}
#[test]
fn account_with_config_dir_is_ok() {
let c = GithubConfig {
config_dir: Some(PathBuf::from("/cfg/acct")),
account: Some("bob-work".to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
assert_eq!(
env.vars(),
&[(ENV_GH_CONFIG_DIR.to_string(), "/cfg/acct".to_string())]
);
}
#[test]
fn host_always_applied() {
let c = GithubConfig {
host: Some("github.example.com".to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
assert_eq!(
env.vars(),
&[(ENV_GH_HOST.to_string(), "github.example.com".to_string())]
);
}
#[test]
fn host_applied_with_identity() {
let c = GithubConfig {
config_dir: Some(PathBuf::from("/cfg")),
host: Some("ghe.corp".to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
assert_eq!(
env.vars(),
&[
(ENV_GH_CONFIG_DIR.to_string(), "/cfg".to_string()),
(ENV_GH_HOST.to_string(), "ghe.corp".to_string()),
]
);
}
#[test]
fn effective_host_default() {
assert_eq!(effective_host(None), "github.com");
assert_eq!(effective_host(Some(&cfg())), "github.com");
}
#[test]
fn effective_host_override() {
let c = GithubConfig {
host: Some("github.example.com".to_string()),
..cfg()
};
assert_eq!(effective_host(Some(&c)), "github.example.com");
}
#[test]
fn clone_url_default_host() {
assert_eq!(
clone_url(None, "bobmatnyc/trusty-tools"),
"https://github.com/bobmatnyc/trusty-tools"
);
}
#[test]
fn clone_url_enterprise_host() {
let c = GithubConfig {
host: Some("github.example.com".to_string()),
..cfg()
};
assert_eq!(
clone_url(Some(&c), "acme/widget"),
"https://github.example.com/acme/widget"
);
}
#[test]
fn select_github_config_project_wins() {
let project = GithubConfig {
account: Some("project-account".into()),
..cfg()
};
let global = GithubConfig {
account: Some("global-account".into()),
..cfg()
};
let selected = select_github_config(Some(&project), Some(&global));
assert_eq!(selected, Some(&project));
}
#[test]
fn select_github_config_falls_back_to_global() {
let global = GithubConfig {
account: Some("global-account".into()),
..cfg()
};
let selected = select_github_config(None, Some(&global));
assert_eq!(selected, Some(&global));
}
#[test]
fn select_github_config_none_when_neither_set() {
assert_eq!(select_github_config(None, None), None);
}
use crate::core::trusty_tools_config::ProjectConfig;
fn project_cfg(repo_url: &str, github: Option<GithubConfig>) -> ProjectConfig {
ProjectConfig {
name: "p".into(),
repo_url: repo_url.into(),
github,
..ProjectConfig::default()
}
}
#[test]
fn select_config_for_origin_project_wins() {
let config = TrustyToolsConfig {
github: Some(gh("/cfg/global")),
projects: vec![project_cfg(
"https://github.com/acme/widget",
Some(gh("/cfg/project")),
)],
..TrustyToolsConfig::default()
};
let selected =
select_config_for_origin(&config, Some("https://github.com/acme/widget.git"));
assert_eq!(
selected.and_then(|c| c.config_dir.as_deref()),
Some(std::path::Path::new("/cfg/project"))
);
}
#[test]
fn select_config_for_origin_falls_back_to_global() {
let config = TrustyToolsConfig {
github: Some(gh("/cfg/global")),
projects: vec![project_cfg("https://github.com/acme/widget", None)],
..TrustyToolsConfig::default()
};
let selected = select_config_for_origin(&config, Some("https://github.com/acme/widget"));
assert_eq!(
selected.and_then(|c| c.config_dir.as_deref()),
Some(std::path::Path::new("/cfg/global"))
);
}
#[test]
fn select_config_for_origin_no_match_uses_global() {
let config = TrustyToolsConfig {
github: Some(gh("/cfg/global")),
..TrustyToolsConfig::default()
};
let selected = select_config_for_origin(&config, Some("https://github.com/someone/else"));
assert_eq!(
selected.and_then(|c| c.config_dir.as_deref()),
Some(std::path::Path::new("/cfg/global"))
);
}
#[test]
fn select_config_for_origin_no_origin_uses_global() {
let config = TrustyToolsConfig {
github: Some(gh("/cfg/global")),
..TrustyToolsConfig::default()
};
let selected = select_config_for_origin(&config, None);
assert_eq!(
selected.and_then(|c| c.config_dir.as_deref()),
Some(std::path::Path::new("/cfg/global"))
);
}
fn gh(config_dir: &str) -> GithubConfig {
GithubConfig {
config_dir: Some(PathBuf::from(config_dir)),
..GithubConfig::default()
}
}
#[test]
fn describe_empty_env() {
assert!(GhEnv::default().describe().contains("no github: binding"));
}
#[test]
fn describe_config_dir() {
let env = resolve_gh_env(Some(&gh("/cfg/dir"))).expect("ok");
assert_eq!(
env.describe(),
"GH_CONFIG_DIR=/cfg/dir, -GH_TOKEN, -GITHUB_TOKEN, -GH_ENTERPRISE_TOKEN, \
-GITHUB_ENTERPRISE_TOKEN, -GH_USER"
);
}
#[test]
fn describe_redacts_token() {
let _g = env_lock();
let name = "TM_TEST_GH_TOKEN_DESCRIBE";
unsafe { std::env::set_var(name, "ghp_super_secret") };
let c = GithubConfig {
token_env: Some(name.to_string()),
..cfg()
};
let env = resolve_gh_env(Some(&c)).expect("ok");
unsafe { std::env::remove_var(name) };
let described = env.describe();
assert!(
described.contains("GH_TOKEN=<redacted>"),
"described: {described}"
);
assert!(
!described.contains("ghp_super_secret"),
"described: {described}"
);
}
#[test]
fn gh_env_debug_redacts_every_token() {
let env = GhEnv::from_identity_vars(vec![
("GH_TOKEN".to_string(), "tok-secret-one".to_string()),
(
"GH_ENTERPRISE_TOKEN".to_string(),
"tok-secret-two".to_string(),
),
("GH_HOST".to_string(), "ghe.corp".to_string()),
]);
let shown = format!("{env:?}");
assert!(!shown.contains("tok-secret"), "shown: {shown}");
assert!(
shown.contains("GH_TOKEN") && shown.contains("<redacted>"),
"shown: {shown}"
);
assert!(shown.contains("ghe.corp"), "shown: {shown}");
}
}