Read host:path locations and quote words for a server's shell
This commit is contained in:
@@ -2,6 +2,7 @@ mod details;
|
||||
mod diff;
|
||||
mod filters;
|
||||
mod git;
|
||||
mod location;
|
||||
mod settings;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! 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 <path>` 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'");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user