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, Clone, PartialEq, Eq)]
pub struct Contact {
pub text: String,
pub url: RemoteUrl,
pub remote: Option<String>,
}
#[derive(Debug)]
pub struct AwayPlan {
pub args: Vec<OsString>,
pub global_options: Vec<OsString>,
pub name: ProfileName,
pub profile: Profile,
pub home: Vec<Contact>,
}
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, urls) = 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 mut contacts: Vec<Contact> = Vec::new();
for (remote, text) in urls {
let Some(url) = remote_url::parse(&text) else {
continue;
};
let contact = Contact { text, url, remote };
if !contacts.contains(&contact) {
contacts.push(contact);
}
}
let remotes: Vec<RemoteUrl> = contacts.iter().map(|c| c.url.clone()).collect();
let inputs = Inputs {
flag: profile_flag,
env: env_profile.as_ref(),
git_config: git_config_profile.as_ref(),
remotes: &remotes,
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);
};
refuse_other_profiles_hosts(resolved.name, resolved.profile, &profiles, &remotes)?;
let handled: Vec<Contact> = contacts
.into_iter()
.filter(|contact| resolved.profile.hosts.handles(&contact.url))
.collect();
if handled.is_empty() {
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(),
home: handled,
})))
}
None => Ok(Plan::Away(Box::new(AwayPlan {
args,
global_options: invocation.global_options.to_vec(),
name: resolved.name.clone(),
profile: resolved.profile.clone(),
home: handled,
}))),
}
}
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")))
}
}
}
type RemoteAndUrl = (Option<String>, String);
fn query_repo(
repo: &Repo,
invocation: &Invocation<'_>,
) -> Result<(Option<String>, Vec<RemoteAndUrl>), RunError> {
Ok((repo.profile()?, urls_of(repo, invocation)?))
}
fn urls_of(repo: &Repo, invocation: &Invocation<'_>) -> Result<Vec<RemoteAndUrl>, RunError> {
let names = repo.remote_names()?;
let direction = invocation.direction();
let of_remote = |name: &str| -> Result<Vec<RemoteAndUrl>, RunError> {
Ok(repo
.remote_urls(name, direction)?
.into_iter()
.map(|url| (Some(name.to_owned()), url))
.collect())
};
let mut urls = Vec::new();
if invocation.reaches_several_remotes() {
for name in &names {
urls.extend(of_remote(name)?);
}
} else {
match invocation.remote_argument(|arg| names.iter().any(|name| name == arg)) {
Some(RemoteArg::Url(url)) => urls.push((None, repo.expand_url(url)?)),
Some(RemoteArg::Name(name)) => urls.extend(of_remote(name)?),
None if invocation.subcommand == Some("clone") => {}
None => urls.extend(of_remote(&repo.default_remote(direction)?)?),
}
}
if invocation.reaches_submodules() {
urls.extend(repo.submodule_urls()?.into_iter().map(|url| (None, url)));
}
Ok(urls)
}
fn refuse_other_profiles_hosts(
chosen_name: &ProfileName,
chosen: &Profile,
profiles: &BTreeMap<ProfileName, Profile>,
remotes: &[RemoteUrl],
) -> Result<(), Error> {
for remote in remotes
.iter()
.filter(|remote| !chosen.hosts.handles(remote))
{
if let Some((other, _)) = profiles
.iter()
.find(|(name, profile)| *name != chosen_name && profile.hosts.handles(remote))
{
return Err(Error::msg(format!(
"this command also reaches {}, a home host of profile `{other}`, but it runs \
with profile `{chosen_name}`. gitaway uses one tunnel per command: run it once \
per remote, such as `git push <remote>`",
remote.host
)));
}
}
Ok(())
}
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"
);
}