Skip to main content

sley_remote/
ssh.rs

1//! Callable SSH transport plumbing and fetch/push/ls-remote orchestration.
2//!
3//! These drive the transport-agnostic protocol codecs ([`sley_protocol`]) over an
4//! `ssh` subprocess spawned via [`sley_transport::ssh_process_command`]. Like the
5//! HTTP and local paths, everything is taken as explicit parameters — the
6//! already-resolved [`RemoteUrl`], the [`ObjectFormat`], `git_dir`, the repository
7//! [`GitConfig`], and the seam objects ([`CredentialProvider`], [`ProgressSink`]) —
8//! so they never read process-global state, parse arguments, or print. SSH does not
9//! authenticate at this layer (it delegates to the `ssh` program), so the
10//! credential seam is accepted for uniformity but unused.
11//!
12//! The `ssh` program is chosen by [`ssh_program`], which reads the `GIT_SSH`
13//! environment variable (git's standard mechanism) and falls back to `ssh`; this is
14//! the one piece of ambient state the SSH transport inherently depends on.
15//!
16//! SSH mirrors the HTTP path ([`crate::http`]): two ref advertisements are read
17//! from the spawned process's stdout (the second, re-advertised set in the RPC
18//! stream is skipped), then the upload-pack/receive-pack request is written to its
19//! stdin and the packfile/report read back from stdout. The ref-map / `FETCH_HEAD`
20//! / prune helpers and the push-planning helpers are shared with the other
21//! transports via [`crate::fetch`] and [`crate::push`].
22
23use std::collections::HashMap;
24use std::env;
25use std::io::Read;
26use std::path::Path;
27use std::process::{Child, ChildStdin, ChildStdout, Command as ProcessCommand, Stdio};
28
29use sley_core::{Capability, GitError, ObjectFormat, ObjectId, Result};
30use sley_fetch::{install_upload_pack_raw_promisor_response, install_upload_pack_raw_response};
31use sley_odb::{FileObjectDatabase, build_reachable_pack, collect_reachable_object_ids};
32use sley_protocol::{
33    GitService, ProtocolV2FetchShallowInfo, ReceivePackCommand, ReceivePackFeatures,
34    ReceivePackPushRequestOptions, RefAdvertisement, UploadPackFeatures,
35    UploadPackNegotiationRequest, UploadPackRawPackfileResponse, UploadPackRequest,
36    build_receive_pack_push_request, parse_receive_pack_features, parse_refspec,
37    parse_upload_pack_features, plan_push_commands, read_receive_pack_report_status,
38    read_ref_advertisement_set, read_upload_pack_raw_packfile_response,
39    read_upload_pack_shallow_info_and_raw_packfile_response, write_receive_pack_push_request,
40    write_upload_pack_negotiation_request, write_upload_pack_request,
41};
42use sley_refs::FileRefStore;
43use sley_transport::{RemoteTransport, RemoteUrl, SshCommandVariant, ssh_process_command};
44
45use crate::{PushOutcome, PushRequest};
46
47/// The `ssh` program to spawn for SSH transport: the `GIT_SSH` environment
48/// variable when set, otherwise `ssh`. This mirrors git's basic `GIT_SSH`
49/// selection (the richer `GIT_SSH_COMMAND`/`core.sshCommand` forms are not
50/// handled, matching the CLI behavior being lifted).
51pub fn ssh_program() -> String {
52    env::var("GIT_SSH").unwrap_or_else(|_| "ssh".into())
53}
54
55/// Push to a resolved SSH `remote` from the repository at `git_dir`.
56///
57/// Performs the work the CLI's `push_ssh_repository` did, sharing the
58/// push-planning helpers with the HTTP and local transports: advertises the
59/// remote's refs over `ssh`, plans the receive-pack commands for `refspecs`,
60/// rejects non-fast-forward updates (unless forced), builds the pack of objects
61/// the remote lacks, sends the receive-pack request, and validates the
62/// report-status. `credentials` is accepted for seam uniformity but unused. The
63/// "To <remote>" summary and set-upstream config stay with the caller, driven from
64/// [`PushOutcome::commands`].
65pub(crate) struct SshPushRequest<'a> {
66    pub git_dir: &'a Path,
67    pub common_git_dir: &'a Path,
68    pub format: ObjectFormat,
69    pub remote: &'a RemoteUrl,
70    pub refspecs: &'a [String],
71    pub force: bool,
72}
73
74pub(crate) struct SshPushCommandsRequest<'a> {
75    pub common_git_dir: &'a Path,
76    pub format: ObjectFormat,
77    pub remote: &'a RemoteUrl,
78    pub command_forces: Vec<(ReceivePackCommand, bool)>,
79    pub pack_objects: Vec<ObjectId>,
80}
81
82pub(crate) struct SshPushPlan {
83    pub(crate) commands: Vec<ReceivePackCommand>,
84    pub(crate) pack_objects: Vec<ObjectId>,
85    child: Child,
86    stdin: Option<ChildStdin>,
87    stdout: ChildStdout,
88    features: ReceivePackFeatures,
89    advertisements: Vec<RefAdvertisement>,
90    remote: RemoteUrl,
91}
92
93pub(crate) fn plan_push_ssh(request: SshPushRequest<'_>) -> Result<SshPushPlan> {
94    let SshPushRequest {
95        git_dir,
96        common_git_dir,
97        format,
98        remote,
99        refspecs,
100        force,
101    } = request;
102    if remote.transport != RemoteTransport::Ssh {
103        return Err(GitError::InvalidFormat(
104            "SSH receive-pack requires an SSH remote".into(),
105        ));
106    }
107    let ssh = ssh_process_command(
108        remote,
109        GitService::ReceivePack,
110        ssh_program(),
111        SshCommandVariant::OpenSsh,
112    )?;
113    let mut child = ProcessCommand::new(&ssh.program)
114        .args(&ssh.args)
115        .stdin(Stdio::piped())
116        .stdout(Stdio::piped())
117        .stderr(Stdio::piped())
118        .spawn()?;
119    let mut stdout = child
120        .stdout
121        .take()
122        .ok_or_else(|| GitError::Command("ssh receive-pack stdout was not piped".into()))?;
123    let stdin = child
124        .stdin
125        .take()
126        .ok_or_else(|| GitError::Command("ssh receive-pack stdin was not piped".into()))?;
127
128    let advertisement_set = read_ref_advertisement_set(format, &mut stdout)?;
129    let features = advertisement_set
130        .refs
131        .first()
132        .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
133        .transpose()?
134        .unwrap_or_default();
135    if let Some(remote_format) = features.object_format {
136        if remote_format != format {
137            return Err(GitError::InvalidObjectId(format!(
138                "remote repository uses {}, local repository uses {}",
139                remote_format.name(),
140                format.name()
141            )));
142        }
143    } else if format != ObjectFormat::Sha1 {
144        return Err(GitError::InvalidObjectId(format!(
145            "remote repository did not advertise object-format for {} push",
146            format.name()
147        )));
148    }
149
150    let local_store = FileRefStore::new(git_dir, format);
151    let local_refs = crate::push::local_push_source_refs(&local_store, format)?;
152    let parsed_refspecs = refspecs
153        .iter()
154        .map(|refspec| parse_refspec(&crate::push::normalize_push_refspec(refspec)))
155        .collect::<Result<Vec<_>>>()?;
156    let mut command_forces = Vec::new();
157    for refspec in &parsed_refspecs {
158        for command in plan_push_commands(
159            format,
160            &local_refs,
161            &advertisement_set.refs,
162            std::slice::from_ref(refspec),
163        )? {
164            command_forces.push((command, force || refspec.force));
165        }
166    }
167    let commands = command_forces
168        .iter()
169        .map(|(command, _)| command.clone())
170        .collect::<Vec<_>>();
171    if commands.is_empty() {
172        drop(stdin);
173        return Ok(SshPushPlan {
174            commands,
175            pack_objects: Vec::new(),
176            child,
177            stdin: None,
178            stdout,
179            features,
180            advertisements: advertisement_set.refs,
181            remote: remote.clone(),
182        });
183    }
184
185    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
186    crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
187    Ok(SshPushPlan {
188        commands,
189        pack_objects: Vec::new(),
190        child,
191        stdin: Some(stdin),
192        stdout,
193        features,
194        advertisements: advertisement_set.refs,
195        remote: remote.clone(),
196    })
197}
198
199pub(crate) fn plan_push_ssh_commands(request: SshPushCommandsRequest<'_>) -> Result<SshPushPlan> {
200    let SshPushCommandsRequest {
201        common_git_dir,
202        format,
203        remote,
204        command_forces,
205        pack_objects,
206    } = request;
207    if remote.transport != RemoteTransport::Ssh {
208        return Err(GitError::InvalidFormat(
209            "SSH receive-pack requires an SSH remote".into(),
210        ));
211    }
212    let ssh = ssh_process_command(
213        remote,
214        GitService::ReceivePack,
215        ssh_program(),
216        SshCommandVariant::OpenSsh,
217    )?;
218    let mut child = ProcessCommand::new(&ssh.program)
219        .args(&ssh.args)
220        .stdin(Stdio::piped())
221        .stdout(Stdio::piped())
222        .stderr(Stdio::piped())
223        .spawn()?;
224    let mut stdout = child
225        .stdout
226        .take()
227        .ok_or_else(|| GitError::Command("ssh receive-pack stdout was not piped".into()))?;
228    let stdin = child
229        .stdin
230        .take()
231        .ok_or_else(|| GitError::Command("ssh receive-pack stdin was not piped".into()))?;
232
233    let advertisement_set = read_ref_advertisement_set(format, &mut stdout)?;
234    let features = advertisement_set
235        .refs
236        .first()
237        .map(|advertisement| parse_receive_pack_features(&advertisement.capabilities))
238        .transpose()?
239        .unwrap_or_default();
240    if let Some(remote_format) = features.object_format {
241        if remote_format != format {
242            return Err(GitError::InvalidObjectId(format!(
243                "remote repository uses {}, local repository uses {}",
244                remote_format.name(),
245                format.name()
246            )));
247        }
248    } else if format != ObjectFormat::Sha1 {
249        return Err(GitError::InvalidObjectId(format!(
250            "remote repository did not advertise object-format for {} push",
251            format.name()
252        )));
253    }
254
255    let commands = command_forces
256        .iter()
257        .map(|(command, _)| command.clone())
258        .collect::<Vec<_>>();
259
260    if commands.is_empty() {
261        drop(stdin);
262        return Ok(SshPushPlan {
263            commands,
264            pack_objects,
265            child,
266            stdin: None,
267            stdout,
268            features,
269            advertisements: advertisement_set.refs,
270            remote: remote.clone(),
271        });
272    }
273
274    let local_db = FileObjectDatabase::from_git_dir(common_git_dir, format);
275    crate::push::reject_non_fast_forward_pushes(&local_db, format, &command_forces)?;
276    Ok(SshPushPlan {
277        commands,
278        pack_objects,
279        child,
280        stdin: Some(stdin),
281        stdout,
282        features,
283        advertisements: advertisement_set.refs,
284        remote: remote.clone(),
285    })
286}
287
288pub(crate) fn execute_push_ssh_plan(
289    request: PushRequest<'_>,
290    mut plan: SshPushPlan,
291) -> Result<PushOutcome> {
292    if plan.commands.is_empty() {
293        return Ok(PushOutcome::default());
294    }
295    let mut stdin = plan
296        .stdin
297        .take()
298        .ok_or_else(|| GitError::Command("ssh receive-pack stdin was not available".into()))?;
299    let commands = plan.commands.clone();
300    let local_db = FileObjectDatabase::from_git_dir(request.common_git_dir, request.format);
301    let remote_excluded_tips =
302        crate::remote_advertisement_tips_known_to_local(&local_db, &plan.advertisements)?;
303    let remote_excluded =
304        collect_reachable_object_ids(&local_db, request.format, remote_excluded_tips)?;
305    let starts = crate::pack::push_pack_roots(&commands, &plan.pack_objects);
306    let packfile = build_reachable_pack(&local_db, request.format, starts, &remote_excluded)?
307        .map(|pack| pack.pack)
308        .unwrap_or_default();
309    let request = build_receive_pack_push_request(
310        &plan.features,
311        commands.clone(),
312        packfile,
313        ReceivePackPushRequestOptions {
314            report_status: plan.features.report_status,
315            ofs_delta: plan.features.ofs_delta,
316            quiet: request.options.quiet && plan.features.quiet,
317            object_format: plan
318                .features
319                .object_format
320                .filter(|_| request.format != ObjectFormat::Sha1),
321            ..ReceivePackPushRequestOptions::default()
322        },
323    )?;
324    write_receive_pack_push_request(&mut stdin, &request)?;
325    drop(stdin);
326
327    let report = if plan.features.report_status {
328        let report = read_receive_pack_report_status(&mut plan.stdout)?;
329        crate::push::validate_receive_pack_report(&report)?;
330        Some(report)
331    } else {
332        let mut sink = Vec::new();
333        plan.stdout.read_to_end(&mut sink)?;
334        None
335    };
336    let output = plan.child.wait_with_output()?;
337    if !output.status.success() {
338        return Err(GitError::Command(format!(
339            "ssh receive-pack failed for {}: {}",
340            ssh_remote_display(&plan.remote),
341            String::from_utf8_lossy(&output.stderr).trim()
342        )));
343    }
344
345    Ok(PushOutcome { commands, report })
346}
347
348/// List the advertised refs for a resolved SSH `remote`, mirroring the records the
349/// HTTP/local paths return ([`crate::ls_remote::LsRemoteRecord`]): advertise over
350/// `ssh`, then apply the `--heads`/`--tags`/`--refs` class filters and the
351/// caller-supplied `matches` predicate. Returns the records and the object format
352/// in effect (currently SHA-1 only).
353pub(crate) fn ls_remote_ssh(
354    remote: &RemoteUrl,
355    filter: &crate::ls_remote::LsRemoteFilter,
356    matches: &dyn Fn(&str) -> bool,
357) -> Result<(Vec<crate::ls_remote::LsRemoteRecord>, ObjectFormat)> {
358    if remote.transport != RemoteTransport::Ssh {
359        return Err(GitError::InvalidFormat(
360            "SSH upload-pack requires an SSH remote".into(),
361        ));
362    }
363    let ssh = ssh_process_command(
364        remote,
365        GitService::UploadPack,
366        ssh_program(),
367        SshCommandVariant::OpenSsh,
368    )?;
369    let output = ProcessCommand::new(&ssh.program)
370        .args(&ssh.args)
371        .stdin(Stdio::null())
372        .output()?;
373    let mut stdout = output.stdout.as_slice();
374    let set = match read_ref_advertisement_set(ObjectFormat::Sha1, &mut stdout) {
375        Ok(set) => set,
376        Err(_) if !output.status.success() => {
377            return Err(GitError::Command(format!(
378                "ssh upload-pack failed for {}: {}",
379                ssh_remote_display(remote),
380                String::from_utf8_lossy(&output.stderr).trim()
381            )));
382        }
383        Err(err) => return Err(err),
384    };
385    let features = set
386        .refs
387        .first()
388        .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
389        .transpose()?
390        .unwrap_or_default();
391    let format = features.object_format.unwrap_or(ObjectFormat::Sha1);
392    if format != ObjectFormat::Sha1 {
393        return Err(GitError::Unsupported(format!(
394            "ssh ls-remote currently supports SHA-1 advertisements, got {}",
395            format.name()
396        )));
397    }
398    let symrefs = features
399        .symrefs
400        .iter()
401        .filter_map(|symref| symref.split_once(':'))
402        .map(|(name, target)| (name.to_string(), target.to_string()))
403        .collect::<HashMap<_, _>>();
404    let mut records = Vec::new();
405    for advertisement in set.refs {
406        if advertisement.oid.is_null() {
407            continue;
408        }
409        if filter.refs_only && (advertisement.name == "HEAD" || advertisement.name.ends_with("^{}"))
410        {
411            continue;
412        }
413        let is_head = advertisement.name.starts_with("refs/heads/");
414        let is_tag = advertisement.name.starts_with("refs/tags/");
415        if (filter.heads || filter.tags) && !((filter.heads && is_head) || (filter.tags && is_tag))
416        {
417            continue;
418        }
419        if !matches(&advertisement.name) {
420            continue;
421        }
422        records.push(crate::ls_remote::LsRemoteRecord {
423            oid: advertisement.oid,
424            symref: symrefs.get(&advertisement.name).cloned(),
425            name: advertisement.name,
426        });
427    }
428    Ok((records, format))
429}
430
431/// Fetch `wants` from an SSH upload-pack remote into the repository at `git_dir`,
432/// installing the resulting pack. Objects already present locally are skipped (for
433/// non-shallow fetches); `promisor` selects promisor-pack installation.
434///
435/// When `deepen` is set the fetch is shallow: the request replays `shallow` (the
436/// client's current boundary from `$GIT_DIR/shallow`) and asks the server to
437/// truncate history to `deepen` commits. The returned [`ProtocolV2FetchShallowInfo`]
438/// entries are the server's shallow-info updates the caller must fold into
439/// `$GIT_DIR/shallow` (see [`crate::apply_shallow_info`]); they are empty for a
440/// non-deepen fetch.
441pub struct SshFetchPackRequest<'a> {
442    /// Local repository `$GIT_DIR`.
443    pub git_dir: &'a Path,
444    /// Local repository object format.
445    pub format: ObjectFormat,
446    /// Resolved SSH remote.
447    pub remote: &'a RemoteUrl,
448    /// Upload-pack features advertised by the remote.
449    pub features: &'a UploadPackFeatures,
450    /// Wanted object ids.
451    pub wants: Vec<ObjectId>,
452    /// Existing shallow boundary to replay.
453    pub shallow: Vec<ObjectId>,
454    /// Requested deepen depth, if this is a shallow fetch.
455    pub deepen: Option<u32>,
456    /// Whether to install the response as a promisor pack.
457    pub promisor: bool,
458}
459
460pub fn install_fetch_pack_via_ssh_upload_pack(
461    request: SshFetchPackRequest<'_>,
462) -> Result<Vec<ProtocolV2FetchShallowInfo>> {
463    if request.wants.is_empty() {
464        return Ok(Vec::new());
465    }
466    let local_db = FileObjectDatabase::from_git_dir(request.git_dir, request.format);
467    // A deepen request must always reach the server (the shallow boundary may move
468    // even when every wanted object is already present), so only the plain fetch
469    // takes the "everything is local already" shortcut.
470    if request.deepen.is_none() && all_wants_present(&local_db, &request.wants)? {
471        return Ok(Vec::new());
472    }
473    let upload_request = UploadPackRequest {
474        wants: request.wants,
475        capabilities: ssh_shallow_request_capabilities(request.deepen),
476        shallow: request.shallow,
477        deepen: request.deepen,
478        ..UploadPackRequest::default()
479    };
480    let haves = crate::local::local_have_oids(request.git_dir, request.format)?;
481    // Only a deepen request gets a leading shallow-info section in the response;
482    // a plain fetch must use the non-shallow reader (the response starts straight
483    // at the NAK/ACK), preserving the existing SSH wire handling exactly.
484    let (shallow_info, response) = if request.deepen.is_some() {
485        ssh_upload_pack_shallow_fetch_response(
486            request.remote,
487            request.format,
488            request.features,
489            upload_request,
490            haves,
491        )?
492    } else {
493        let response = ssh_upload_pack_fetch_response(
494            request.remote,
495            request.format,
496            request.features,
497            upload_request,
498            haves,
499        )?;
500        (Vec::new(), response)
501    };
502    if request.promisor {
503        install_upload_pack_raw_promisor_response(&response, &local_db)?;
504    } else {
505        install_upload_pack_raw_response(&response, &local_db)?;
506    }
507    Ok(shallow_info)
508}
509
510fn all_wants_present(db: &FileObjectDatabase, wants: &[ObjectId]) -> Result<bool> {
511    for want in wants {
512        if !db.contains(want)? {
513            return Ok(false);
514        }
515    }
516    Ok(true)
517}
518
519/// The want-line capabilities for an SSH fetch: the `shallow` capability when a
520/// deepen is requested, otherwise none (preserving the existing plain-fetch wire
521/// form exactly).
522fn ssh_shallow_request_capabilities(deepen: Option<u32>) -> Vec<Capability> {
523    if deepen.is_some() {
524        vec![Capability {
525            name: "shallow".into(),
526            value: None,
527        }]
528    } else {
529        Vec::new()
530    }
531}
532
533/// The upload-pack ref advertisements and parsed features for SSH `remote`.
534pub fn ssh_upload_pack_advertisements(
535    remote: &RemoteUrl,
536    format: ObjectFormat,
537) -> Result<(Vec<RefAdvertisement>, UploadPackFeatures)> {
538    if remote.transport != RemoteTransport::Ssh {
539        return Err(GitError::InvalidFormat(
540            "SSH upload-pack requires an SSH remote".into(),
541        ));
542    }
543    let ssh = ssh_process_command(
544        remote,
545        GitService::UploadPack,
546        ssh_program(),
547        SshCommandVariant::OpenSsh,
548    )?;
549    let output = ProcessCommand::new(&ssh.program)
550        .args(&ssh.args)
551        .stdin(Stdio::null())
552        .output()?;
553    let mut stdout = output.stdout.as_slice();
554    let set = match read_ref_advertisement_set(format, &mut stdout) {
555        Ok(set) => set,
556        Err(_) if !output.status.success() => {
557            return Err(GitError::Command(format!(
558                "ssh upload-pack failed for {}: {}",
559                ssh_remote_display(remote),
560                String::from_utf8_lossy(&output.stderr).trim()
561            )));
562        }
563        Err(err) => return Err(err),
564    };
565    let features = set
566        .refs
567        .first()
568        .map(|advertisement| parse_upload_pack_features(&advertisement.capabilities))
569        .transpose()?
570        .unwrap_or_default();
571    Ok((set.refs, features))
572}
573
574/// Post an upload-pack `request` + `haves` over SSH and read back the raw packfile
575/// response. The leading re-advertised ref set in the RPC stream is read and
576/// discarded before the request is written. For a plain (non-deepen) request; see
577/// [`ssh_upload_pack_shallow_fetch_response`] for the deepen case.
578pub fn ssh_upload_pack_fetch_response(
579    remote: &RemoteUrl,
580    format: ObjectFormat,
581    _features: &UploadPackFeatures,
582    request: UploadPackRequest,
583    haves: Vec<ObjectId>,
584) -> Result<UploadPackRawPackfileResponse> {
585    let (_shallow, response) =
586        ssh_upload_pack_fetch_response_inner(remote, format, request, haves, false)?;
587    Ok(response)
588}
589
590/// Post a deepen upload-pack `request` + `haves` over SSH and read back the
591/// shallow-info section plus the raw packfile response. Use this when `request`
592/// carries a `shallow`/`deepen`/`deepen-since`/`deepen-not` argument: the response
593/// is then prefixed with a shallow-info section (possibly empty). The returned
594/// [`ProtocolV2FetchShallowInfo`] entries are the server's shallow-info updates.
595pub fn ssh_upload_pack_shallow_fetch_response(
596    remote: &RemoteUrl,
597    format: ObjectFormat,
598    _features: &UploadPackFeatures,
599    request: UploadPackRequest,
600    haves: Vec<ObjectId>,
601) -> Result<(
602    Vec<ProtocolV2FetchShallowInfo>,
603    UploadPackRawPackfileResponse,
604)> {
605    ssh_upload_pack_fetch_response_inner(remote, format, request, haves, true)
606}
607
608/// Drive the `ssh` upload-pack subprocess for `request` + `haves`, reading back the
609/// raw packfile response. When `expect_shallow_info` is set (the request is a
610/// deepen request) the response's leading shallow-info section is parsed and
611/// returned; otherwise no shallow-info is expected and the returned vec is empty.
612fn ssh_upload_pack_fetch_response_inner(
613    remote: &RemoteUrl,
614    format: ObjectFormat,
615    request: UploadPackRequest,
616    haves: Vec<ObjectId>,
617    expect_shallow_info: bool,
618) -> Result<(
619    Vec<ProtocolV2FetchShallowInfo>,
620    UploadPackRawPackfileResponse,
621)> {
622    if remote.transport != RemoteTransport::Ssh {
623        return Err(GitError::InvalidFormat(
624            "SSH upload-pack requires an SSH remote".into(),
625        ));
626    }
627    let ssh = ssh_process_command(
628        remote,
629        GitService::UploadPack,
630        ssh_program(),
631        SshCommandVariant::OpenSsh,
632    )?;
633    let mut child = ProcessCommand::new(&ssh.program)
634        .args(&ssh.args)
635        .stdin(Stdio::piped())
636        .stdout(Stdio::piped())
637        .stderr(Stdio::piped())
638        .spawn()?;
639    let mut stdout = child
640        .stdout
641        .take()
642        .ok_or_else(|| GitError::Command("ssh upload-pack stdout was not piped".into()))?;
643    let mut stdin = child
644        .stdin
645        .take()
646        .ok_or_else(|| GitError::Command("ssh upload-pack stdin was not piped".into()))?;
647
648    read_ref_advertisement_set(format, &mut stdout)?;
649    write_upload_pack_request(&mut stdin, Some(&request))?;
650    write_upload_pack_negotiation_request(
651        &mut stdin,
652        &UploadPackNegotiationRequest { haves, done: true },
653    )?;
654    drop(stdin);
655
656    let result = if expect_shallow_info {
657        read_upload_pack_shallow_info_and_raw_packfile_response(format, &mut stdout)?
658    } else {
659        (
660            Vec::new(),
661            read_upload_pack_raw_packfile_response(format, &mut stdout)?,
662        )
663    };
664    let output = child.wait_with_output()?;
665    if !output.status.success() {
666        return Err(GitError::Command(format!(
667            "ssh upload-pack failed for {}: {}",
668            ssh_remote_display(remote),
669            String::from_utf8_lossy(&output.stderr).trim()
670        )));
671    }
672    Ok(result)
673}
674
675/// A human-readable rendering of an SSH `remote` for error messages. The CLI built
676/// these messages from the resolved URL string; the library only has the parsed
677/// [`RemoteUrl`], so reconstruct the `user@host[:port]/path` (or `host:path` SCP)
678/// form for the diagnostic text.
679fn ssh_remote_display(remote: &RemoteUrl) -> String {
680    let host = remote.host.as_deref().unwrap_or("");
681    let mut out = String::new();
682    if let Some(user) = &remote.user {
683        out.push_str(user);
684        out.push('@');
685    }
686    out.push_str(host);
687    if let Some(port) = remote.port {
688        out.push(':');
689        out.push_str(&port.to_string());
690    }
691    if !remote.path.is_empty() {
692        if !out.is_empty() {
693            out.push(':');
694        }
695        out.push_str(&remote.path);
696    }
697    out
698}