use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::core::gh_account_registry::find_pinned_gh_identity;
use crate::core::trusty_tools_config::GithubConfig;
pub const GH_DOCTOR_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GhAccountStatus {
pub active: Option<String>,
pub logged_in: Vec<String>,
}
impl GhAccountStatus {
pub fn is_ambiguous(&self) -> bool {
self.logged_in.len() > 1
}
pub fn canonical_logged_in_login(&self, login: &str) -> Option<String> {
let needle = login.trim();
self.logged_in
.iter()
.find(|known| known.eq_ignore_ascii_case(needle))
.cloned()
}
}
pub(crate) fn gh_config_dir() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("GH_CONFIG_DIR") {
let dir = dir.trim();
if !dir.is_empty() {
return Some(PathBuf::from(dir));
}
}
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
let xdg = xdg.trim();
if !xdg.is_empty() {
return Some(PathBuf::from(xdg).join("gh"));
}
}
dirs::home_dir().map(|h| h.join(".config").join("gh"))
}
fn gh_hosts_yml_path() -> Option<PathBuf> {
Some(gh_config_dir()?.join("hosts.yml"))
}
pub fn parse_active_account_from_hosts_yml(yaml: &str) -> Option<String> {
let doc: serde_yaml::Value = serde_yaml::from_str(yaml).ok()?;
let user = doc.get("github.com")?.get("user")?.as_str()?.trim();
if user.is_empty() {
None
} else {
Some(user.to_string())
}
}
pub fn parse_gh_account_status_from_hosts_yml(yaml: &str) -> Option<GhAccountStatus> {
let doc: serde_yaml::Value = serde_yaml::from_str(yaml).ok()?;
let host = doc.get("github.com")?;
let active = host
.get("user")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let mut logged_in: Vec<String> = Vec::new();
if let Some(users) = host.get("users").and_then(|v| v.as_mapping()) {
for key in users.keys() {
if let Some(name) = key.as_str() {
let name = name.trim();
if !name.is_empty() && !logged_in.iter().any(|e| e == name) {
logged_in.push(name.to_string());
}
}
}
}
if logged_in.is_empty()
&& let Some(a) = &active
{
logged_in.push(a.clone());
}
if active.is_none() && logged_in.is_empty() {
return None;
}
Some(GhAccountStatus { active, logged_in })
}
pub fn parse_logged_in_accounts(auth_status: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for line in auth_status.lines() {
let Some((_, rest)) = line.split_once("Logged in to") else {
continue;
};
if let Some(login) = extract_login_token(rest)
&& !out.iter().any(|e| e == &login)
{
out.push(login);
}
}
out
}
fn extract_login_token(rest: &str) -> Option<String> {
let tokens: Vec<&str> = rest.split_whitespace().collect();
for keyword in ["account", "as"] {
if let Some(pos) = tokens.iter().position(|t| *t == keyword)
&& let Some(raw) = tokens.get(pos + 1)
{
let login = raw.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_');
if !login.is_empty() {
return Some(login.to_string());
}
}
}
None
}
pub fn gh_account_status_local() -> Option<GhAccountStatus> {
let path = gh_hosts_yml_path()?;
let contents = std::fs::read_to_string(&path).ok()?;
parse_gh_account_status_from_hosts_yml(&contents)
}
pub(crate) fn run_bounded<T, F>(timeout: Duration, f: F) -> Option<T>
where
T: Send + 'static,
F: FnOnce() -> Option<T> + Send + 'static,
{
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(f());
});
rx.recv_timeout(timeout).ok().flatten()
}
pub fn parse_gh_account_status_from_auth_status(auth_status: &str) -> GhAccountStatus {
let logged_in = parse_logged_in_accounts(auth_status);
let mut active: Option<String> = None;
let mut current: Option<String> = None;
for line in auth_status.lines() {
if let Some((_, rest)) = line.split_once("Logged in to") {
current = extract_login_token(rest);
} else if active.is_none() && line.contains("Active account: true") {
active = current.clone();
}
}
if active.is_none() && logged_in.len() == 1 {
active = logged_in.first().cloned();
}
GhAccountStatus { active, logged_in }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GhAuthProbe {
Answered(GhAccountStatus),
Inconclusive(String),
}
pub fn probe_gh_auth_with<F>(timeout: Duration, run: F) -> GhAuthProbe
where
F: FnOnce() -> Result<trusty_common::gh::GhOutput, String> + Send + 'static,
{
run_bounded(timeout, move || {
Some(crate::core::gh_login_probe::classify_auth_status(run()))
})
.unwrap_or_else(|| {
GhAuthProbe::Inconclusive(format!(
"`gh auth status` did not answer within {timeout:?}"
))
})
}
pub fn probe_gh_auth(timeout: Duration) -> GhAuthProbe {
probe_gh_auth_with(timeout, || {
trusty_common::gh::GhCommand::new(["auth", "status"])
.output_blocking()
.map_err(|e| e.to_string())
})
}
pub(crate) const GH_ENFORCE_TIMEOUT: Duration = Duration::from_secs(5);
#[path = "gh_account_enforce.rs"]
mod enforce;
pub use enforce::{
AccountTarget, GH_CONFIG_DIR_ENV, configured_account_pair, ensure_gh_account_for_project,
ensure_gh_account_in_dir,
};
pub(crate) use enforce::api_login_scoped;
pub const GH_TOKEN_ENV_VAR: &str = "GH_TOKEN";
pub const GH_USER_ENV_VAR: &str = "GH_USER";
pub fn gh_token_via_cli(account: &str) -> Result<String, String> {
let owned = account.to_string();
run_bounded(GH_ENFORCE_TIMEOUT, move || {
Some(
trusty_common::gh::GhCommand::new(["auth", "token", "-u", &owned])
.nonempty_stdout_blocking()
.map_err(|e| format!("`gh auth token -u {owned}` failed: {e}")),
)
})
.unwrap_or_else(|| {
Err(format!(
"`gh auth token -u {account}` did not respond within {GH_ENFORCE_TIMEOUT:?} \
(is `gh` installed and on PATH?)"
))
})
}
#[derive(Clone, Default, PartialEq, Eq)]
pub struct GhSpawnEnv {
pub vars: Vec<(String, String)>,
pub warning: Option<String>,
}
impl std::fmt::Debug for GhSpawnEnv {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GhSpawnEnv")
.field("vars", &crate::core::gh_identity::RedactedVars(&self.vars))
.field("warning", &self.warning)
.finish()
}
}
pub(crate) fn config_dir_has_credential(dir: &Path) -> bool {
std::fs::read_to_string(dir.join("hosts.yml"))
.ok()
.and_then(|text| parse_gh_account_status_from_hosts_yml(&text))
.is_some_and(|status| !status.logged_in.is_empty())
}
fn scoped_config_dir_env(dir: &Path, account: Option<&str>) -> GhSpawnEnv {
let cfg = GithubConfig {
config_dir: Some(dir.to_path_buf()),
account: account.map(str::to_string),
..GithubConfig::default()
};
match crate::core::gh_identity::resolve_gh_env(Some(&cfg)) {
Ok(env) => {
let mut vars = env.vars().to_vec();
if let Some(account) = account {
vars.push((GH_USER_ENV_VAR.to_string(), account.to_string()));
}
let warning =
(!config_dir_has_credential(dir)).then(|| no_credential_warning(dir, account));
GhSpawnEnv { vars, warning }
}
Err(e) => GhSpawnEnv {
vars: Vec::new(),
warning: Some(format!(
"cannot pin this session to gh config dir {}: {e}",
dir.display()
)),
},
}
}
fn no_credential_warning(dir: &Path, account: Option<&str>) -> String {
let dir = dir.display();
let who = account.unwrap_or("the pinned account");
format!(
"gh config dir {dir} holds no github.com credential ({dir}/hosts.yml is missing or \
names no account), so every `gh` call in this session will fail with exit 4. Run \
`GH_CONFIG_DIR={dir} gh auth login` to authenticate as {who} inside it. tm does not \
fall back to the machine-global gh account here — that fallback is the wrong-identity \
defect #5851 fixes."
)
}
pub fn resolve_gh_account_env_with(
gh_account: Option<&str>,
config_dir: Option<&Path>,
resolve_token: impl FnOnce(&str) -> Result<String, String>,
) -> Option<Result<GhSpawnEnv, String>> {
let account = gh_account.map(str::trim).filter(|s| !s.is_empty());
let dir = config_dir
.map(|d| d.to_string_lossy().trim().to_string())
.filter(|s| !s.is_empty())
.map(PathBuf::from);
if let Some(dir) = dir {
return Some(Ok(scoped_config_dir_env(&dir, account)));
}
let account = account?;
Some(resolve_token(account).map(|token| GhSpawnEnv {
vars: vec![
(GH_TOKEN_ENV_VAR.to_string(), token),
(GH_USER_ENV_VAR.to_string(), account.to_string()),
],
warning: None,
}))
}
pub fn resolve_gh_account_env(
gh_account: Option<&str>,
config_dir: Option<&Path>,
) -> Option<Result<GhSpawnEnv, String>> {
resolve_gh_account_env_with(gh_account, config_dir, gh_token_via_cli)
}
pub async fn resolve_gh_account_env_for_registry(
registry: &crate::project::ProjectRegistry,
cwd: &std::path::Path,
) -> Vec<(String, String)> {
let load = crate::core::gh_org_accounts::OrgAccounts::load_default;
resolve_gh_account_env_for_registry_with(registry, cwd, load).await
}
pub(crate) async fn resolve_gh_account_env_for_registry_with(
registry: &crate::project::ProjectRegistry,
cwd: &std::path::Path,
load: impl FnOnce() -> Result<
crate::core::gh_org_accounts::OrgAccounts,
crate::core::gh_org_accounts::OrgAccountsError,
>,
) -> Vec<(String, String)> {
use crate::core::remote_mode::{RemoteMode, remote_mode};
let cwd_for_origin = cwd.to_path_buf();
let probe = tokio::task::spawn_blocking(move || remote_mode(&cwd_for_origin)).await;
let origin = match probe {
Ok(Ok(RemoteMode::Origin(origin))) => origin,
Ok(Ok(RemoteMode::LocalOnly { root })) => {
let config = crate::core::config::MpmConfig::load_default();
return crate::core::remote_mode::local_only_spawn_vars(&root, &config);
}
Ok(Err(e)) => {
tracing::warn!(
cwd = %cwd.display(),
"cannot read git origin remote; the session's gh authenticates as nobody: {e}"
);
return crate::core::gh_account_proof::identity_token_vars(None);
}
Ok(Ok(RemoteMode::NotARepository)) | Err(_) => return Vec::new(),
};
let pinned = match find_pinned_gh_identity(registry, &origin).await {
Ok(Some(p)) if p.account.is_none() && p.config_dir.is_none() => return Vec::new(),
Ok(Some(pinned)) => pinned,
Ok(None) => match crate::core::gh_org_accounts::org_map_pin(
&origin,
&crate::session_manager::ssh_host_alias::SshHostAliases::for_current_user(),
load,
) {
Ok(Some(pinned)) => pinned,
Ok(None) => return Vec::new(),
Err(env) => return log_spawn_env(Some(Ok(env)), cwd),
},
Err(reason) => {
let env = refused_spawn_env(None, &origin, &reason, Default::default());
return log_spawn_env(Some(Ok(env)), cwd);
}
};
let cwd_for_log = cwd.to_path_buf();
let (pinned_for_task, origin_for_task) = (pinned.clone(), origin.clone());
let joined = tokio::task::spawn_blocking(move || {
use crate::core::gh_session_account::{StoredTokenFirst, session_spawn_env};
let state_root = crate::core::paths::FrameworkPaths::default().root;
let aliases = crate::session_manager::ssh_host_alias::SshHostAliases::for_current_user();
let prove = |login: &str| {
use crate::core::gh_account_proof::{AccountProver, CliTokenProbe, HttpUserCheck};
let mut sources = crate::core::gh_account_dir::AccountDirSources::for_origin(
&crate::core::trusty_tools_config::TrustyToolsConfig::load(),
&origin_for_task,
state_root.clone(),
gh_config_dir(),
);
if let Some(dir) = &pinned_for_task.config_dir {
sources.static_config_dir = Some(dir.clone());
}
let prover = AccountProver {
sources: &sources,
probe: &StoredTokenFirst(&CliTokenProbe),
check: &HttpUserCheck,
cache: None,
};
spawn_proof(&prover, login, &origin_for_task, &aliases)
};
let env = session_spawn_env(
&pinned_for_task,
&origin_for_task,
&state_root,
&aliases,
prove,
);
log_spawn_env(env, &cwd_for_log)
})
.await;
joined_spawn_vars(joined, &pinned, &origin, cwd)
}
pub(crate) fn spawn_proof(
prover: &crate::core::gh_account_proof::AccountProver<'_>,
login: &str,
origin: &str,
aliases: &crate::session_manager::ssh_host_alias::SshHostAliases,
) -> Result<crate::core::gh_account_proof::ProvenToken, String> {
let origin = crate::session_manager::worktree_reclaim_gh::proof_origin(origin, aliases)?;
prover
.prove(login, &origin)
.map_err(|reasons| reasons.join("; "))
}
pub(crate) fn joined_spawn_vars(
joined: Result<Vec<(String, String)>, tokio::task::JoinError>,
pinned: &PinnedGhIdentity,
origin: &str,
cwd: &Path,
) -> Vec<(String, String)> {
match joined {
Ok(vars) => vars,
Err(e) => {
let login = pinned
.account
.as_deref()
.map(str::trim)
.filter(|a| !a.is_empty());
let reason = format!("the gh identity task did not finish: {e}");
let env = refused_spawn_env(login, origin, &reason, pinned.source);
log_spawn_env(Some(Ok(env)), cwd)
}
}
}
pub(crate) fn log_spawn_env(
env: Option<Result<GhSpawnEnv, String>>,
cwd: &Path,
) -> Vec<(String, String)> {
match env {
None => Vec::new(),
Some(Ok(env)) => {
if let Some(warning) = env.warning {
tracing::warn!(cwd = %cwd.display(), "{warning}");
}
env.vars
}
Some(Err(msg)) => {
tracing::warn!(
cwd = %cwd.display(),
"gh_account token resolution failed; spawning without GH_TOKEN: {msg}"
);
Vec::new()
}
}
}
pub(crate) const REFUSED_GH_TOKEN: &str = "tm-refused-unverified-gh-account-pin";
pub(crate) fn pinned_spawn_env(
pinned: &PinnedGhIdentity,
origin: &str,
prove: impl FnOnce(&str) -> Result<crate::core::gh_account_proof::ProvenToken, String>,
) -> Option<Result<GhSpawnEnv, String>> {
let account = pinned
.account
.as_deref()
.map(str::trim)
.filter(|a| !a.is_empty());
let login = account?;
Some(Ok(match prove(login) {
Ok(proven) => {
let mut vars = proven.identity_vars();
vars.push((GH_USER_ENV_VAR.to_string(), login.to_string()));
GhSpawnEnv {
vars,
warning: None,
}
}
Err(reason) => refused_spawn_env(Some(login), origin, &reason, pinned.source),
}))
}
fn refused_spawn_env(
login: Option<&str>,
origin: &str,
reason: &str,
source: crate::core::gh_org_accounts::AccountSource,
) -> GhSpawnEnv {
let mut vars = crate::core::gh_account_proof::identity_token_vars(None);
vars.extend(login.map(|l| (GH_USER_ENV_VAR.to_string(), l.to_string())));
let who = login.unwrap_or("<login>");
let warning = match source {
crate::core::gh_org_accounts::AccountSource::OrgMap => {
crate::core::gh_org_accounts::org_map_refusal(who, reason)
}
_ => format!(
"this project is pinned to gh account '{who}', and no gh token is proven by `GET /user` to be its own ({reason}). The session's gh is \
given a token that authenticates as nobody, so it fails instead of acting as the \
machine's global account (#8510). Fix: `tm projects register <name> --repo-url \
{} --gh-account {who} --gh-config-dir <dir>`.",
crate::core::remote_url_redact::redact_stored_url(origin)
),
};
GhSpawnEnv {
vars,
warning: Some(warning),
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PinnedGhIdentity {
pub account: Option<String>,
pub config_dir: Option<PathBuf>,
pub source: crate::core::gh_org_accounts::AccountSource,
}
#[cfg(test)]
#[path = "gh_account_spawn_env_tests.rs"]
mod spawn_env_tests;
#[cfg(test)]
mod tests {
use super::*;
const MULTI_HOSTS_YML: &str = "\
github.com:
git_protocol: https
users:
bobmatnyc:
bob-duetto:
user: bob-duetto
";
const SINGLE_HOSTS_YML: &str = "\
github.com:
git_protocol: https
user: bobmatnyc
";
const MULTI_AUTH_STATUS: &str = "\
github.com
✓ Logged in to github.com account bob-duetto (keyring)
- Active account: true
- Git operations protocol: https
- Token: gho_************************************
- Token scopes: 'admin:org', 'gist', 'project', 'repo', 'workflow'
✓ Logged in to github.com account bobmatnyc (keyring)
- Active account: false
- Git operations protocol: https
- Token: gho_************************************
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
";
fn gh_stdout(text: &str) -> trusty_common::gh::GhOutput {
trusty_common::gh::GhOutput::from_parts("auth status", Some(0), text, "")
}
const SINGLE_AUTH_STATUS: &str = "\
github.com
✓ Logged in to github.com account bobmatnyc (keyring)
- Active account: true
- Git operations protocol: https
- Token: gho_************************************
";
#[test]
fn parse_active_account_present() {
assert_eq!(
parse_active_account_from_hosts_yml(MULTI_HOSTS_YML).as_deref(),
Some("bob-duetto")
);
assert_eq!(
parse_active_account_from_hosts_yml(SINGLE_HOSTS_YML).as_deref(),
Some("bobmatnyc")
);
}
#[test]
fn parse_active_account_absent() {
assert_eq!(parse_active_account_from_hosts_yml(""), None);
assert_eq!(
parse_active_account_from_hosts_yml("other.host:\n user: x"),
None
);
assert_eq!(
parse_active_account_from_hosts_yml("github.com:\n git_protocol: https"),
None
);
assert_eq!(parse_active_account_from_hosts_yml(": : ["), None);
}
#[test]
fn parse_gh_account_status_single() {
let status = parse_gh_account_status_from_hosts_yml(SINGLE_HOSTS_YML).expect("some");
assert_eq!(status.active.as_deref(), Some("bobmatnyc"));
assert_eq!(status.logged_in, vec!["bobmatnyc".to_string()]);
assert!(!status.is_ambiguous());
}
#[test]
fn parse_gh_account_status_multi_from_users_map() {
let status = parse_gh_account_status_from_hosts_yml(MULTI_HOSTS_YML).expect("some");
assert_eq!(status.active.as_deref(), Some("bob-duetto"));
assert!(status.logged_in.contains(&"bobmatnyc".to_string()));
assert!(status.logged_in.contains(&"bob-duetto".to_string()));
assert_eq!(status.logged_in.len(), 2);
assert!(status.is_ambiguous());
}
#[test]
fn parse_gh_account_status_empty() {
assert_eq!(parse_gh_account_status_from_hosts_yml(""), None);
assert_eq!(
parse_gh_account_status_from_hosts_yml("enterprise.example.com:\n user: x"),
None
);
}
#[test]
fn parse_logged_in_single() {
assert_eq!(
parse_logged_in_accounts(SINGLE_AUTH_STATUS),
vec!["bobmatnyc".to_string()]
);
}
#[test]
fn parse_logged_in_multiple() {
assert_eq!(
parse_logged_in_accounts(MULTI_AUTH_STATUS),
vec!["bob-duetto".to_string(), "bobmatnyc".to_string()]
);
}
#[test]
fn parse_logged_in_older_as_phrasing() {
let text = "✓ Logged in to github.com as octocat (oauth_token)";
assert_eq!(parse_logged_in_accounts(text), vec!["octocat".to_string()]);
}
#[test]
fn parse_logged_in_empty() {
assert!(parse_logged_in_accounts("").is_empty());
assert!(parse_logged_in_accounts("You are not logged into any GitHub hosts.").is_empty());
}
const TOKEN_AUTH_STATUS: &str = "\
github.com
✓ Logged in to github.com account mac-duetto (GH_TOKEN)
- Active account: true
- Git operations protocol: https
- Token scopes: 'repo', 'workflow'
";
#[test]
fn parse_auth_status_active_from_marker() {
let status = parse_gh_account_status_from_auth_status(MULTI_AUTH_STATUS);
assert_eq!(status.active.as_deref(), Some("bob-duetto"));
assert_eq!(
status.logged_in,
vec!["bob-duetto".to_string(), "bobmatnyc".to_string()]
);
assert!(status.is_ambiguous());
}
#[test]
fn canonical_logged_in_login_is_case_insensitive() {
let status = parse_gh_account_status_from_auth_status(MULTI_AUTH_STATUS);
assert_eq!(
status.canonical_logged_in_login("BobMatNyc").as_deref(),
Some("bobmatnyc")
);
assert_eq!(
status
.canonical_logged_in_login(" bob-duetto ")
.as_deref(),
Some("bob-duetto")
);
}
#[test]
fn canonical_logged_in_login_rejects_unknown() {
let status = parse_gh_account_status_from_auth_status(MULTI_AUTH_STATUS);
assert_eq!(status.canonical_logged_in_login("typo-bot"), None);
assert_eq!(
GhAccountStatus::default().canonical_logged_in_login("bobmatnyc"),
None
);
}
#[test]
fn parse_auth_status_token_auth() {
let status = parse_gh_account_status_from_auth_status(TOKEN_AUTH_STATUS);
assert_eq!(status.active.as_deref(), Some("mac-duetto"));
assert_eq!(status.logged_in, vec!["mac-duetto".to_string()]);
assert!(!status.is_ambiguous());
}
#[test]
fn parse_auth_status_unauthenticated() {
let status =
parse_gh_account_status_from_auth_status("You are not logged into any GitHub hosts.");
assert_eq!(status, GhAccountStatus::default());
}
#[test]
fn doctor_probe_tolerates_token_auth_latency() {
let probe = probe_gh_auth_with(GH_DOCTOR_TIMEOUT, || {
std::thread::sleep(Duration::from_millis(400));
Ok(gh_stdout(TOKEN_AUTH_STATUS))
});
let GhAuthProbe::Answered(status) = probe else {
panic!("token-auth probe must answer under GH_DOCTOR_TIMEOUT, got {probe:?}");
};
assert_eq!(status.active.as_deref(), Some("mac-duetto"));
}
#[test]
fn probe_beyond_bound_is_inconclusive() {
let probe = probe_gh_auth_with(Duration::from_millis(50), || {
std::thread::sleep(Duration::from_millis(500));
Ok(gh_stdout(TOKEN_AUTH_STATUS))
});
match probe {
GhAuthProbe::Inconclusive(reason) => assert!(reason.contains("did not answer")),
other => panic!("expected Inconclusive, got {other:?}"),
}
}
#[test]
fn probe_missing_gh_is_inconclusive() {
match probe_gh_auth_with(GH_DOCTOR_TIMEOUT, || Err("no such file".to_string())) {
GhAuthProbe::Inconclusive(reason) => assert!(reason.contains("could not run gh")),
other => panic!("expected Inconclusive, got {other:?}"),
}
}
#[test]
fn probe_answered_parses_accounts() {
let probe = probe_gh_auth_with(GH_DOCTOR_TIMEOUT, || Ok(gh_stdout(MULTI_AUTH_STATUS)));
assert_eq!(
probe,
GhAuthProbe::Answered(parse_gh_account_status_from_auth_status(MULTI_AUTH_STATUS))
);
}
#[test]
fn is_ambiguous_boundary() {
let none = GhAccountStatus::default();
assert!(!none.is_ambiguous());
let one = GhAccountStatus {
active: Some("a".into()),
logged_in: vec!["a".into()],
};
assert!(!one.is_ambiguous());
let two = GhAccountStatus {
active: Some("a".into()),
logged_in: vec!["a".into(), "b".into()],
};
assert!(two.is_ambiguous());
}
}