use std::collections::BTreeMap;
use std::ffi::OsString;
use crate::config::name::ProfileName;
use crate::config::profile::Profile;
use crate::config::resolve::{self, Inputs};
use crate::error::Error;
use crate::git::classify::{Invocation, RemoteArg};
use crate::git::remote_url::{self, RemoteUrl};
use crate::git::repo::{PROFILE_KEY, Repo};
use crate::git::runner::{Git, RunError};
use crate::home_check::{self, AwayReason, Verdict};
pub const PROFILE_ENV: &str = "GITAWAY_PROFILE";
#[derive(Debug)]
pub enum Plan {
Direct(Vec<OsString>),
Away(Box<AwayPlan>),
}
#[derive(Debug)]
pub struct AwayPlan {
pub args: Vec<OsString>,
pub global_options: Vec<OsString>,
pub name: ProfileName,
pub profile: Profile,
pub remote: RemoteUrl,
}
pub fn plan(
invocation: &Invocation<'_>,
profile_flag: Option<&ProfileName>,
) -> Result<Plan, Error> {
let as_typed = as_typed(invocation);
let direct = |args| Ok(Plan::Direct(args));
let env_profile = env_profile()?;
let store = super::open_store()?;
let profiles = store.load_profiles()?;
if profiles.is_empty() && profile_flag.is_none() && env_profile.is_none() {
return direct(as_typed);
}
let global = store.load_global()?;
let repo = Repo::new(Git::new(invocation.global_options));
let (git_config_profile, url) = match query_repo(&repo, invocation) {
Ok(facts) => facts,
Err(RunError::Failed { .. }) => return direct(as_typed),
Err(e) => return Err(e.into()),
};
let git_config_profile = git_config_profile
.map(|name| name.parse::<ProfileName>())
.transpose()
.map_err(|e| Error::msg(format!("git config {PROFILE_KEY}: {e}")))?;
let remote = url.as_deref().and_then(remote_url::parse);
let inputs = Inputs {
flag: profile_flag,
env: env_profile.as_ref(),
git_config: git_config_profile.as_ref(),
remote: remote.as_ref(),
default: global.default_profile.as_ref(),
};
let mut verdicts: BTreeMap<ProfileName, Verdict> = BTreeMap::new();
let resolved = resolve::resolve(inputs, &profiles, |name, profile| {
let verdict = verdict_if_checkable(profile);
let home = verdict == Some(Verdict::Home);
if let Some(verdict) = verdict {
verdicts.insert(name.clone(), verdict);
}
home
})?;
let Some(resolved) = resolved else {
return direct(as_typed);
};
let Some(remote) = remote else {
return direct(as_typed);
};
if !resolved.profile.hosts.handles(&remote) {
return direct(as_typed);
}
let verdict = match verdicts.remove(resolved.name) {
Some(verdict) => Some(verdict),
None => home_verdict(resolved.profile)?,
};
let args = pin_clone(invocation, resolved.name).unwrap_or(as_typed);
match verdict {
Some(Verdict::Home) => direct(args),
Some(Verdict::Away(reason)) => {
match &reason {
AwayReason::WrongKey { fingerprint } => {
wrong_key_note(resolved.name, resolved.profile, fingerprint);
}
AwayReason::NotPinned => eprintln!(
"gitaway: note: profile `{}` has no home host key yet, so it can't tell when \
you're home and uses the tunnel. Pin the key with `gitaway check {}`",
resolved.name, resolved.name
),
_ => {}
}
Ok(Plan::Away(Box::new(AwayPlan {
args,
global_options: invocation.global_options.to_vec(),
name: resolved.name.clone(),
profile: resolved.profile.clone(),
remote,
})))
}
None => Ok(Plan::Away(Box::new(AwayPlan {
args,
global_options: invocation.global_options.to_vec(),
name: resolved.name.clone(),
profile: resolved.profile.clone(),
remote,
}))),
}
}
fn as_typed(invocation: &Invocation<'_>) -> Vec<OsString> {
let mut args = invocation.global_options.to_vec();
args.extend(invocation.subcommand.map(OsString::from));
args.extend(invocation.args.iter().cloned());
args
}
pub(crate) fn env_profile() -> Result<Option<ProfileName>, Error> {
match std::env::var(PROFILE_ENV) {
Ok(value) if !value.is_empty() => value
.parse()
.map(Some)
.map_err(|e| Error::msg(format!("{PROFILE_ENV}: {e}"))),
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => {
Err(Error::msg(format!("{PROFILE_ENV} isn't valid UTF-8")))
}
}
}
fn query_repo(
repo: &Repo,
invocation: &Invocation<'_>,
) -> Result<(Option<String>, Option<String>), RunError> {
Ok((repo.profile()?, remote_url_of(repo, invocation)?))
}
fn remote_url_of(repo: &Repo, invocation: &Invocation<'_>) -> Result<Option<String>, RunError> {
let names = repo.remote_names()?;
let direction = invocation.direction();
let url = match invocation.remote_argument(|arg| names.iter().any(|name| name == arg)) {
Some(RemoteArg::Url(url)) => Some(repo.expand_url(url)?),
Some(RemoteArg::Name(name)) => repo.remote_url(name, direction)?,
None if invocation.subcommand == Some("clone") => None,
None => {
let remote = repo.default_remote(direction)?;
repo.remote_url(&remote, direction)?
}
};
Ok(url)
}
fn home_verdict(profile: &Profile) -> Result<Option<Verdict>, Error> {
match &profile.home {
Some(home) => Ok(Some(home_check::check(home)?)),
None => Ok(None),
}
}
fn verdict_if_checkable(profile: &Profile) -> Option<Verdict> {
profile
.home
.as_ref()
.and_then(|home| home_check::check(home).ok())
}
fn pin_clone(invocation: &Invocation<'_>, name: &ProfileName) -> Option<Vec<OsString>> {
if invocation.subcommand != Some("clone") {
return None;
}
let mut args = invocation.global_options.to_vec();
args.push("clone".into());
args.push("--config".into());
args.push(format!("{PROFILE_KEY}={name}").into());
args.extend(invocation.args.iter().cloned());
Some(args)
}
fn wrong_key_note(name: &ProfileName, profile: &Profile, fingerprint: &str) {
let server = profile
.home
.as_ref()
.map_or_else(|| "the home server".to_owned(), |home| home.ssh.to_string());
eprintln!(
"gitaway: note: {server} answered with a different host key ({fingerprint}), so this \
isn't profile `{name}`'s home network. Using the tunnel.\n\
If you are home, the server's key may have changed: `gitaway check {name}` fetches the \
server's current key through the tunnel and offers to update it"
);
}