Open repositories on another machine over ssh; keep the window responsive while git runs #5
@@ -16,7 +16,7 @@ pub struct ChangedFile {
|
|||||||
pub deleted: Option<u32>,
|
pub deleted: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
#[derive(Serialize, Debug, PartialEq)]
|
||||||
pub struct CommitDetails {
|
pub struct CommitDetails {
|
||||||
hash: String,
|
hash: String,
|
||||||
author: String,
|
author: String,
|
||||||
@@ -153,4 +153,37 @@ mod tests {
|
|||||||
fn parses_an_empty_diff() {
|
fn parses_an_empty_diff() {
|
||||||
assert_eq!(parse_files("", ""), vec![]);
|
assert_eq!(parse_files("", ""), vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shows_the_same_details_through_ssh() {
|
||||||
|
use std::process::Command;
|
||||||
|
let dir = std::env::temp_dir().join(format!("gites-details-ssh-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let run = |args: &[&str]| {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&dir)
|
||||||
|
.args(["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false"])
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||||
|
};
|
||||||
|
run(&["init", "-q", "-b", "main"]);
|
||||||
|
std::fs::write(dir.join("it's $a; b.txt"), "one\ntwo\n").unwrap();
|
||||||
|
run(&["add", "-A"]);
|
||||||
|
run(&["commit", "-q", "-m", "first"]);
|
||||||
|
std::fs::write(dir.join("it's $a; b.txt"), "one\nthree\n").unwrap();
|
||||||
|
run(&["commit", "-q", "-am", "second\n\nwith a body"]);
|
||||||
|
let hash = run(&["rev-parse", "HEAD"]);
|
||||||
|
crate::test_ssh::fake_ssh();
|
||||||
|
let remote = Location::Ssh { target: "myserver".into(), path: dir.to_string_lossy().into_owned() };
|
||||||
|
let local = load(&Location::Local(dir.clone()), &hash).unwrap();
|
||||||
|
let through_ssh = load(&remote, &hash).unwrap();
|
||||||
|
assert_eq!(local.files, vec![file("M", "it's $a; b.txt", None, Some(1), Some(1))]);
|
||||||
|
assert_eq!(local, through_ssh);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-5
@@ -48,12 +48,39 @@ pub fn ssh_error(target: &str, stderr: &str) -> String {
|
|||||||
message
|
message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The folder for the sockets of shared connections: `runtime`, the user's runtime folder,
|
||||||
|
/// or else a `gites` folder inside `cache`, the user's cache folder, made if missing so that
|
||||||
|
/// only the user can open it. `None` when neither gives a folder; then every command opens
|
||||||
|
/// its own connection.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn socket_dir(runtime: Option<PathBuf>, cache: Option<PathBuf>) -> Option<PathBuf> {
|
||||||
|
if runtime.is_some() {
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
let dir = cache?.join("gites");
|
||||||
|
let mut builder = std::fs::DirBuilder::new();
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||||
|
match builder.create(&dir) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && dir.is_dir() => {}
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
// A folder left from before may let others in; only the user may reach the sockets.
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).ok()?;
|
||||||
|
}
|
||||||
|
Some(dir)
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs git in the folder `loc` names: here, or on another machine through ssh.
|
/// Runs git in the folder `loc` names: here, or on another machine through ssh.
|
||||||
pub fn git(loc: &Location, args: &[&str]) -> Result<String, String> {
|
pub fn git(loc: &Location, args: &[&str]) -> Result<String, String> {
|
||||||
// Tests swap in a script that acts like ssh.
|
// Tests swap in a script that acts like ssh.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
if let Some(fake) = crate::test_ssh::SSH_OVERRIDE.get() {
|
if let Some(fake) = crate::test_ssh::SSH_OVERRIDE.with_borrow(|fake| fake.clone()) {
|
||||||
return git_via(fake, loc, args);
|
return git_via(&fake, loc, args);
|
||||||
}
|
}
|
||||||
git_via(&[OsString::from("ssh")], loc, args)
|
git_via(&[OsString::from("ssh")], loc, args)
|
||||||
}
|
}
|
||||||
@@ -72,13 +99,15 @@ pub fn git_via(ssh: &[OsString], loc: &Location, args: &[&str]) -> Result<String
|
|||||||
cmd.args(&ssh[1..]);
|
cmd.args(&ssh[1..]);
|
||||||
// Never ask for a password: gites has no terminal to ask on.
|
// Never ask for a password: gites has no terminal to ask on.
|
||||||
cmd.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]);
|
cmd.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]);
|
||||||
|
// If the server stops answering, ssh gives up after about 10 seconds instead of
|
||||||
|
// waiting for ever, also on a connection that is shared.
|
||||||
|
cmd.args(["-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2"]);
|
||||||
// Later commands to the same server reuse one connection for a while. Windows ssh
|
// Later commands to the same server reuse one connection for a while. Windows ssh
|
||||||
// does not support this.
|
// does not support this.
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
if let Some(dir) = socket_dir(dirs::runtime_dir(), dirs::cache_dir()) {
|
||||||
let socket = dirs::runtime_dir().unwrap_or_else(|| PathBuf::from("/tmp")).join("gites-%C");
|
|
||||||
cmd.args(["-o", "ControlMaster=auto", "-o"]);
|
cmd.args(["-o", "ControlMaster=auto", "-o"]);
|
||||||
cmd.arg(format!("ControlPath={}", socket.display()));
|
cmd.arg(format!("ControlPath={}", dir.join("gites-%C").display()));
|
||||||
cmd.args(["-o", "ControlPersist=10m"]);
|
cmd.args(["-o", "ControlPersist=10m"]);
|
||||||
}
|
}
|
||||||
// `--` keeps a target from being read as an option.
|
// `--` keeps a target from being read as an option.
|
||||||
@@ -293,6 +322,83 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&base);
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explains_a_failed_connection_when_ssh_exits_with_255() {
|
||||||
|
let fake = ["sh", "-c", "echo 'Host key verification failed.' >&2; exit 255", "fake-ssh"].map(OsString::from);
|
||||||
|
let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() };
|
||||||
|
let err = git_via(&fake, &loc, &["status"]).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
"could not connect to myserver: Host key verification failed.\n\
|
||||||
|
Run `ssh myserver` once in a terminal to accept the server's key."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asks_ssh_to_notice_a_dead_connection() {
|
||||||
|
// This stand-in prints the words it was given, one per line, and fails.
|
||||||
|
let fake = ["sh", "-c", "printf '%s\\n' \"$@\" >&2; exit 1", "fake-ssh"].map(OsString::from);
|
||||||
|
let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() };
|
||||||
|
let err = git_via(&fake, &loc, &["status"]).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("-o\nConnectTimeout=10\n-o\nServerAliveInterval=5\n-o\nServerAliveCountMax=2\n"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opens_a_path_in_the_home_folder_through_ssh() {
|
||||||
|
let home = temp_dir("home");
|
||||||
|
let dir = home.join("repo");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
run_git(&dir, &["init", "-q", "-b", "main"]);
|
||||||
|
std::fs::write(dir.join("a.txt"), "one\n").unwrap();
|
||||||
|
run_git(&dir, &["add", "-A"]);
|
||||||
|
run_git(&dir, &["commit", "-q", "-m", "first"]);
|
||||||
|
crate::test_ssh::fake_ssh_with_home(&home);
|
||||||
|
let data = load(&Location::Ssh { target: "myserver".into(), path: "~/repo".into() }).unwrap();
|
||||||
|
assert_eq!(data.commits.len(), 1);
|
||||||
|
assert_eq!(data.root, format!("myserver:{}", dir.display()));
|
||||||
|
let _ = std::fs::remove_dir_all(&home);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn keeps_sockets_in_the_runtime_folder() {
|
||||||
|
let runtime = PathBuf::from("/run/user/1000");
|
||||||
|
assert_eq!(socket_dir(Some(runtime.clone()), Some(PathBuf::from("/c"))), Some(runtime));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn keeps_sockets_in_a_private_cache_folder_when_there_is_no_runtime_folder() {
|
||||||
|
let cache = temp_dir("cache");
|
||||||
|
let got = socket_dir(None, Some(cache.clone()));
|
||||||
|
assert_eq!(got, Some(cache.join("gites")));
|
||||||
|
assert!(cache.join("gites").is_dir());
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = std::fs::metadata(cache.join("gites")).unwrap().permissions().mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o700);
|
||||||
|
}
|
||||||
|
// A second call finds the folder already there.
|
||||||
|
assert_eq!(socket_dir(None, Some(cache.clone())), Some(cache.join("gites")));
|
||||||
|
let _ = std::fs::remove_dir_all(&cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
#[test]
|
||||||
|
fn shares_no_connection_when_there_is_no_folder_for_sockets() {
|
||||||
|
assert_eq!(socket_dir(None, None), None);
|
||||||
|
// The cache folder is a file here, so no folder can be made inside it.
|
||||||
|
let base = temp_dir("cache-file");
|
||||||
|
let file = base.join("file");
|
||||||
|
std::fs::write(&file, "").unwrap();
|
||||||
|
assert_eq!(socket_dir(None, Some(file)), None);
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reports_a_missing_ssh_program() {
|
fn reports_a_missing_ssh_program() {
|
||||||
let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() };
|
let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() };
|
||||||
|
|||||||
+11
-8
@@ -21,9 +21,12 @@ impl RepoDir {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The commands that run git are marked `async` so Tauri runs them away from the thread that
|
||||||
|
// draws the window: a command that waits for a slow server must not freeze the window.
|
||||||
|
|
||||||
/// The open repository, or `None` when there is none yet.
|
/// The open repository, or `None` when there is none yet.
|
||||||
#[tauri::command]
|
#[tauri::command(async)]
|
||||||
fn load_repo(dir: tauri::State<RepoDir>) -> Result<Option<git::RepoData>, String> {
|
fn load_repo(dir: tauri::State<'_, RepoDir>) -> Result<Option<git::RepoData>, String> {
|
||||||
match dir.get() {
|
match dir.get() {
|
||||||
Ok(d) => git::load(&d).map(Some),
|
Ok(d) => git::load(&d).map(Some),
|
||||||
Err(_) => Ok(None),
|
Err(_) => Ok(None),
|
||||||
@@ -31,8 +34,8 @@ fn load_repo(dir: tauri::State<RepoDir>) -> Result<Option<git::RepoData>, String
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Switches to another repository. The current one stays open if `path` is not a repository.
|
/// Switches to another repository. The current one stays open if `path` is not a repository.
|
||||||
#[tauri::command]
|
#[tauri::command(async)]
|
||||||
fn open_repo(dir: tauri::State<RepoDir>, path: String) -> Result<git::RepoData, String> {
|
fn open_repo(dir: tauri::State<'_, RepoDir>, path: String) -> Result<git::RepoData, String> {
|
||||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||||
let loc = location::parse(&path, &cwd);
|
let loc = location::parse(&path, &cwd);
|
||||||
let data = git::load(&loc)?;
|
let data = git::load(&loc)?;
|
||||||
@@ -40,14 +43,14 @@ fn open_repo(dir: tauri::State<RepoDir>, path: String) -> Result<git::RepoData,
|
|||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command(async)]
|
||||||
fn commit_details(dir: tauri::State<RepoDir>, hash: String) -> Result<details::CommitDetails, String> {
|
fn commit_details(dir: tauri::State<'_, RepoDir>, hash: String) -> Result<details::CommitDetails, String> {
|
||||||
details::load(&dir.get()?, &hash)
|
details::load(&dir.get()?, &hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command(async)]
|
||||||
fn file_diff(
|
fn file_diff(
|
||||||
dir: tauri::State<RepoDir>,
|
dir: tauri::State<'_, RepoDir>,
|
||||||
hash: String,
|
hash: String,
|
||||||
path: String,
|
path: String,
|
||||||
old_path: Option<String>,
|
old_path: Option<String>,
|
||||||
|
|||||||
@@ -46,11 +46,12 @@ pub fn quote(word: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The command for a shell that reads quotes the standard way: `git -C <path>` followed by
|
/// The command for a shell that reads quotes the standard way: `git -C <path>` followed by
|
||||||
/// `args`. A path starting with `~/` keeps the `~` outside the quotes so the shell still
|
/// `args`. A path that is `~` or starts with `~/` keeps the `~` outside the quotes so the
|
||||||
/// replaces it with the home folder.
|
/// shell still replaces it with the home folder.
|
||||||
pub fn sh_command(path: &str, args: &[&str]) -> String {
|
pub fn sh_command(path: &str, args: &[&str]) -> String {
|
||||||
let path = match path.strip_prefix("~/") {
|
let path = match path.strip_prefix("~/") {
|
||||||
Some(rest) => format!("~/{}", quote(rest)),
|
Some(rest) => format!("~/{}", quote(rest)),
|
||||||
|
None if path == "~" => "~".to_string(),
|
||||||
None => quote(path),
|
None => quote(path),
|
||||||
};
|
};
|
||||||
let mut command = format!("git -C {path}");
|
let mut command = format!("git -C {path}");
|
||||||
@@ -182,5 +183,8 @@ mod tests {
|
|||||||
"git -C '/srv/my app' 'log' '--format=%H'"
|
"git -C '/srv/my app' 'log' '--format=%H'"
|
||||||
);
|
);
|
||||||
assert_eq!(sh_command("~/x y", &["status"]), "git -C ~/'x y' 'status'");
|
assert_eq!(sh_command("~/x y", &["status"]), "git -C ~/'x y' 'status'");
|
||||||
|
assert_eq!(sh_command("~", &["status"]), "git -C ~ 'status'");
|
||||||
|
// Only the home folder of the user logged in is left for the shell; `~other` is not.
|
||||||
|
assert_eq!(sh_command("~other", &["status"]), "git -C '~other' 'status'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
//! A stand-in for ssh, so tests can run the remote code path without a server.
|
//! A stand-in for ssh, so tests can run the remote code path without a server.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::sync::OnceLock;
|
use std::path::Path;
|
||||||
|
|
||||||
/// The words `git::git` starts instead of `ssh` while tests run: a program, then its arguments.
|
thread_local! {
|
||||||
pub static SSH_OVERRIDE: OnceLock<Vec<OsString>> = OnceLock::new();
|
/// The words `git::git` starts instead of `ssh` in the test running on this thread: a
|
||||||
|
/// program, then its arguments. Each test runs on its own thread, so one test's stand-in
|
||||||
|
/// never reaches another test.
|
||||||
|
pub static SSH_OVERRIDE: RefCell<Option<Vec<OsString>>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
/// The shell script that acts like ssh. It checks the arguments the way a real ssh would
|
/// The shell script that acts like ssh. It checks the arguments the way a real ssh would
|
||||||
/// need them (so a wrong argument list fails a test), skips the options and the target,
|
/// need them (so a wrong argument list fails a test), skips the options and the target,
|
||||||
@@ -21,8 +26,17 @@ shift 2
|
|||||||
exec sh -c "$1"
|
exec sh -c "$1"
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
/// Makes `git::git` use the stand-in for the rest of the test run. No file is written:
|
/// Makes `git::git` use the stand-in for the rest of the calling test. No file is written:
|
||||||
/// the script is given to `sh` on its command line.
|
/// the script is given to `sh` on its command line.
|
||||||
pub fn fake_ssh() {
|
pub fn fake_ssh() {
|
||||||
SSH_OVERRIDE.get_or_init(|| ["sh", "-c", SCRIPT, "fake-ssh"].map(OsString::from).to_vec());
|
SSH_OVERRIDE.set(Some(["sh", "-c", SCRIPT, "fake-ssh"].map(OsString::from).to_vec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `fake_ssh`, but the command runs with `home` as its home folder, the way a real
|
||||||
|
/// server would run it in the user's home. Only the stand-in's own shell sees the new home.
|
||||||
|
pub fn fake_ssh_with_home(home: &Path) {
|
||||||
|
let script = format!("HOME=\"$1\"; export HOME; shift\n{SCRIPT}");
|
||||||
|
let mut words: Vec<OsString> = ["sh", "-c", &script, "fake-ssh"].map(OsString::from).to_vec();
|
||||||
|
words.push(home.as_os_str().to_owned());
|
||||||
|
SSH_OVERRIDE.set(Some(words));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user