Read host:path locations and quote words for a server's shell

This commit is contained in:
2026-10-15 15:05:23 +02:00
parent ddb05980e0
commit 157a4063c7
2 changed files with 134 additions and 0 deletions
+1
View File
@@ -2,6 +2,7 @@ mod details;
mod diff;
mod filters;
mod git;
mod location;
mod settings;
use std::path::PathBuf;
+133
View File
@@ -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(&quote(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'");
}
}