Skip to main content

sley_remote/
lib.rs

1//! `git-remote` — callable fetch / push / clone / ls-remote orchestration.
2//!
3//! This crate lifts the network-transport orchestration out of the `git-cli`
4//! monolith so it can be driven as a library (the way a downstream consumer such
5//! as heddle needs). The wire codecs ([`sley_protocol`]), the pack encoder
6//! ([`sley_pack`]), pack building ([`sley_odb`]) and ref/commit plumbing already
7//! live in their own crates; `git-remote` is the glue that sequences them into
8//! `fetch`/`push`/`clone`/`ls-remote`, with the CLI-specific concerns (argument
9//! parsing, stdout/stderr formatting, exit codes, repository discovery from
10//! process-global state) kept out via the seams below:
11//!
12//! * [`CredentialProvider`] — how authenticated remotes obtain credentials. The
13//!   caller injects one (e.g. a credential-helper-backed impl, an interactive
14//!   prompt, or [`NoCredentials`] for unauthenticated/public access).
15//! * [`ProgressSink`] — where human-facing progress/summary lines go. The
16//!   orchestration returns structured outcomes and emits progress through this
17//!   sink instead of printing, so the caller controls presentation.
18//!
19//! The lift proceeds in stages (see `docs/git-remote-extraction.md`); this is
20//! the scaffold (stage A).
21
22use std::io::Read;
23use std::path::Path;
24
25use sley_config::GitConfig;
26pub use sley_config::remotes::{SetUrlError, SetUrlKind, SetUrlOp, set_url};
27use sley_core::{GitError, ObjectFormat, Result};
28use sley_protocol::{
29    RefAdvertisementSet, parse_ref_advertisement_set, parse_upload_pack_features,
30    read_pkt_line_frames_until_flush,
31};
32use sley_transport::GitCredential;
33
34mod admin;
35mod install;
36pub use admin::{
37    AddRemoteOptions, AddRemoteOutcome, RemoteAdminError, RemoteHeadMutation, RemoteHeadPlan,
38    RemoteMirror, RemotePrunePlan, RemoteTagMode, RemoveRemoteOutcome, RenameRemoteOutcome,
39    add_remote, apply_remote_head, plan_remote_prune, remote_branch_fetch_refspec, remove_remote,
40    rename_remote, set_remote_branches,
41};
42pub use install::{
43    install_protocol_v2_fetch_promisor_response_from_reader,
44    install_protocol_v2_fetch_response_from_reader,
45    install_upload_pack_packfile_promisor_response_from_reader,
46    install_upload_pack_packfile_response_from_reader,
47    install_upload_pack_raw_promisor_response_from_reader,
48    install_upload_pack_raw_response_from_reader,
49    install_upload_pack_shallow_packfile_promisor_response_from_reader,
50    install_upload_pack_shallow_packfile_response_from_reader,
51    install_upload_pack_shallow_raw_promisor_response_from_reader,
52    install_upload_pack_shallow_raw_response_from_reader,
53    shallow_info_from_protocol_v2_fetch_header,
54};
55
56mod credentials;
57pub use credentials::{
58    CredentialHelperProvider, credential_fill, credential_request_for_url, credential_store,
59    http_credential_host, http_protocol_name, http_url_credential,
60};
61
62mod http_backend;
63pub use http_backend::{
64    HttpBackendOperation, HttpBackendPlan, HttpBackendRequest, HttpBackendService,
65    http_backend_service_enabled, plan_http_backend_request,
66};
67
68#[cfg(feature = "http")]
69mod http;
70#[cfg(feature = "http")]
71pub use http::{
72    HttpFetchPackRequest, HttpOperationBatch, HttpServiceAdvertisements, HttpUploadPackDiscovery,
73    http_advertised_refs, http_authorization_headers, http_check_status, http_discover_upload_pack,
74    http_protocol_v2_fetch_response, http_send_with_auth, http_service_advertisements,
75    http_upload_pack_advertisements, http_upload_pack_features, http_validate_content_type,
76    install_fetch_pack_via_http_protocol_v2_fetch, install_fetch_pack_via_http_upload_pack,
77    new_http_client, remote_url_is_http,
78};
79// Re-export the smart-HTTP client seam so out-of-crate hosts (e.g. weft) can
80// implement [`HttpClient`] to enforce network policy on the dial without a
81// direct `sley-transport` dependency. See `fetch_with_http_client` /
82// `clone_with_http_client`.
83#[cfg(feature = "http")]
84pub use sley_transport::{HttpClient, HttpResponse, UreqHttpClient};
85
86mod ssh;
87pub use ssh::{
88    SshFetchPackRequest, SshTransportOptions, SshUploadPackDiscovery,
89    discover_ssh_upload_pack_advertisements_with_command, install_fetch_pack_via_ssh_upload_pack,
90    ssh_program, ssh_transport_options_from_config, ssh_upload_pack_advertisements,
91    ssh_upload_pack_advertisements_with_command, ssh_upload_pack_advertisements_with_options,
92};
93
94mod git;
95mod git_proxy;
96pub use git::{
97    GitFetchPackRequest, discover_git_upload_pack_advertisements, git_upload_pack_advertisements,
98    git_upload_pack_advertisements_with_protocol, install_fetch_pack_via_git_upload_pack,
99};
100
101mod helper;
102pub use helper::{
103    DiscoveredRemoteHelperFetchOperation, RemoteHelperCapabilities, RemoteHelperEvent,
104    RemoteHelperEventSink, RemoteHelperExportRequest, RemoteHelperFetchDiscovery,
105    RemoteHelperFetchOperation, RemoteHelperFetchServices, RemoteHelperPlumbing,
106    RemoteHelperPushError, RemoteHelperPushOperation, RemoteHelperPushOptions,
107    RemoteHelperPushOutcome, RemoteHelperPushServices, RemoteHelperRef, RemoteHelperRefValue,
108    RemoteHelperSession, RemoteHelperSpec, discover_remote_helper_fetch,
109    fetch_via_discovered_remote_helper, fetch_via_remote_helper,
110    imported_remote_helper_advertisements, push_via_remote_helper, resolve_remote_helper,
111    rewrite_remote_helper_import_stream,
112};
113
114mod proc_receive;
115mod receive_hooks;
116pub use receive_hooks::{run_pre_receive, run_update_hooks};
117mod receive_pack_server;
118
119pub use proc_receive::{
120    ProcReceiveRefPattern, ProcReceiveReport, ReceivePackCommandState, parse_proc_receive_refs,
121    proc_receive_ref_matches,
122};
123pub use receive_pack_server::{
124    ReceivePackServerOptions, ReceivePackServerOutcome, ReceivePackServerReport,
125    ReceivePackServerRequest, flush_receive_pack_sideband, receive_pack_server_report_v1,
126    request_uses_sideband, run_receive_pack_post_hooks, serve_receive_pack,
127    write_receive_pack_server_report, write_receive_pack_sideband_stderr,
128};
129
130/// Read a v0/v1 upload-pack advertisement before a local object format exists.
131///
132/// Clone is the important caller: the remote advertisement establishes the
133/// format of the repository that will be created. Fetch and push instead know
134/// their repository format already and continue to use the strict readers.
135pub(crate) fn read_discovered_upload_pack_advertisements(
136    reader: &mut impl Read,
137) -> Result<(RefAdvertisementSet, ObjectFormat)> {
138    let frames = read_pkt_line_frames_until_flush(reader)?;
139    let sha1 = parse_ref_advertisement_set(ObjectFormat::Sha1, &frames);
140    let sha256 = parse_ref_advertisement_set(ObjectFormat::Sha256, &frames);
141    for (format, parsed) in [
142        (ObjectFormat::Sha1, sha1.as_ref()),
143        (ObjectFormat::Sha256, sha256.as_ref()),
144    ] {
145        let Ok(set) = parsed else {
146            continue;
147        };
148        let advertised = set
149            .refs
150            .first()
151            .map(|reference| parse_upload_pack_features(&reference.capabilities))
152            .transpose()?
153            .and_then(|features| features.object_format)
154            .unwrap_or(ObjectFormat::Sha1);
155        if advertised == format {
156            return Ok((set.clone(), format));
157        }
158    }
159    match sha1 {
160        Err(err) => Err(err),
161        Ok(_) => Err(GitError::InvalidObjectId(
162            "advertised object format does not match advertised object ids".into(),
163        )),
164    }
165}
166
167mod local;
168pub use local::{
169    INFINITE_DEPTH, LocalDeepenPlan, attach_receive_pack_capabilities,
170    attach_upload_pack_capabilities, compute_local_deepen, compute_local_deepen_by_rev_list,
171    hydrate_objects_from_local_promisor_remotes, hydrate_reachable_from_local_promisor_remotes,
172    install_fetch_pack_via_local_upload_pack, local_fetch_advertisements, local_have_oids,
173    receive_pack_features, receive_pack_into_local_repository,
174    receive_pack_request_uses_push_options, receive_pack_stream_into_local_repository,
175    serve_upload_pack_v2, serve_upload_pack_v2_stateless_with_config,
176    serve_upload_pack_v2_with_config, upload_pack_features, upload_pack_from_local_repository,
177    upload_pack_request_uses_sideband, upload_pack_sideband_response,
178};
179
180mod filter;
181pub use filter::{pack_filter_from_spec, pack_filter_from_spec_for_clone};
182
183mod fetch;
184#[cfg(feature = "http")]
185pub use fetch::fetch_with_http_client;
186pub use fetch::{
187    FetchOptions, FetchOutcome, FetchRepositoryPlan, FetchRequest, FetchServices, FetchSource,
188    PruneRefsInput, PrunedRef, RemoteHelperFetchRequest, append_reachable_auto_follow_tags,
189    apply_configured_fetch_prune_option, apply_configured_partial_clone_filter,
190    apply_configured_remote_tag_option, fetch, fetch_head_source_description,
191    fetch_refspec_excludes, fetch_refspecs_for_source, finalize_remote_helper_fetch,
192    mark_tag_refspec_updates_not_for_merge, order_bundle_fetch_all_tags_updates,
193    plan_fetch_repository, prune_refs_from_advertisements, retain_missing_auto_follow_tags,
194    write_default_fetch_head, write_fetch_head, write_fetch_head_records,
195};
196
197mod pack;
198pub use pack::{
199    PushPackRequest, build_push_packfile, build_receive_pack_body,
200    remote_advertisement_tips_known_to_local,
201};
202
203mod push;
204pub use push::{
205    PushAction, PushActionPlan, PushActionRequest, PushCommand, PushDestination, PushOptions,
206    PushOutcome, PushPlan, PushQuarantine, PushRefStatus, PushReportRef, PushReportRequest,
207    PushRequest, PushServices, PushStatusReport, PushThinMode, ReceivePackPushReport,
208    apply_receive_pack_report_to_push_refs, execute_push_action_plan, execute_push_plan,
209    local_push_source_refs, normalize_push_refname, normalize_push_refspec, plan_push,
210    plan_push_actions, push, push_actions, push_local_uses_receive_pack_server,
211    push_local_with_report, push_local_with_report_and_objects, push_url_for_display,
212    read_receive_pack_push_report, reject_non_fast_forward_pushes, run_local_push_post_hooks,
213    stage_local_push_quarantine, validate_receive_pack_report, validate_receive_pack_unpack,
214};
215
216mod ls_remote;
217pub use ls_remote::{
218    LsRemoteFilter, LsRemoteOutcome, LsRemoteRecord, LsRemoteRequest, LsRemoteSource, ls_remote,
219    ls_remote_with,
220};
221
222mod clone;
223#[cfg(feature = "http")]
224pub use clone::clone_with_http_client;
225pub use clone::{CloneOptions, CloneOutcome, CloneRequest, CloneServices, CloneSource, clone};
226
227mod bundle;
228pub use bundle::{FetchBundleRequest, fetch_bundle};
229mod bundle_uri;
230pub use bundle_uri::{
231    BundleUriEntry, BundleUriList, bundle_uri_fetch_order, handshake_advertises_bundle_uri,
232    http_remote_bundle_uri_list, parse_bundle_uri_line, prefetch_advertised_bundle_uris,
233    prefetch_advertised_bundle_uris_with_client, transfer_bundle_uri_enabled,
234};
235
236mod shallow;
237pub use shallow::{apply_shallow_info, read_shallow, write_shallow};
238
239mod capabilities;
240pub use capabilities::{RemoteTransportKind, TransportCapabilities};
241
242mod protocol;
243pub use protocol::{
244    TransportPolicyError, check_transport_allowed, is_transport_allowed,
245    transport_scheme_for_remote, transport_scheme_for_url,
246};
247
248mod promisor;
249pub use promisor::{
250    PromisorAcceptPolicy, PromisorRemoteDecision, PromisorRemoteField, PromisorRemoteFieldUpdate,
251    apply_promisor_remote_field_updates, config_has_promisor_remote,
252    configured_promisor_remote_names, decide_promisor_remote_reply, promisor_accept_policy,
253    promisor_remote_auto_filter, promisor_remote_server_capability,
254};
255
256mod resolve;
257pub use resolve::{
258    RemoteResolutionContext, ResolvedRemote, discover_local_git_dir, fetch_source_for_url,
259    fetch_url, push_destination_for_url, push_url, resolve_configured_local_remote_git_dir,
260    resolve_fetch_source, resolve_local_remote_git_dir, resolve_push_destination, resolve_remote,
261    transport_kind_for_url,
262};
263
264/// The object format of the repository whose common `$GIT_DIR` is `common_git_dir`.
265///
266/// Reads `common_git_dir/config`'s `extensions.objectFormat`, defaulting to
267/// SHA-1 when the config is absent or unreadable (matching git). `common_git_dir`
268/// must already be the common git dir; this does no worktree resolution.
269pub fn object_format_for_git_dir(common_git_dir: &Path) -> Result<ObjectFormat> {
270    let Ok(config) = GitConfig::read(common_git_dir.join("config")) else {
271        return Ok(ObjectFormat::Sha1);
272    };
273    config.repository_object_format()
274}
275
276/// Supplies credentials for an authenticated remote, mirroring git's credential
277/// protocol: [`fill`](CredentialProvider::fill) is handed a partial
278/// [`GitCredential`] describing the request (protocol/host/path) and returns a
279/// completed credential, or `None` to proceed unauthenticated.
280///
281/// [`approve`](CredentialProvider::approve) / [`reject`](CredentialProvider::reject)
282/// let a backing store remember or forget a credential after the request
283/// succeeds or fails; the default no-ops suit providers without a store.
284pub trait CredentialProvider {
285    /// Complete `request` into a usable credential, or return `None` to attempt
286    /// the request without authentication.
287    fn fill(&mut self, request: GitCredential) -> Result<Option<GitCredential>>;
288
289    /// Record `credential` as having worked (e.g. store it). Default: no-op.
290    fn approve(&mut self, _credential: &GitCredential) -> Result<()> {
291        Ok(())
292    }
293
294    /// Record `credential` as having failed (e.g. erase it). Default: no-op.
295    fn reject(&mut self, _credential: &GitCredential) -> Result<()> {
296        Ok(())
297    }
298}
299
300/// A [`CredentialProvider`] that never supplies credentials, so every request is
301/// attempted unauthenticated. This is what an embedder targeting public remotes
302/// (e.g. heddle) uses to suppress prompts.
303#[derive(Debug, Default, Clone, Copy)]
304pub struct NoCredentials;
305
306impl CredentialProvider for NoCredentials {
307    fn fill(&mut self, _request: GitCredential) -> Result<Option<GitCredential>> {
308        Ok(None)
309    }
310}
311
312/// Structured transfer statistics during a fetch/clone pack receive+index.
313/// All counters are monotonic within one operation and reset per operation.
314#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
315pub struct TransferProgress {
316    /// Wire/pack bytes received so far. THE KB counter. No reliable total
317    /// (smart-HTTP doesn't announce pack size up front).
318    pub received_bytes: u64,
319    /// Objects received/parsed from the pack so far.
320    pub received_objects: u64,
321    /// Total objects the server announced (pack header), once known — the
322    /// denominator for an object-count percentage. `None` until the header
323    /// is read.
324    pub total_objects: Option<u64>,
325    /// Deltas resolved during the resolving-deltas phase (surface if cheap).
326    pub indexed_deltas: u64,
327}
328
329/// Equal-work counts for one pack generated natively by Sley.
330///
331/// These are semantic events rather than pre-rendered terminal text, allowing
332/// an embedding wrapper to render Git-compatible generation progress.
333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
334pub struct PackGenerationProgress {
335    /// Objects enumerated and written by the transfer.
336    pub total_objects: usize,
337    /// Objects eligible for the delta-compression search phase.
338    pub compression_objects: usize,
339    /// Objects ultimately written as deltas.
340    pub delta_objects: u32,
341}
342
343/// Receives human-facing progress and summary events from an operation (the
344/// `To <remote>` push summary, prune notices, "Cloning into…", etc.). The
345/// orchestration returns structured outcomes regardless; this is purely for
346/// presentation, so the default implementations discard everything.
347pub trait ProgressSink {
348    /// A structured object-transfer progress event.
349    fn transfer(&mut self, _progress: TransferProgress) {}
350
351    /// Structured counts for a pack generated locally by Sley.
352    fn pack_generation(&mut self, _progress: &PackGenerationProgress) {}
353
354    /// A free-form progress or summary line.
355    fn message(&mut self, _message: &str) {}
356    /// A user-facing diagnostic that belongs on stderr in a CLI wrapper.
357    fn diagnostic(&mut self, message: &str) {
358        self.message(message);
359    }
360}
361
362/// A [`ProgressSink`] that discards every event.
363#[derive(Debug, Default, Clone, Copy)]
364pub struct SilentProgress;
365
366impl ProgressSink for SilentProgress {}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use std::fs;
372    use std::path::{Path, PathBuf};
373    use std::sync::atomic::{AtomicU64, Ordering};
374
375    use sley_config::{ConfigEntry, ConfigSection};
376    use sley_formats::RepositoryLayout;
377    use sley_object::{Commit, EncodedObject, ObjectType, Tree};
378    use sley_odb::{FileObjectDatabase, ObjectWriter};
379    use sley_refs::{FileRefStore, RefTarget, RefUpdate};
380    use sley_transport::{RemoteUrl, parse_remote_url};
381
382    #[test]
383    fn clone_discovery_adopts_advertised_sha256_format() {
384        let advertised = sley_protocol::RefAdvertisementSet {
385            protocol: sley_protocol::ProtocolVersion::V0,
386            refs: vec![sley_protocol::RefAdvertisement {
387                oid: sley_core::ObjectId::null(ObjectFormat::Sha256),
388                name: "capabilities^{}".into(),
389                capabilities: vec![sley_core::Capability {
390                    name: "object-format".into(),
391                    value: Some("sha256".into()),
392                }],
393            }],
394            shallow: Vec::new(),
395        };
396        let mut bytes = Vec::new();
397        sley_protocol::write_ref_advertisement_set(&mut bytes, &advertised)
398            .expect("advertisement should encode");
399
400        let (parsed, format) = read_discovered_upload_pack_advertisements(&mut bytes.as_slice())
401            .expect("format should be discovered");
402
403        assert_eq!(format, ObjectFormat::Sha256);
404        assert_eq!(parsed, advertised);
405    }
406
407    #[test]
408    fn no_credentials_never_fills() {
409        let mut provider = NoCredentials;
410        let request = GitCredential::default();
411        assert!(
412            provider
413                .fill(request)
414                .expect("test operation should succeed")
415                .is_none()
416        );
417    }
418
419    #[test]
420    fn silent_progress_accepts_messages() {
421        let mut progress = SilentProgress;
422        progress.message("Cloning into 'x'...");
423    }
424
425    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
426
427    fn live_env(name: &str) -> Option<String> {
428        match std::env::var(name) {
429            Ok(value) if !value.is_empty() => Some(value),
430            _ => None,
431        }
432    }
433
434    fn live_repo(name: &str) -> PathBuf {
435        let dir = std::env::temp_dir().join(format!(
436            "sley-remote-live-{name}-{}-{}",
437            std::process::id(),
438            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
439        ));
440        let _ = fs::remove_dir_all(&dir);
441        RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
442            .expect("live test repository should initialize");
443        dir.join(".git")
444    }
445
446    fn remote_config(url: &str) -> GitConfig {
447        GitConfig {
448            sections: vec![ConfigSection::new(
449                "remote",
450                Some("origin".into()),
451                vec![
452                    ConfigEntry::new("url", Some(url.into())),
453                    ConfigEntry::new("fetch", Some("+refs/heads/*:refs/remotes/origin/*".into())),
454                ],
455            )],
456            ..GitConfig::default()
457        }
458    }
459
460    fn fetch_options(depth: Option<u32>) -> FetchOptions {
461        FetchOptions {
462            quiet: true,
463            progress: None,
464            auto_follow_tags: false,
465            fetch_all_tags: false,
466            prune: false,
467            prune_tags: false,
468            dry_run: false,
469            force: false,
470            append: false,
471            write_fetch_head: true,
472            tag_option_explicit: true,
473            prune_option_explicit: true,
474            prune_tags_option_explicit: true,
475            refmap: None,
476            depth,
477            merge_srcs: Vec::new(),
478            filter: None,
479            filter_auto: false,
480            refetch: false,
481            cloning: false,
482            record_promisor_refs: true,
483            update_shallow: false,
484            reject_shallow: false,
485            deepen_relative: false,
486            update_head_ok: false,
487            deepen_since: None,
488            deepen_not: Vec::new(),
489            ssh_options: None,
490            upload_pack_command: None,
491            atomic: false,
492            negotiation_restrict: None,
493            negotiation_include: None,
494        }
495    }
496
497    fn write_live_commit(git_dir: &Path, branch: &str) {
498        let format = ObjectFormat::Sha1;
499        let db = FileObjectDatabase::from_git_dir(git_dir, format);
500        let tree = db
501            .write_object(EncodedObject::new(
502                ObjectType::Tree,
503                Tree { entries: vec![] }.write(),
504            ))
505            .expect("live commit tree should write");
506        let timestamp = 1 + TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
507        let identity =
508            format!("Sley Remote Live <sley@example.invalid> {timestamp} +0000").into_bytes();
509        let oid = db
510            .write_object(EncodedObject::new(
511                ObjectType::Commit,
512                Commit {
513                    tree,
514                    parents: Vec::new(),
515                    author: identity.clone(),
516                    committer: identity,
517                    encoding: None,
518                    message: format!("sley remote live {branch}\n").into_bytes(),
519                }
520                .write(),
521            ))
522            .expect("live commit should write");
523        let store = FileRefStore::new(git_dir, format);
524        let mut tx = store.transaction();
525        tx.update(RefUpdate {
526            name: format!("refs/heads/{branch}"),
527            expected: None,
528            new: RefTarget::Direct(oid),
529            reflog: None,
530        });
531        tx.update(RefUpdate {
532            name: "HEAD".into(),
533            expected: None,
534            new: RefTarget::Symbolic(format!("refs/heads/{branch}")),
535            reflog: None,
536        });
537        tx.commit().expect("live refs should update");
538    }
539
540    struct EnvCredentials {
541        username: String,
542        password: String,
543    }
544
545    impl CredentialProvider for EnvCredentials {
546        fn fill(&mut self, mut request: GitCredential) -> Result<Option<GitCredential>> {
547            request.username = Some(self.username.clone());
548            request.password = Some(self.password.clone());
549            Ok(Some(request))
550        }
551    }
552
553    fn live_fetch(
554        url_var: &str,
555        branch_var: &str,
556        source: FetchSource,
557        credentials: &mut dyn CredentialProvider,
558        depth: Option<u32>,
559    ) {
560        let Some(url) = live_env(url_var) else {
561            return;
562        };
563        let branch = live_env(branch_var).unwrap_or_else(|| "main".into());
564        let local = live_repo(url_var);
565        let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
566        let config = remote_config(&url);
567        let options = fetch_options(depth);
568        let mut progress = SilentProgress;
569
570        let outcome = fetch(
571            FetchRequest {
572                git_dir: &local,
573                format: ObjectFormat::Sha1,
574                config: &config,
575                remote_name: "origin",
576                source: &source,
577                refspecs: &[refspec],
578                options: &options,
579                validation: None,
580            },
581            FetchServices {
582                credentials,
583                progress: &mut progress,
584                ref_hook: None,
585            },
586        )
587        .expect("live fetch should succeed");
588
589        assert!(!outcome.ref_updates.is_empty());
590        if depth.is_some() {
591            assert!(
592                local.join("shallow").exists(),
593                "shallow fetch should write .git/shallow"
594            );
595        }
596    }
597
598    fn live_push(
599        url_var: &str,
600        branch_prefix_var: &str,
601        destination: PushDestination,
602        credentials: &mut dyn CredentialProvider,
603    ) {
604        let Some(_) = live_env(url_var) else {
605            return;
606        };
607        let branch_prefix =
608            live_env(branch_prefix_var).unwrap_or_else(|| "sley-remote-live".into());
609        let branch = format!(
610            "{branch_prefix}-{}-{}",
611            std::process::id(),
612            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
613        );
614        let local = live_repo(url_var);
615        write_live_commit(&local, &branch);
616        let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
617        let options = PushOptions {
618            quiet: true,
619            force: false,
620            thin: PushThinMode::Auto,
621            atomic: false,
622            push_options: Vec::new(),
623        };
624        let mut progress = SilentProgress;
625
626        let outcome = push(
627            PushRequest {
628                git_dir: &local,
629                common_git_dir: &local,
630                format: ObjectFormat::Sha1,
631                config: &GitConfig::default(),
632                remote: "origin",
633                destination: &destination,
634                refspecs: &[refspec],
635                options: &options,
636            },
637            PushServices {
638                credentials,
639                progress: &mut progress,
640            },
641        )
642        .expect("live push should succeed");
643
644        assert_eq!(outcome.commands.len(), 1);
645    }
646
647    #[test]
648    fn live_github_https_public_fetch() {
649        let Some(url) = live_env("SLEY_REMOTE_LIVE_GITHUB_HTTPS_PUBLIC_URL") else {
650            return;
651        };
652        let remote = parse_remote_url(&url).expect("live HTTPS URL should parse");
653        let mut credentials = NoCredentials;
654        live_fetch(
655            "SLEY_REMOTE_LIVE_GITHUB_HTTPS_PUBLIC_URL",
656            "SLEY_REMOTE_LIVE_GITHUB_HTTPS_PUBLIC_BRANCH",
657            FetchSource::Http(remote),
658            &mut credentials,
659            None,
660        );
661    }
662
663    #[test]
664    fn live_private_https_auth_fetch_uses_credential_provider() {
665        let (Some(url), Some(username), Some(password)) = (
666            live_env("SLEY_REMOTE_LIVE_PRIVATE_HTTPS_URL"),
667            live_env("SLEY_REMOTE_LIVE_PRIVATE_HTTPS_USERNAME"),
668            live_env("SLEY_REMOTE_LIVE_PRIVATE_HTTPS_PASSWORD"),
669        ) else {
670            return;
671        };
672        let remote = parse_remote_url(&url).expect("live private HTTPS URL should parse");
673        let mut credentials = EnvCredentials { username, password };
674        live_fetch(
675            "SLEY_REMOTE_LIVE_PRIVATE_HTTPS_URL",
676            "SLEY_REMOTE_LIVE_PRIVATE_HTTPS_BRANCH",
677            FetchSource::Http(remote),
678            &mut credentials,
679            None,
680        );
681    }
682
683    #[test]
684    fn live_https_push() {
685        let Some(url) = live_env("SLEY_REMOTE_LIVE_HTTPS_PUSH_URL") else {
686            return;
687        };
688        let remote = parse_remote_url(&url).expect("live HTTPS push URL should parse");
689        let mut no_credentials;
690        let mut env_credentials;
691        let credentials: &mut dyn CredentialProvider = match (
692            live_env("SLEY_REMOTE_LIVE_HTTPS_PUSH_USERNAME"),
693            live_env("SLEY_REMOTE_LIVE_HTTPS_PUSH_PASSWORD"),
694        ) {
695            (Some(username), Some(password)) => {
696                env_credentials = EnvCredentials { username, password };
697                &mut env_credentials
698            }
699            _ => {
700                no_credentials = NoCredentials;
701                &mut no_credentials
702            }
703        };
704        live_push(
705            "SLEY_REMOTE_LIVE_HTTPS_PUSH_URL",
706            "SLEY_REMOTE_LIVE_HTTPS_PUSH_BRANCH_PREFIX",
707            PushDestination::Http(remote),
708            credentials,
709        );
710    }
711
712    #[test]
713    fn live_ssh_fetch() {
714        let Some(url) = live_env("SLEY_REMOTE_LIVE_SSH_FETCH_URL") else {
715            return;
716        };
717        let remote = parse_remote_url(&url).expect("live SSH fetch URL should parse");
718        let mut credentials = NoCredentials;
719        live_fetch(
720            "SLEY_REMOTE_LIVE_SSH_FETCH_URL",
721            "SLEY_REMOTE_LIVE_SSH_FETCH_BRANCH",
722            FetchSource::Ssh(remote),
723            &mut credentials,
724            None,
725        );
726    }
727
728    #[test]
729    fn live_ssh_push() {
730        let Some(url) = live_env("SLEY_REMOTE_LIVE_SSH_PUSH_URL") else {
731            return;
732        };
733        let remote = parse_remote_url(&url).expect("live SSH push URL should parse");
734        let mut credentials = NoCredentials;
735        live_push(
736            "SLEY_REMOTE_LIVE_SSH_PUSH_URL",
737            "SLEY_REMOTE_LIVE_SSH_PUSH_BRANCH_PREFIX",
738            PushDestination::Ssh(remote),
739            &mut credentials,
740        );
741    }
742
743    #[test]
744    fn live_shallow_https_fetch_and_clone() {
745        let Some(url) = live_env("SLEY_REMOTE_LIVE_SHALLOW_HTTPS_URL") else {
746            return;
747        };
748        let branch =
749            live_env("SLEY_REMOTE_LIVE_SHALLOW_HTTPS_BRANCH").unwrap_or_else(|| "main".into());
750        let remote = parse_remote_url(&url).expect("live shallow HTTPS URL should parse");
751        let mut credentials = NoCredentials;
752        live_fetch(
753            "SLEY_REMOTE_LIVE_SHALLOW_HTTPS_URL",
754            "SLEY_REMOTE_LIVE_SHALLOW_HTTPS_BRANCH",
755            FetchSource::Http(remote.clone()),
756            &mut credentials,
757            Some(1),
758        );
759
760        let destination = std::env::temp_dir().join(format!(
761            "sley-remote-live-clone-{}-{}",
762            std::process::id(),
763            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
764        ));
765        let _ = fs::remove_dir_all(&destination);
766        let config = remote_config(&url);
767        let mut configure = |_git_dir: &Path| Ok(config.clone());
768        let mut configure_branch = |_git_dir: &Path, _branch: &str| Ok(config.clone());
769        let options = CloneOptions {
770            origin: "origin",
771            checkout_branch: &branch,
772            remote_head_branch: &branch,
773            single_branch: true,
774            progress: false,
775            depth: Some(1),
776            deepen_since: None,
777            deepen_not: Vec::new(),
778            committer: b"Sley Remote Live <sley@example.invalid> 1 +0000".to_vec(),
779            detached_head: None,
780            checkout: true,
781            filter: None,
782            filter_auto: false,
783            // The live test clones a specific branch via --single-branch, so the
784            // branch was explicitly requested (a missing remote tip is a hard error).
785            branch_explicit: true,
786            ref_storage: sley_formats::RefStorageFormat::Files,
787            ssh_options: None,
788            upload_pack_command: None,
789            reject_shallow: false,
790        };
791        let mut clone_credentials = NoCredentials;
792        let mut progress = SilentProgress;
793
794        let outcome = clone(
795            CloneRequest {
796                destination: &destination,
797                git_dir_override: None,
798                core_worktree: None,
799                format: ObjectFormat::Sha1,
800                source: &CloneSource::Http(RemoteUrl { ..remote }),
801                options: &options,
802            },
803            CloneServices {
804                configure: &mut configure,
805                configure_branch: &mut configure_branch,
806                credentials: &mut clone_credentials,
807                progress: &mut progress,
808            },
809        )
810        .expect("live shallow HTTPS clone should succeed");
811
812        assert!(outcome.git_dir.join("shallow").exists());
813    }
814}