diff --git a/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md b/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md index e3525f7..594f077 100644 --- a/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md +++ b/docs/superpowers/specs/2026-10-15-remote-repositories-over-ssh-design.md @@ -69,7 +69,12 @@ A path on the server that is `~` or starts with `~/` is handed to the server's s `gites` folder in the user's cache folder, made so that only the user can open it; never in `/tmp`. If neither folder can be found or made, these three options are left out and every command opens a new connection. `%C` is a hash ssh makes from - the host, port and user, so the path stays short. + the host, port and user, 40 characters long. macOS accepts a socket path of at most + 103 characters (Linux 107), and while ssh sets up a shared connection it adds a + temporary end of up to 18 characters to the file name. So gites counts the folder's + length + 1 + 6 (`gites-`) + 40 + 18; if that is more than 103 (one limit on every + system), these three options are left out too. The folder is found, made and checked + once when gites first runs ssh, not for every command. - On Windows, ssh cannot share connections, so these three options are left out and every command opens a new connection. - Quoting: every word of the git command is put in single quotes, and a `'` inside a diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 6d6813d..8c033f0 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -75,6 +75,33 @@ fn socket_dir(runtime: Option, cache: Option) -> Option bool { + const HASH: usize = 40; + const TEMPORARY_END: usize = 18; + // The 1 is the `/` between the folder and the file name. + dir.as_os_str().len() + 1 + SOCKET_PREFIX.len() + HASH + TEMPORARY_END <= MAX_SOCKET_PATH +} + +/// The path ssh is given for the socket of shared connections, or `None` to share none: when +/// there is no folder for sockets, or the path would be too long. +#[cfg(not(windows))] +fn control_path(dir: Option) -> Option { + dir.filter(|d| socket_path_fits(d)).map(|d| d.join(format!("{SOCKET_PREFIX}%C"))) +} + /// 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. @@ -104,11 +131,17 @@ pub fn git_via(ssh: &[OsString], loc: &Location, args: &[&str]) -> Result> = std::sync::OnceLock::new(); + let path = CONTROL_PATH + .get_or_init(|| control_path(socket_dir(dirs::runtime_dir(), dirs::cache_dir()))); + if let Some(path) = path { + cmd.args(["-o", "ControlMaster=auto", "-o"]); + cmd.arg(format!("ControlPath={}", path.display())); + cmd.args(["-o", "ControlPersist=10m"]); + } } // `--` keeps a target from being read as an option. cmd.arg("--").arg(target).arg(remote_command(path, args)); @@ -399,6 +432,36 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + #[cfg(not(windows))] + #[test] + fn shares_connections_through_a_socket_in_a_short_folder() { + assert!(socket_path_fits(&PathBuf::from("/run/user/1000"))); + assert_eq!( + control_path(Some(PathBuf::from("/run/user/1000"))), + Some(PathBuf::from("/run/user/1000/gites-%C")) + ); + } + + #[cfg(not(windows))] + #[test] + fn shares_no_connection_when_the_socket_path_would_be_too_long() { + // The longest folder that fits: the rest of the path takes 1 + 6 + 40 + 18 characters. + let longest = 103 - 1 - SOCKET_PREFIX.len() - 40 - 18; + let too_long = PathBuf::from(format!("/{}", "a".repeat(longest))); + assert!(!socket_path_fits(&too_long)); + assert_eq!(control_path(Some(too_long)), None); + assert_eq!(control_path(None), None); + } + + #[cfg(not(windows))] + #[test] + fn shares_connections_when_the_socket_path_is_exactly_at_the_limit() { + let longest = 103 - 1 - SOCKET_PREFIX.len() - 40 - 18; + let at_limit = PathBuf::from(format!("/{}", "a".repeat(longest - 1))); + assert_eq!(at_limit.as_os_str().len(), longest); + assert!(socket_path_fits(&at_limit)); + } + #[test] fn reports_a_missing_ssh_program() { let loc = Location::Ssh { target: "myserver".into(), path: "/x".into() };