use crate::core::gh_account::{configured_account_pair, ensure_gh_account_in_dir};
use crate::core::gh_identity::{GhIdentityError, resolve_gh_env, select_github_config};
use crate::core::trusty_tools_config::{GithubConfig, TrustyToolsConfig};
use crate::project::record::repo_url_matches;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GitIdentity {
pub env: Vec<(String, String)>,
pub commit_name: Option<String>,
pub commit_email: Option<String>,
}
impl GitIdentity {
pub fn is_empty(&self) -> bool {
self.env.is_empty() && self.commit_name.is_none() && self.commit_email.is_none()
}
pub fn commit_config_args(&self) -> Vec<String> {
let mut args = Vec::new();
if let Some(name) = &self.commit_name {
args.push("-c".to_string());
args.push(format!("user.name={name}"));
}
if let Some(email) = &self.commit_email {
args.push("-c".to_string());
args.push(format!("user.email={email}"));
}
args
}
}
pub fn resolve_git_identity(
project_github: Option<&GithubConfig>,
global_github: Option<&GithubConfig>,
commit_name: Option<&str>,
commit_email: Option<&str>,
) -> Result<GitIdentity, GhIdentityError> {
let selected = select_github_config(project_github, global_github);
let env = resolve_gh_env(selected)?;
Ok(GitIdentity {
env: env.vars().to_vec(),
commit_name: commit_name.map(str::to_string),
commit_email: commit_email.map(str::to_string),
})
}
pub fn resolve_for_config(
config: &TrustyToolsConfig,
repo_url: &str,
) -> Result<GitIdentity, GhIdentityError> {
let matched = config
.projects
.iter()
.find(|p| repo_url_matches(&p.repo_url, repo_url));
let project_github = matched.and_then(|p| p.github.as_ref());
let commit_name = matched.and_then(|p| p.commit_name.as_deref());
let commit_email = matched.and_then(|p| p.commit_email.as_deref());
resolve_git_identity(
project_github,
config.github.as_ref(),
commit_name,
commit_email,
)
}
pub fn select_github_config_for<'a>(
config: &'a TrustyToolsConfig,
repo_url: &str,
) -> Option<&'a GithubConfig> {
let project_github = config
.projects
.iter()
.find(|p| repo_url_matches(&p.repo_url, repo_url))
.and_then(|p| p.github.as_ref());
select_github_config(project_github, config.github.as_ref())
}
pub async fn resolve_for_config_enforced(
config: &TrustyToolsConfig,
repo_url: &str,
) -> anyhow::Result<GitIdentity> {
let identity = resolve_for_config(config, repo_url)?;
if let Some((dir, account)) =
configured_account_pair(select_github_config_for(config, repo_url))
{
tokio::task::spawn_blocking(move || ensure_gh_account_in_dir(&account, &dir))
.await
.map_err(|e| anyhow::anyhow!("gh account enforcement task panicked: {e}"))??;
}
Ok(identity)
}
#[cfg(test)]
mod tests {
use super::*;
fn gh(account: &str) -> GithubConfig {
GithubConfig {
account: None,
config_dir: Some(std::path::PathBuf::from(format!("/cfg/{account}"))),
token_env: None,
host: None,
}
}
#[test]
fn resolve_git_identity_project_overrides_global() {
let project = gh("project");
let global = gh("global");
let identity = resolve_git_identity(Some(&project), Some(&global), None, None).expect("ok");
assert_eq!(
identity.env,
vec![("GH_CONFIG_DIR".to_string(), "/cfg/project".to_string())]
);
}
#[test]
fn resolve_git_identity_falls_back_to_global() {
let global = gh("global");
let identity = resolve_git_identity(None, Some(&global), None, None).expect("ok");
assert_eq!(
identity.env,
vec![("GH_CONFIG_DIR".to_string(), "/cfg/global".to_string())]
);
}
#[test]
fn resolve_git_identity_none_when_nothing_configured() {
let identity = resolve_git_identity(None, None, None, None).expect("ok");
assert!(identity.is_empty());
}
#[test]
fn resolve_git_identity_commit_identity_independent_of_github() {
let identity =
resolve_git_identity(None, None, Some("Bot"), Some("bot@example.com")).expect("ok");
assert!(identity.env.is_empty());
assert_eq!(identity.commit_name.as_deref(), Some("Bot"));
assert_eq!(identity.commit_email.as_deref(), Some("bot@example.com"));
}
fn project_config(
repo_url: &str,
github: Option<GithubConfig>,
) -> crate::core::trusty_tools_config::ProjectConfig {
crate::core::trusty_tools_config::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: Some("Project Bot".into()),
commit_email: Some("bot@project.example.com".into()),
untracked_sync: None,
worktree: None,
}
}
#[test]
fn resolve_for_config_matches_project_by_repo_url() {
let config = TrustyToolsConfig {
projects: vec![project_config(
"https://github.com/acme/widget.git",
Some(gh("project")),
)],
..Default::default()
};
let identity = resolve_for_config(&config, "https://github.com/acme/widget").expect("ok");
assert_eq!(
identity.env,
vec![("GH_CONFIG_DIR".to_string(), "/cfg/project".to_string())]
);
assert_eq!(identity.commit_name.as_deref(), Some("Project Bot"));
assert_eq!(
identity.commit_email.as_deref(),
Some("bot@project.example.com")
);
}
#[test]
fn resolve_for_config_falls_back_to_global_github() {
let config = TrustyToolsConfig {
github: Some(gh("global")),
projects: vec![project_config("https://github.com/acme/widget", None)],
..Default::default()
};
let identity = resolve_for_config(&config, "https://github.com/acme/widget").expect("ok");
assert_eq!(
identity.env,
vec![("GH_CONFIG_DIR".to_string(), "/cfg/global".to_string())]
);
}
#[test]
fn resolve_for_config_ambient_when_no_match() {
let config = TrustyToolsConfig::default();
let identity = resolve_for_config(&config, "https://github.com/someone/else").expect("ok");
assert!(identity.is_empty());
}
#[test]
fn select_github_config_for_project_wins() {
let config = TrustyToolsConfig {
github: Some(gh("global")),
projects: vec![project_config(
"https://github.com/acme/widget.git",
Some(gh("project")),
)],
..Default::default()
};
let selected = select_github_config_for(&config, "https://github.com/acme/widget");
assert_eq!(selected, Some(&gh("project")));
}
#[test]
fn select_github_config_for_falls_back_to_global() {
let config = TrustyToolsConfig {
github: Some(gh("global")),
projects: vec![project_config("https://github.com/acme/widget", None)],
..Default::default()
};
let selected = select_github_config_for(&config, "https://github.com/acme/widget");
assert_eq!(selected, Some(&gh("global")));
}
#[test]
fn select_github_config_for_no_match_is_global() {
let config = TrustyToolsConfig::default();
assert_eq!(
select_github_config_for(&config, "https://github.com/someone/else"),
None
);
}
fn fake_gh_lock() -> std::sync::MutexGuard<'static, ()> {
crate::core::trusty_tools_config::env_test_lock()
}
fn block_on<F: std::future::Future>(fut: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build current-thread runtime for test")
.block_on(fut)
}
#[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"),
}
}
}
fn account_paired_config(config_dir: &std::path::Path, account: &str) -> TrustyToolsConfig {
TrustyToolsConfig {
github: Some(GithubConfig {
config_dir: Some(config_dir.to_path_buf()),
token_env: None,
account: Some(account.to_string()),
host: None,
}),
..Default::default()
}
}
#[cfg(unix)]
#[test]
fn resolve_for_config_enforced_self_heals_mismatch() {
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 config = account_paired_config(config_dir.path(), "bobmatnyc");
let result = block_on(resolve_for_config_enforced(
&config,
"https://github.com/acme/widget",
));
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_for_config_enforced_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 config = account_paired_config(config_dir.path(), "bobmatnyc");
let unenforced = resolve_for_config(&config, "https://github.com/acme/widget");
assert!(
unenforced.is_ok(),
"sanity: the plain resolver must still ignore active-account state: {unenforced:?}"
);
let enforced = block_on(resolve_for_config_enforced(
&config,
"https://github.com/acme/widget",
));
restore_path(prior_path);
let err = enforced.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_for_config_enforced_skips_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 = TrustyToolsConfig {
github: Some(gh(&config_dir.path().display().to_string())),
..Default::default()
};
let result = block_on(resolve_for_config_enforced(
&config,
"https://github.com/acme/widget",
));
restore_path(prior_path);
assert!(
result.is_ok(),
"no account paired with config_dir must skip enforcement entirely: {result:?}"
);
}
#[test]
fn commit_config_args_empty_when_unset() {
assert!(GitIdentity::default().commit_config_args().is_empty());
}
#[test]
fn commit_config_args_both_set() {
let identity = GitIdentity {
commit_name: Some("Bot".into()),
commit_email: Some("bot@example.com".into()),
..Default::default()
};
assert_eq!(
identity.commit_config_args(),
vec![
"-c".to_string(),
"user.name=Bot".to_string(),
"-c".to_string(),
"user.email=bot@example.com".to_string(),
]
);
}
#[test]
fn commit_config_args_name_only() {
let identity = GitIdentity {
commit_name: Some("Bot".into()),
..Default::default()
};
assert_eq!(
identity.commit_config_args(),
vec!["-c".to_string(), "user.name=Bot".to_string()]
);
}
}