Run git through ssh for repositories on another machine

This commit is contained in:
2026-10-15 15:09:17 +02:00
parent 121fe1bc2f
commit 7fe5a70c69
5 changed files with 199 additions and 40 deletions
+4 -4
View File
@@ -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<ChangedFile> {
files
}
pub fn load(dir: &Path, hash: &str) -> Result<CommitDetails, String> {
pub fn load(loc: &Location, hash: &str) -> Result<CommitDetails, String> {
// 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<CommitDetails, String> {
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")?);
+34 -15
View File
@@ -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<FileDiff, String> {
pub fn load(loc: &Location, hash: &str, path: &str, old_path: Option<&str>, force: bool) -> Result<FileDiff, String> {
// 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);
}
}
}
+125 -15
View File
@@ -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<String>,
}
pub fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
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<String, String> {
// 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<String, String> {
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<String, String> {
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<RepoData, String> {
let root = git(dir, &["rev-parse", "--show-toplevel"])?.trim().to_string();
let root_path = Path::new(&root);
pub fn load(loc: &Location) -> Result<RepoData, String> {
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<RepoData, String> {
/// 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<PathBuf>, cwd: PathBuf) -> Option<PathBuf> {
arg.or_else(|| git(&cwd, &["rev-parse", "--git-dir"]).ok().map(|_| cwd))
pub fn start_dir(arg: Option<String>, cwd: PathBuf) -> Option<Location> {
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}");
}
}
+11 -6
View File
@@ -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<Option<PathBuf>>);
struct RepoDir(Mutex<Option<Location>>);
impl RepoDir {
fn get(&self) -> Result<PathBuf, String> {
fn get(&self) -> Result<Location, String> {
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<RepoDir>) -> Result<Option<git::RepoData>, String
/// Switches to another repository. The current one stays open if `path` is not a repository.
#[tauri::command]
fn open_repo(dir: tauri::State<RepoDir>, path: String) -> Result<git::RepoData, String> {
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);
+25
View File
@@ -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<PathBuf> = 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()
}