//! Where a repository lives: a folder on this machine, or a folder on another machine //! reached over ssh. use std::path::{Path, PathBuf}; #[derive(Clone, Debug, PartialEq)] pub enum Location { Local(PathBuf), /// `target` is what is given to ssh, like `host` or `user@host`. Ssh { target: String, path: String }, } /// Reads `[user@]host:path` as a remote location. Everything else is local: text where the /// part before the first `:` is a single character (a Windows drive like `C:`) or contains /// `/` or `\`, and text that is the whole name of a folder that exists inside `cwd`. pub fn parse(text: &str, cwd: &Path) -> Location { let local = || Location::Local(PathBuf::from(text)); let Some((target, path)) = text.split_once(':') else { return local() }; if target.chars().count() <= 1 || target.contains('/') || target.contains('\\') || cwd.join(text).exists() { return local(); } Location::Ssh { target: target.to_string(), path: path.to_string() } } impl Location { /// The text the user would type to open this location. pub fn name(&self) -> String { match self { Location::Local(path) => path.to_string_lossy().into_owned(), Location::Ssh { target, path } => format!("{target}:{path}"), } } /// The same machine with another folder. pub fn with_path(&self, path: &str) -> Location { match self { Location::Local(_) => Location::Local(PathBuf::from(path)), Location::Ssh { target, .. } => Location::Ssh { target: target.clone(), path: path.to_string() }, } } } /// Wraps one word in single quotes so a shell reads it back unchanged. pub fn quote(word: &str) -> String { format!("'{}'", word.replace('\'', r"'\''")) } /// The command line to run on the remote machine: `git -C ` followed by `args`. /// A path starting with `~/` keeps the `~` outside the quotes so the shell still /// replaces it with the home folder. pub fn remote_command(path: &str, args: &[&str]) -> String { let path = match path.strip_prefix("~/") { Some(rest) => format!("~/{}", quote(rest)), None => quote(path), }; let mut command = format!("git -C {path}"); for arg in args { command.push(' '); command.push_str("e(arg)); } command } #[cfg(test)] mod tests { use super::*; use std::process::Command; fn cwd() -> PathBuf { std::env::temp_dir() } #[test] fn reads_remote_locations() { assert_eq!( parse("myserver:/srv/app", &cwd()), Location::Ssh { target: "myserver".into(), path: "/srv/app".into() } ); assert_eq!( parse("ada@myserver:~/x", &cwd()), Location::Ssh { target: "ada@myserver".into(), path: "~/x".into() } ); } #[test] fn keeps_local_paths_local() { for text in [r"C:\code\x", "C:/code/x", "./a:b", "relative/dir", "/abs/dir"] { assert_eq!(parse(text, &cwd()), Location::Local(PathBuf::from(text)), "{text}"); } } #[test] fn keeps_an_existing_local_folder_with_a_colon_local() { let temp = std::env::temp_dir().join(format!("gites-location-{}", std::process::id())); let folder = temp.join("notes:2024"); std::fs::create_dir_all(&folder).unwrap(); assert_eq!(parse("notes:2024", &temp), Location::Local(PathBuf::from("notes:2024"))); std::fs::remove_dir_all(&folder).unwrap(); assert_eq!(parse("notes:2024", &temp), Location::Ssh { target: "notes".into(), path: "2024".into() }); std::fs::remove_dir_all(&temp).unwrap(); } #[test] fn names_locations() { let remote = Location::Ssh { target: "myserver".into(), path: "/srv/app".into() }; assert_eq!(remote.name(), "myserver:/srv/app"); assert_eq!(Location::Local(PathBuf::from("/abs/dir")).name(), "/abs/dir"); } #[test] fn changes_only_the_path() { let remote = Location::Ssh { target: "ada@myserver".into(), path: "/srv/app".into() }; assert_eq!(remote.with_path("/srv/other"), Location::Ssh { target: "ada@myserver".into(), path: "/srv/other".into() }); assert_eq!(Location::Local(PathBuf::from("/a")).with_path("/b"), Location::Local(PathBuf::from("/b"))); } #[test] fn quoted_words_survive_a_shell() { for word in ["plain", "with space", "it's", "\"double\"", "$HOME", "a;b", "*", "line\nbreak"] { let out = Command::new("sh").arg("-c").arg(format!("printf %s {}", quote(word))).output().unwrap(); assert_eq!(String::from_utf8(out.stdout).unwrap(), word); } } #[test] fn builds_the_remote_command() { assert_eq!( remote_command("/srv/my app", &["log", "--format=%H"]), "git -C '/srv/my app' 'log' '--format=%H'" ); assert_eq!(remote_command("~/x y", &["status"]), "git -C ~/'x y' 'status'"); } }