c1123a5e
Run a sync's fetch and push over one SSH connection
a73x 2026-09-05 17:09
Commit message
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -18,6 +18,7 @@ pub mod patch; | |||
| 18 | pub mod refs; | 18 | pub mod refs; |
| 19 | pub mod release; | 19 | pub mod release; |
| 20 | pub mod signing; | 20 | pub mod signing; |
| 21 | pub mod ssh_share; | ||
| 21 | pub mod state; | 22 | pub mod state; |
| 22 | pub mod status; | 23 | pub mod status; |
| 23 | pub mod sync; | 24 | pub mod sync; |
src/release.rs
| Old | New | ||
|---|---|---|---|
| @@ -137,10 +137,10 @@ fn validate_remote_path(path: &str) -> Result<(), Error> { | |||
| 137 | /// The candidate ssh command strings, in the order they were read from their | 137 | /// The candidate ssh command strings, in the order they were read from their |
| 138 | /// respective sources. Named fields (rather than positional `Option<String>` | 138 | /// respective sources. Named fields (rather than positional `Option<String>` |
| 139 | /// args) so a call site can't silently pass them in the wrong order. | 139 | /// args) so a call site can't silently pass them in the wrong order. |
| 140 | struct SshCommandSources { | 140 | pub(crate) struct SshCommandSources { |
| 141 | collab_env: Option<String>, | 141 | pub(crate) collab_env: Option<String>, |
| 142 | git_env: Option<String>, | 142 | pub(crate) git_env: Option<String>, |
| 143 | config: Option<String>, | 143 | pub(crate) config: Option<String>, |
| 144 | } | 144 | } |
| 145 | 145 | ||
| 146 | /// Pick the ssh command string to use, first match wins: `collab_env` | 146 | /// Pick the ssh command string to use, first match wins: `collab_env` |
| @@ -150,7 +150,7 @@ struct SshCommandSources { | |||
| 150 | /// overridden when the GIT_SSH_COMMAND environment variable is set". A | 150 | /// overridden when the GIT_SSH_COMMAND environment variable is set". A |
| 151 | /// candidate that is empty or whitespace-only is treated as absent rather | 151 | /// candidate that is empty or whitespace-only is treated as absent rather |
| 152 | /// than winning with a blank program name. | 152 | /// than winning with a blank program name. |
| 153 | fn resolve_ssh_command(sources: SshCommandSources) -> String { | 153 | pub(crate) fn resolve_ssh_command(sources: SshCommandSources) -> String { |
| 154 | [sources.collab_env, sources.git_env, sources.config] | 154 | [sources.collab_env, sources.git_env, sources.config] |
| 155 | .into_iter() | 155 | .into_iter() |
| 156 | .flatten() | 156 | .flatten() |
src/ssh_share.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,122 @@ | |||
| 1 | //! One SSH connection for the whole of a sync. | ||
| 2 | //! | ||
| 3 | //! A sync runs `git fetch` and then `git push`, two separate processes that | ||
| 4 | //! each open their own SSH connection: two handshakes, two authentications, | ||
| 5 | //! two of whatever the network charges. Nothing between them needs the | ||
| 6 | //! remote, so the push can ride the connection the fetch already opened. | ||
| 7 | //! | ||
| 8 | //! OpenSSH's ControlMaster does exactly that. The first `ssh` becomes a | ||
| 9 | //! master that stays up after its client exits (`ControlPersist`), later | ||
| 10 | //! invocations with the same `ControlPath` multiplex over it, and `ssh -O | ||
| 11 | //! exit` tears it down. [`SharedSsh`] appends those options to whatever ssh | ||
| 12 | //! command git would run anyway, so a user's own `GIT_SSH_COMMAND` or | ||
| 13 | //! `core.sshCommand` (their key, their known-hosts policy) still applies. | ||
| 14 | |||
| 15 | use std::path::PathBuf; | ||
| 16 | use std::process::Command; | ||
| 17 | |||
| 18 | use git2::Repository; | ||
| 19 | |||
| 20 | use crate::release::{resolve_ssh_command, SshCommandSources}; | ||
| 21 | |||
| 22 | /// A control-socket directory whose masters are closed on drop. | ||
| 23 | pub struct SharedSsh { | ||
| 24 | dir: PathBuf, | ||
| 25 | ssh_command: String, | ||
| 26 | } | ||
| 27 | |||
| 28 | impl SharedSsh { | ||
| 29 | /// Start sharing for the commands `apply` is later called on. `None` when | ||
| 30 | /// sharing cannot safely be set up, in which case each command opens its | ||
| 31 | /// own connection as before: | ||
| 32 | /// | ||
| 33 | /// - git would not run OpenSSH at all (`GIT_SSH` names a program and | ||
| 34 | /// nothing overrides it, or the configured command is not `ssh`), since | ||
| 35 | /// the options below are OpenSSH's; | ||
| 36 | /// - the socket directory cannot be created. | ||
| 37 | /// | ||
| 38 | /// Non-Unix platforms never share: control sockets are a Unix feature. | ||
| 39 | pub fn start(repo: &Repository) -> Option<SharedSsh> { | ||
| 40 | if !cfg!(unix) { | ||
| 41 | return None; | ||
| 42 | } | ||
| 43 | let git_env = std::env::var("GIT_SSH_COMMAND").ok(); | ||
| 44 | if git_env.is_none() && std::env::var_os("GIT_SSH").is_some() { | ||
| 45 | return None; | ||
| 46 | } | ||
| 47 | let base = resolve_ssh_command(SshCommandSources { | ||
| 48 | collab_env: std::env::var("GIT_COLLAB_SSH_COMMAND").ok(), | ||
| 49 | git_env, | ||
| 50 | config: repo | ||
| 51 | .config() | ||
| 52 | .ok() | ||
| 53 | .and_then(|cfg| cfg.get_string("core.sshCommand").ok()), | ||
| 54 | }); | ||
| 55 | let program = base.split_whitespace().next()?; | ||
| 56 | if std::path::Path::new(program).file_name()? != "ssh" { | ||
| 57 | return None; | ||
| 58 | } | ||
| 59 | |||
| 60 | let dir = socket_dir(&std::env::temp_dir(), std::process::id())?; | ||
| 61 | std::fs::create_dir_all(&dir).ok()?; | ||
| 62 | // `%C` is a fixed-length hash of host, port and user, so the path | ||
| 63 | // stays inside the socket length limit whatever the remote is named. | ||
| 64 | let ssh_command = format!( | ||
| 65 | "{base} -o ControlMaster=auto -o ControlPath={} -o ControlPersist=60", | ||
| 66 | dir.join("%C").display() | ||
| 67 | ); | ||
| 68 | Some(SharedSsh { dir, ssh_command }) | ||
| 69 | } | ||
| 70 | |||
| 71 | /// Point a git command at the shared connection. | ||
| 72 | pub fn apply(&self, cmd: &mut Command) { | ||
| 73 | cmd.env("GIT_SSH_COMMAND", &self.ssh_command); | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | /// A Unix socket path is limited to about 104 bytes on the platforms that | ||
| 78 | /// have them. Anything longer makes ssh fail outright, which would turn a | ||
| 79 | /// long `TMPDIR` into a sync that cannot fetch at all. | ||
| 80 | const SOCKET_PATH_LIMIT: usize = 100; | ||
| 81 | |||
| 82 | /// Where the control sockets go, or `None` when a socket under it could not | ||
| 83 | /// be opened. `%C` expands to a 40-character hash. | ||
| 84 | fn socket_dir(tmp: &std::path::Path, pid: u32) -> Option<PathBuf> { | ||
| 85 | let dir = tmp.join(format!("git-collab-ssh-{pid}")); | ||
| 86 | let socket_len = dir.as_os_str().len() + "/".len() + 40; | ||
| 87 | (socket_len <= SOCKET_PATH_LIMIT).then_some(dir) | ||
| 88 | } | ||
| 89 | |||
| 90 | impl Drop for SharedSsh { | ||
| 91 | fn drop(&mut self) { | ||
| 92 | // `-O exit` needs only the socket; the host argument is never | ||
| 93 | // resolved, it merely satisfies ssh's argument parsing. | ||
| 94 | if let Ok(entries) = std::fs::read_dir(&self.dir) { | ||
| 95 | for entry in entries.flatten() { | ||
| 96 | let _ = Command::new("ssh") | ||
| 97 | .args(["-O", "exit", "-o"]) | ||
| 98 | .arg(format!("ControlPath={}", entry.path().display())) | ||
| 99 | .arg("git-collab-shared-connection") | ||
| 100 | .output(); | ||
| 101 | } | ||
| 102 | } | ||
| 103 | let _ = std::fs::remove_dir_all(&self.dir); | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | #[cfg(test)] | ||
| 108 | mod tests { | ||
| 109 | use super::*; | ||
| 110 | |||
| 111 | #[test] | ||
| 112 | fn a_socket_dir_that_would_overflow_the_path_limit_is_refused() { | ||
| 113 | let long = std::path::Path::new("/").join("x".repeat(120)); | ||
| 114 | assert!(socket_dir(&long, 1).is_none()); | ||
| 115 | } | ||
| 116 | |||
| 117 | #[test] | ||
| 118 | fn a_short_socket_dir_is_accepted() { | ||
| 119 | let dir = socket_dir(std::path::Path::new("/tmp"), 42).unwrap(); | ||
| 120 | assert_eq!(dir, std::path::Path::new("/tmp/git-collab-ssh-42")); | ||
| 121 | } | ||
| 122 | } | ||
src/sync.rs
| Old | New | ||
|---|---|---|---|
| @@ -9,6 +9,7 @@ use crate::dag; | |||
| 9 | use crate::error::Error; | 9 | use crate::error::Error; |
| 10 | use crate::identity::get_author; | 10 | use crate::identity::get_author; |
| 11 | use crate::signing; | 11 | use crate::signing; |
| 12 | use crate::ssh_share::SharedSsh; | ||
| 12 | use crate::state; | 13 | use crate::state; |
| 13 | use crate::sync_lock::SyncLock; | 14 | use crate::sync_lock::SyncLock; |
| 14 | use crate::trust; | 15 | use crate::trust; |
| @@ -160,7 +161,12 @@ impl SyncState { | |||
| 160 | /// | 161 | /// |
| 161 | /// On success, all refs are marked as Pushed. On failure, falls back to | 162 | /// On success, all refs are marked as Pushed. On failure, falls back to |
| 162 | /// per-ref pushes to determine which specific refs failed. | 163 | /// per-ref pushes to determine which specific refs failed. |
| 163 | fn push_refs_batched(workdir: &Path, remote_name: &str, refs: &[String]) -> Vec<RefPushResult> { | 164 | fn push_refs_batched( |
| 165 | workdir: &Path, | ||
| 166 | remote_name: &str, | ||
| 167 | refs: &[String], | ||
| 168 | ssh: Option<&SharedSsh>, | ||
| 169 | ) -> Vec<RefPushResult> { | ||
| 164 | if refs.is_empty() { | 170 | if refs.is_empty() { |
| 165 | return Vec::new(); | 171 | return Vec::new(); |
| 166 | } | 172 | } |
| @@ -169,11 +175,12 @@ fn push_refs_batched(workdir: &Path, remote_name: &str, refs: &[String]) -> Vec< | |||
| 169 | let mut args = vec!["push", remote_name]; | 175 | let mut args = vec!["push", remote_name]; |
| 170 | args.extend(refspecs.iter().map(|s| s.as_str())); | 176 | args.extend(refspecs.iter().map(|s| s.as_str())); |
| 171 | 177 | ||
| 172 | match Command::new("git") | 178 | let mut cmd = Command::new("git"); |
| 173 | .args(&args) | 179 | cmd.args(&args).current_dir(workdir); |
| 174 | .current_dir(workdir) | 180 | if let Some(ssh) = ssh { |
| 175 | .output() | 181 | ssh.apply(&mut cmd); |
| 176 | { | 182 | } |
| 183 | match cmd.output() { | ||
| 177 | Ok(output) if output.status.success() => { | 184 | Ok(output) if output.status.success() => { |
| 178 | // All refs pushed successfully | 185 | // All refs pushed successfully |
| 179 | refs.iter() | 186 | refs.iter() |
| @@ -187,20 +194,27 @@ fn push_refs_batched(workdir: &Path, remote_name: &str, refs: &[String]) -> Vec< | |||
| 187 | Ok(_) | Err(_) => { | 194 | Ok(_) | Err(_) => { |
| 188 | // Batch failed — retry each ref individually to isolate failures | 195 | // Batch failed — retry each ref individually to isolate failures |
| 189 | refs.iter() | 196 | refs.iter() |
| 190 | .map(|ref_name| push_ref_single(workdir, remote_name, ref_name)) | 197 | .map(|ref_name| push_ref_single(workdir, remote_name, ref_name, ssh)) |
| 191 | .collect() | 198 | .collect() |
| 192 | } | 199 | } |
| 193 | } | 200 | } |
| 194 | } | 201 | } |
| 195 | 202 | ||
| 196 | /// Push a single ref to the remote. Used as a fallback when batched push fails. | 203 | /// Push a single ref to the remote. Used as a fallback when batched push fails. |
| 197 | fn push_ref_single(workdir: &Path, remote_name: &str, ref_name: &str) -> RefPushResult { | 204 | fn push_ref_single( |
| 205 | workdir: &Path, | ||
| 206 | remote_name: &str, | ||
| 207 | ref_name: &str, | ||
| 208 | ssh: Option<&SharedSsh>, | ||
| 209 | ) -> RefPushResult { | ||
| 198 | let refspec = format!("{}:{}", ref_name, ref_name); | 210 | let refspec = format!("{}:{}", ref_name, ref_name); |
| 199 | match Command::new("git") | 211 | let mut cmd = Command::new("git"); |
| 200 | .args(["push", remote_name, &refspec]) | 212 | cmd.args(["push", remote_name, &refspec]) |
| 201 | .current_dir(workdir) | 213 | .current_dir(workdir); |
| 202 | .output() | 214 | if let Some(ssh) = ssh { |
| 203 | { | 215 | ssh.apply(&mut cmd); |
| 216 | } | ||
| 217 | match cmd.output() { | ||
| 204 | Ok(output) => { | 218 | Ok(output) => { |
| 205 | if output.status.success() { | 219 | if output.status.success() { |
| 206 | RefPushResult { | 220 | RefPushResult { |
| @@ -712,9 +726,13 @@ fn sync_remote( | |||
| 712 | 726 | ||
| 713 | let author = get_author(repo)?; | 727 | let author = get_author(repo)?; |
| 714 | 728 | ||
| 729 | // One SSH connection for the fetch and the push both; see `ssh_share`. | ||
| 730 | let shared_ssh = SharedSsh::start(repo); | ||
| 731 | |||
| 715 | // Step 1: Fetch collab refs using system git (handles SSH agent, credentials, etc.) | 732 | // Step 1: Fetch collab refs using system git (handles SSH agent, credentials, etc.) |
| 716 | outln!("Fetching from '{}'...", remote_name); | 733 | outln!("Fetching from '{}'...", remote_name); |
| 717 | let fetch_status = Command::new("git") | 734 | let mut fetch = Command::new("git"); |
| 735 | fetch | ||
| 718 | .args([ | 736 | .args([ |
| 719 | "fetch", | 737 | "fetch", |
| 720 | remote_name, | 738 | remote_name, |
| @@ -723,7 +741,11 @@ fn sync_remote( | |||
| 723 | "+refs/collab/archive/issues/*:refs/collab/sync/archive/issues/*", | 741 | "+refs/collab/archive/issues/*:refs/collab/sync/archive/issues/*", |
| 724 | "+refs/collab/archive/patches/*:refs/collab/sync/archive/patches/*", | 742 | "+refs/collab/archive/patches/*:refs/collab/sync/archive/patches/*", |
| 725 | ]) | 743 | ]) |
| 726 | .current_dir(&workdir) | 744 | .current_dir(&workdir); |
| 745 | if let Some(ssh) = &shared_ssh { | ||
| 746 | ssh.apply(&mut fetch); | ||
| 747 | } | ||
| 748 | let fetch_status = fetch | ||
| 727 | .status() | 749 | .status() |
| 728 | .map_err(|e| Error::Cmd(format!("failed to run git fetch: {}", e)))?; | 750 | .map_err(|e| Error::Cmd(format!("failed to run git fetch: {}", e)))?; |
| 729 | 751 | ||
| @@ -759,7 +781,7 @@ fn sync_remote( | |||
| 759 | if refs_to_push.is_empty() { | 781 | if refs_to_push.is_empty() { |
| 760 | outln!("Nothing to push."); | 782 | outln!("Nothing to push."); |
| 761 | } else { | 783 | } else { |
| 762 | let sync_result = push_refs(&workdir, remote_name, &refs_to_push); | 784 | let sync_result = push_refs(&workdir, remote_name, &refs_to_push, shared_ssh.as_ref()); |
| 763 | 785 | ||
| 764 | if !sync_result.is_complete() { | 786 | if !sync_result.is_complete() { |
| 765 | // Save state for resume | 787 | // Save state for resume |
| @@ -829,7 +851,7 @@ fn sync_resume( | |||
| 829 | 851 | ||
| 830 | // Push the pending refs | 852 | // Push the pending refs |
| 831 | let ref_names: Vec<String> = state.pending_refs.iter().map(|(r, _)| r.clone()).collect(); | 853 | let ref_names: Vec<String> = state.pending_refs.iter().map(|(r, _)| r.clone()).collect(); |
| 832 | let sync_result = push_refs(workdir, remote_name, &ref_names); | 854 | let sync_result = push_refs(workdir, remote_name, &ref_names, None); |
| 833 | 855 | ||
| 834 | if sync_result.is_complete() { | 856 | if sync_result.is_complete() { |
| 835 | // All pending refs pushed successfully | 857 | // All pending refs pushed successfully |
| @@ -876,8 +898,13 @@ fn sync_resume( | |||
| 876 | } | 898 | } |
| 877 | 899 | ||
| 878 | /// Push all refs in a single batch, printing per-ref status, and return aggregated results. | 900 | /// Push all refs in a single batch, printing per-ref status, and return aggregated results. |
| 879 | fn push_refs(workdir: &Path, remote_name: &str, refs: &[String]) -> SyncResult { | 901 | fn push_refs( |
| 880 | let results = push_refs_batched(workdir, remote_name, refs); | 902 | workdir: &Path, |
| 903 | remote_name: &str, | ||
| 904 | refs: &[String], | ||
| 905 | ssh: Option<&SharedSsh>, | ||
| 906 | ) -> SyncResult { | ||
| 907 | let results = push_refs_batched(workdir, remote_name, refs, ssh); | ||
| 881 | for result in &results { | 908 | for result in &results { |
| 882 | match &result.status { | 909 | match &result.status { |
| 883 | PushStatus::Pushed => outln!(" Pushed {}", result.ref_name), | 910 | PushStatus::Pushed => outln!(" Pushed {}", result.ref_name), |
tests/sync_ssh_connection_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,50 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use common::ServerHarness; | ||
| 4 | |||
| 5 | /// A sync fetches and then pushes. Run as two independent `git` invocations | ||
| 6 | /// those are two SSH connections, each paying a handshake, an auth round and | ||
| 7 | /// whatever the network charges. The push should ride the connection the | ||
| 8 | /// fetch already authenticated, so the server sees one login per sync. | ||
| 9 | #[test] | ||
| 10 | fn a_sync_authenticates_to_the_server_once() { | ||
| 11 | let harness = ServerHarness::new("one-login"); | ||
| 12 | let key = harness.ssh_client_key(); | ||
| 13 | harness.push_head(); | ||
| 14 | |||
| 15 | let repo = harness.work_repo(); | ||
| 16 | let url = harness.ssh_url_for(harness.repo_name()); | ||
| 17 | repo.git(&["remote", "add", "srv", &url]); | ||
| 18 | repo.git(&[ | ||
| 19 | "config", | ||
| 20 | "--add", | ||
| 21 | "remote.srv.fetch", | ||
| 22 | "+refs/collab/*:refs/collab/sync/srv/*", | ||
| 23 | ]); | ||
| 24 | repo.git(&["config", "collab.autoSync", "false"]); | ||
| 25 | repo.issue_open("Counted once"); | ||
| 26 | |||
| 27 | let ssh = format!( | ||
| 28 | "ssh -i {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ | ||
| 29 | -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=5", | ||
| 30 | key.display() | ||
| 31 | ); | ||
| 32 | let output = repo | ||
| 33 | .cli_command() | ||
| 34 | .args(["sync", "--remote", "srv"]) | ||
| 35 | .env("GIT_SSH_COMMAND", &ssh) | ||
| 36 | .output() | ||
| 37 | .expect("failed to run git-collab sync"); | ||
| 38 | assert!( | ||
| 39 | output.status.success(), | ||
| 40 | "sync failed:\n{}", | ||
| 41 | String::from_utf8_lossy(&output.stderr) | ||
| 42 | ); | ||
| 43 | |||
| 44 | let log = harness.server_log(); | ||
| 45 | let logins = log.matches("Public key auth accepted").count(); | ||
| 46 | assert_eq!( | ||
| 47 | logins, 1, | ||
| 48 | "one sync should log in once, saw {logins} logins:\n{log}" | ||
| 49 | ); | ||
| 50 | } | ||