diff --git a/src-tauri/src/details.rs b/src-tauri/src/details.rs index acb3862..06489d3 100644 --- a/src-tauri/src/details.rs +++ b/src-tauri/src/details.rs @@ -2,7 +2,7 @@ use crate::git::git; use serde::Serialize; -use std::path::Path; +use crate::location::Location; #[derive(Serialize, Debug, PartialEq)] pub struct ChangedFile { @@ -63,13 +63,13 @@ pub fn parse_files(name_status: &str, numstat: &str) -> Vec { files } -pub fn load(dir: &Path, hash: &str) -> Result { +pub fn load(loc: &Location, hash: &str) -> Result { // The hash comes from the window; refuse anything git could read as an option. if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) { return Err(format!("not a commit hash: {hash}")); } let info = git( - dir, + loc, &["show", "-s", "--format=%H%x1f%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%x1f%ct%x1f%P%x1f%B", hash, "--"], )?; let f: Vec<&str> = info.splitn(9, '\x1f').collect(); @@ -90,7 +90,7 @@ pub fn load(dir: &Path, hash: &str) -> Result { let with = |flag: &'static str| { let mut a = args.clone(); a.insert(1, flag); - git(dir, &a) + git(loc, &a) }; let files = parse_files(&with("--name-status")?, &with("--numstat")?); diff --git a/src-tauri/src/diff.rs b/src-tauri/src/diff.rs index 0d23126..8da9780 100644 --- a/src-tauri/src/diff.rs +++ b/src-tauri/src/diff.rs @@ -2,7 +2,7 @@ use crate::git::git; use serde::Serialize; -use std::path::Path; +use crate::location::Location; /// Above this many changed lines, or this many bytes of patch, the diff is only sent /// when the user asks for it again. @@ -71,12 +71,12 @@ pub fn parse_patch(patch: &str) -> FileDiff { diff } -pub fn load(dir: &Path, hash: &str, path: &str, old_path: Option<&str>, force: bool) -> Result { +pub fn load(loc: &Location, hash: &str, path: &str, old_path: Option<&str>, force: bool) -> Result { // The hash comes from the window; refuse anything git could read as an option. if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) { return Err(format!("not a commit hash: {hash}")); } - let parents = git(dir, &["rev-list", "--parents", "-n", "1", hash, "--"])?; + let parents = git(loc, &["rev-list", "--parents", "-n", "1", hash, "--"])?; // A merge is compared with its first parent, as in the preview panel's file list. let parent = parents.split_whitespace().nth(1).map(String::from); @@ -89,11 +89,11 @@ pub fn load(dir: &Path, hash: &str, path: &str, old_path: Option<&str>, force: b if let Some(old) = old_path { args.push(old); } - let patch = git(dir, &args)?; + let patch = git(loc, &args)?; let mut diff = parse_patch(&patch); if diff.binary { - let size = |spec: String| git(dir, &["cat-file", "-s", &spec]).ok().and_then(|s| s.trim().parse().ok()); + let size = |spec: String| git(loc, &["cat-file", "-s", &spec]).ok().and_then(|s| s.trim().parse().ok()); diff.old_size = parent.as_ref().and_then(|p| size(format!("{p}:{}", old_path.unwrap_or(path)))); diff.new_size = size(format!("{hash}:{path}")); } @@ -192,7 +192,7 @@ Binary files a/logo.png and b/logo.png differ #[test] fn refuses_a_hash_that_git_could_read_as_an_option() { - let err = load(Path::new("."), "--output=x", "a", None, false).unwrap_err(); + let err = load(&Location::Local(".".into()), "--output=x", "a", None, false).unwrap_err(); assert!(err.contains("not a commit hash"), "{err}"); } @@ -230,6 +230,9 @@ Binary files a/logo.png and b/logo.png differ assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); String::from_utf8_lossy(&out.stdout).trim().to_string() } + fn loc(&self) -> Location { + Location::Local(self.0.clone()) + } fn write(&self, path: &str, content: &[u8]) { std::fs::write(self.0.join(path), content).unwrap(); } @@ -249,7 +252,7 @@ Binary files a/logo.png and b/logo.png differ let r = Repo::new("first"); r.write("a.txt", b"one\ntwo\n"); let first = r.commit("first"); - let d = load(&r.0, &first, "a.txt", None, false).unwrap(); + let d = load(&r.loc(), &first, "a.txt", None, false).unwrap(); assert_eq!(texts(&d), vec!["+one", "+two"]); } @@ -262,14 +265,14 @@ Binary files a/logo.png and b/logo.png differ r.write("a.txt", body.replace("line 5\n", "line five\n").as_bytes()); let second = r.commit("second"); assert_eq!( - texts(&load(&r.0, &second, "a.txt", None, false).unwrap()), + texts(&load(&r.loc(), &second, "a.txt", None, false).unwrap()), vec![" line 2", " line 3", " line 4", "-line 5", "+line five", " line 6", " line 7", " line 8"] ); r.run(&["mv", "a.txt", "b.txt"]); r.write("b.txt", body.replace("line 5\n", "line five\n").replace("line 20\n", "line twenty\n").as_bytes()); let third = r.commit("third"); - let d = load(&r.0, &third, "b.txt", Some("a.txt"), false).unwrap(); + let d = load(&r.loc(), &third, "b.txt", Some("a.txt"), false).unwrap(); assert_eq!(&texts(&d)[3..5], ["-line 20", "+line twenty"]); } @@ -280,7 +283,7 @@ Binary files a/logo.png and b/logo.png differ r.commit("first"); r.run(&["mv", "a.txt", "b.txt"]); let second = r.commit("second"); - assert_eq!(load(&r.0, &second, "b.txt", Some("a.txt"), false).unwrap(), FileDiff::default()); + assert_eq!(load(&r.loc(), &second, "b.txt", Some("a.txt"), false).unwrap(), FileDiff::default()); } #[test] @@ -290,13 +293,13 @@ Binary files a/logo.png and b/logo.png differ r.commit("first"); r.write("x.bin", b"\0\x01\x02\x03\x04"); let second = r.commit("second"); - let d = load(&r.0, &second, "x.bin", None, false).unwrap(); + let d = load(&r.loc(), &second, "x.bin", None, false).unwrap(); assert!(d.binary); assert_eq!((d.old_size, d.new_size), (Some(3), Some(5))); std::fs::remove_file(r.0.join("x.bin")).unwrap(); let third = r.commit("third"); - let d = load(&r.0, &third, "x.bin", None, false).unwrap(); + let d = load(&r.loc(), &third, "x.bin", None, false).unwrap(); assert_eq!((d.old_size, d.new_size), (Some(5), None)); } @@ -313,7 +316,7 @@ Binary files a/logo.png and b/logo.png differ r.commit("main"); r.run(&["merge", "-q", "--no-edit", "side"]); let merge = r.run(&["rev-parse", "HEAD"]); - let d = load(&r.0, &merge, "side.txt", None, false).unwrap(); + let d = load(&r.loc(), &merge, "side.txt", None, false).unwrap(); assert_eq!(texts(&d), vec!["+from side"]); } @@ -323,12 +326,28 @@ Binary files a/logo.png and b/logo.png differ let body: String = (0..MAX_CHANGED_LINES + 1).map(|i| format!("{i}\n")).collect(); r.write("big.txt", body.as_bytes()); let first = r.commit("first"); - let d = load(&r.0, &first, "big.txt", None, false).unwrap(); + let d = load(&r.loc(), &first, "big.txt", None, false).unwrap(); assert_eq!(d.too_large, Some(MAX_CHANGED_LINES + 1)); assert!(d.hunks.is_empty()); - let d = load(&r.0, &first, "big.txt", None, true).unwrap(); + let d = load(&r.loc(), &first, "big.txt", None, true).unwrap(); assert_eq!(d.too_large, None); assert_eq!(texts(&d).len(), (MAX_CHANGED_LINES + 1) as usize); } + + #[test] + fn shows_the_same_diff_through_ssh() { + let r = Repo::new("ssh"); + r.write("it's $a; b.txt", b"one\ntwo\n"); + r.commit("first"); + r.write("it's $a; b.txt", b"one\nthree\n"); + let second = r.commit("second"); + crate::test_ssh::fake_ssh(); + let remote = Location::Ssh { target: "myserver".into(), path: r.0.to_string_lossy().into_owned() }; + let name = "it's $a; b.txt"; + let local = load(&r.loc(), &second, name, None, false).unwrap(); + let through_ssh = load(&remote, &second, name, None, false).unwrap(); + assert!(!local.hunks.is_empty()); + assert_eq!(local, through_ssh); + } } } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 07f2141..1df7b49 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1,5 +1,7 @@ use serde::Serialize; -use std::path::{Path, PathBuf}; +use crate::location::{Location, remote_command}; +use std::ffi::OsStr; +use std::path::PathBuf; use std::process::Command; #[derive(Serialize)] @@ -31,10 +33,58 @@ pub struct RepoData { head: Option, } -pub fn git(dir: &Path, args: &[&str]) -> Result { - let mut cmd = Command::new("git"); - cmd.arg("-C").arg(dir).args(args); - // gites has no console, so Windows would open a console window for every git it +/// The text to show when ssh could not reach the server. +pub fn ssh_error(target: &str, stderr: &str) -> String { + let stderr = stderr.trim(); + let mut message = format!("could not connect to {target}: {stderr}"); + if stderr.contains("Host key verification failed") { + message.push_str(&format!("\nRun `ssh {target}` once in a terminal to accept the server's key.")); + } + if stderr.contains("Permission denied") { + message.push_str(&format!( + "\ngites logs in with keys or your ssh agent only; check that `ssh {target}` works without a password." + )); + } + message +} + +/// Runs git in the folder `loc` names: here, or on another machine through ssh. +pub fn git(loc: &Location, args: &[&str]) -> Result { + // Tests swap in a script that acts like ssh. + #[cfg(test)] + if let Some(fake) = crate::test_ssh::SSH_OVERRIDE.get() { + return git_via(fake.as_os_str(), loc, args); + } + git_via(OsStr::new("ssh"), loc, args) +} + +/// Does the work of `git`, starting the program `ssh` for a remote location. +pub fn git_via(ssh: &OsStr, loc: &Location, args: &[&str]) -> Result { + let mut cmd = match loc { + Location::Local(dir) => { + let mut cmd = Command::new("git"); + cmd.arg("-C").arg(dir).args(args); + cmd + } + Location::Ssh { target, path } => { + let mut cmd = Command::new(ssh); + // Never ask for a password: gites has no terminal to ask on. + cmd.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]); + // Later commands to the same server reuse one connection for a while. Windows ssh + // does not support this. + #[cfg(not(windows))] + { + let socket = dirs::runtime_dir().unwrap_or_else(|| PathBuf::from("/tmp")).join("gites-%C"); + cmd.args(["-o", "ControlMaster=auto", "-o"]); + cmd.arg(format!("ControlPath={}", socket.display())); + cmd.args(["-o", "ControlPersist=10m"]); + } + // `--` keeps a target from being read as an option. + cmd.arg("--").arg(target).arg(remote_command(path, args)); + cmd + } + }; + // gites has no console, so Windows would open a console window for every program it // starts. Each one flashes on screen and takes the focus, and gites reloads on focus. #[cfg(windows)] { @@ -42,16 +92,25 @@ pub fn git(dir: &Path, args: &[&str]) -> Result { const CREATE_NO_WINDOW: u32 = 0x0800_0000; cmd.creation_flags(CREATE_NO_WINDOW); } - let out = cmd.output().map_err(|e| format!("could not run git: {e}"))?; + let program = if matches!(loc, Location::Local(_)) { "git" } else { "ssh" }; + let out = cmd.output().map_err(|e| format!("could not run {program}: {e}"))?; if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + let stderr = String::from_utf8_lossy(&out.stderr); + if let Location::Ssh { target, .. } = loc + && out.status.code() == Some(255) + { + return Err(ssh_error(target, &stderr)); + } + return Err(stderr.trim().to_string()); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } -pub fn load(dir: &Path) -> Result { - let root = git(dir, &["rev-parse", "--show-toplevel"])?.trim().to_string(); - let root_path = Path::new(&root); +pub fn load(loc: &Location) -> Result { + let top = git(loc, &["rev-parse", "--show-toplevel"])?.trim().to_string(); + let root_loc = loc.with_path(&top); + let root_path = &root_loc; + let root = root_loc.name(); let head = git(root_path, &["rev-parse", "--verify", "-q", "HEAD"]) .ok() @@ -135,8 +194,12 @@ pub fn load(dir: &Path) -> Result { /// current folder if it is inside a repository. `None` means gites starts with its /// welcome screen. A path given on the command line is used even if it is not a /// repository, so the window can say what is wrong with it. -pub fn start_dir(arg: Option, cwd: PathBuf) -> Option { - arg.or_else(|| git(&cwd, &["rev-parse", "--git-dir"]).ok().map(|_| cwd)) +pub fn start_dir(arg: Option, cwd: PathBuf) -> Option { + if let Some(arg) = arg { + return Some(crate::location::parse(&arg, &cwd)); + } + let here = Location::Local(cwd); + git(&here, &["rev-parse", "--git-dir"]).ok().map(|_| here) } #[cfg(test)] @@ -153,15 +216,15 @@ mod tests { #[test] fn opens_the_path_given_on_the_command_line_even_if_it_is_not_a_repository() { let given = PathBuf::from("/no/such/folder"); - assert_eq!(start_dir(Some(given.clone()), temp_dir("arg")), Some(given)); + assert_eq!(start_dir(Some("/no/such/folder".into()), temp_dir("arg")), Some(Location::Local(given))); } #[test] fn opens_the_current_folder_when_it_is_inside_a_repository() { let dir = temp_dir("repo"); - git(&dir, &["init", "-q"]).unwrap(); + git(&Location::Local(dir.clone()), &["init", "-q"]).unwrap(); std::fs::create_dir_all(dir.join("sub")).unwrap(); - assert_eq!(start_dir(None, dir.join("sub")), Some(dir.join("sub"))); + assert_eq!(start_dir(None, dir.join("sub")), Some(Location::Local(dir.join("sub")))); let _ = std::fs::remove_dir_all(&dir); } @@ -171,4 +234,51 @@ mod tests { assert_eq!(start_dir(None, dir.clone()), None); let _ = std::fs::remove_dir_all(&dir); } + + fn run_git(dir: &std::path::Path, 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)); + } + + #[test] + fn explains_connection_errors() { + let e = ssh_error("myserver", "Host key verification failed.\n"); + assert!(e.contains("could not connect to myserver: Host key verification failed."), "{e}"); + assert!(e.contains("Run `ssh myserver` once in a terminal"), "{e}"); + let e = ssh_error("myserver", "ada@myserver: Permission denied (publickey).\n"); + assert!(e.contains("gites logs in with keys or your ssh agent only"), "{e}"); + assert!(e.contains("check that `ssh myserver` works without a password."), "{e}"); + let e = ssh_error("myserver", "ssh: connect to host myserver port 22: Connection timed out"); + assert_eq!(e, "could not connect to myserver: ssh: connect to host myserver port 22: Connection timed out"); + } + + #[test] + fn loads_a_repository_through_ssh() { + let base = temp_dir("remote"); + let dir = base.join("remote 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(); + let path = dir.to_string_lossy().into_owned(); + let data = load(&Location::Ssh { target: "myserver".into(), path: path.clone() }).unwrap(); + assert_eq!(data.commits.len(), 1); + assert_eq!(data.root, format!("myserver:{path}")); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn reports_a_missing_ssh_program() { + let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() }; + let err = git_via(OsStr::new("/no/such/ssh"), &loc, &["status"]).unwrap_err(); + assert!(err.starts_with("could not run ssh:"), "{err}"); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 01cfb33..cabac9f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,16 +4,19 @@ mod filters; mod git; mod location; mod settings; +#[cfg(test)] +mod test_ssh; +use location::Location; use std::path::PathBuf; use std::sync::Mutex; -/// The open repository folder, from `git::start_dir`; it changes when the user opens +/// The open repository (a folder here or on another machine), from `git::start_dir`; it changes when the user opens /// another one. `None` until a repository is open: the window shows its welcome screen. -struct RepoDir(Mutex>); +struct RepoDir(Mutex>); impl RepoDir { - fn get(&self) -> Result { + fn get(&self) -> Result { self.0.lock().unwrap_or_else(|e| e.into_inner()).clone().ok_or_else(|| "no repository is open".into()) } } @@ -30,8 +33,10 @@ fn load_repo(dir: tauri::State) -> Result, String /// Switches to another repository. The current one stays open if `path` is not a repository. #[tauri::command] fn open_repo(dir: tauri::State, path: String) -> Result { - let data = git::load(&PathBuf::from(&path))?; - *dir.0.lock().unwrap_or_else(|e| e.into_inner()) = Some(PathBuf::from(&path)); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let loc = location::parse(&path, &cwd); + let data = git::load(&loc)?; + *dir.0.lock().unwrap_or_else(|e| e.into_inner()) = Some(loc); Ok(data) } @@ -73,7 +78,7 @@ fn save_settings(value: serde_json::Value) -> Result<(), String> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let arg = std::env::args_os().nth(1).map(PathBuf::from); + let arg = std::env::args_os().nth(1).map(|a| a.to_string_lossy().into_owned()); let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let path = git::start_dir(arg, cwd); diff --git a/src-tauri/src/test_ssh.rs b/src-tauri/src/test_ssh.rs new file mode 100644 index 0000000..5b433a7 --- /dev/null +++ b/src-tauri/src/test_ssh.rs @@ -0,0 +1,25 @@ +//! A stand-in for ssh, so tests can run the remote code path without a server. + +use std::path::PathBuf; +use std::sync::OnceLock; + +/// The program `git::git` starts instead of `ssh` while tests run. +pub static SSH_OVERRIDE: OnceLock = OnceLock::new(); + +/// Writes (once per test run) a script that acts like ssh: it skips the `-o` options, +/// the `--` and the target, then runs the command on this machine. Returns its path. +pub fn fake_ssh() -> PathBuf { + SSH_OVERRIDE + .get_or_init(|| { + let path = std::env::temp_dir().join(format!("gites-fake-ssh-{}", std::process::id())); + let script = "#!/bin/sh\nwhile [ \"$1\" = \"-o\" ]; do shift 2; done\nshift 2\nexec sh -c \"$1\"\n"; + std::fs::write(&path, script).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + path + }) + .clone() +}