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::path::Path;
23
24use sley_config::GitConfig;
25use sley_core::{ObjectFormat, Result};
26use sley_transport::GitCredential;
27
28mod credentials;
29pub use credentials::{
30    CredentialHelperProvider, credential_fill, credential_request_for_url, credential_store,
31    http_credential_host, http_protocol_name, http_url_credential,
32};
33
34#[cfg(feature = "http")]
35mod http;
36#[cfg(feature = "http")]
37pub use http::{
38    HttpFetchPackRequest, HttpServiceAdvertisements, http_advertised_refs,
39    http_authorization_headers, http_check_status, http_protocol_v2_fetch_response,
40    http_send_with_auth, http_service_advertisements, http_upload_pack_advertisements,
41    http_validate_content_type, install_fetch_pack_via_http_protocol_v2_fetch,
42    install_fetch_pack_via_http_upload_pack, new_http_client, remote_url_is_http,
43};
44
45mod ssh;
46pub use ssh::{
47    SshFetchPackRequest, SshTransportOptions, install_fetch_pack_via_ssh_upload_pack, ssh_program,
48    ssh_transport_options_from_config, ssh_upload_pack_advertisements,
49    ssh_upload_pack_advertisements_with_options,
50};
51
52mod git;
53pub use git::{
54    GitFetchPackRequest, git_upload_pack_advertisements,
55    git_upload_pack_advertisements_with_protocol, install_fetch_pack_via_git_upload_pack,
56};
57
58mod local;
59pub use local::{
60    INFINITE_DEPTH, LocalDeepenPlan, attach_receive_pack_capabilities,
61    attach_upload_pack_capabilities, compute_local_deepen, compute_local_deepen_by_rev_list,
62    install_fetch_pack_via_local_upload_pack, local_fetch_advertisements, local_have_oids,
63    receive_pack_features, receive_pack_into_local_repository,
64    receive_pack_request_uses_push_options, receive_pack_stream_into_local_repository,
65    serve_upload_pack_v2, serve_upload_pack_v2_with_config, upload_pack_features,
66    upload_pack_from_local_repository, upload_pack_request_uses_sideband,
67    upload_pack_sideband_response,
68};
69
70mod fetch;
71pub use fetch::{
72    FetchOptions, FetchOutcome, FetchRequest, FetchServices, FetchSource, PruneRefsInput,
73    PrunedRef, append_reachable_auto_follow_tags, apply_configured_fetch_prune_option,
74    apply_configured_remote_tag_option, fetch, fetch_head_source_description,
75    fetch_refspec_excludes, fetch_refspecs_for_source, mark_tag_refspec_updates_not_for_merge,
76    order_bundle_fetch_all_tags_updates, prune_refs_from_advertisements,
77    retain_missing_auto_follow_tags, write_default_fetch_head, write_fetch_head,
78    write_fetch_head_records,
79};
80
81mod pack;
82pub use pack::{
83    PushPackRequest, build_push_packfile, build_receive_pack_body,
84    remote_advertisement_tips_known_to_local,
85};
86
87mod push;
88pub use push::{
89    PushAction, PushActionPlan, PushActionRequest, PushCommand, PushDestination, PushOptions,
90    PushOutcome, PushPlan, PushQuarantine, PushRefStatus, PushReportRef, PushReportRequest,
91    PushRequest, PushServices, PushStatusReport, PushThinMode, execute_push_action_plan,
92    execute_push_plan, local_push_source_refs, normalize_push_refname, normalize_push_refspec,
93    plan_push, plan_push_actions, push, push_actions, push_local_with_report,
94    reject_non_fast_forward_pushes, stage_local_push_quarantine, validate_receive_pack_report,
95};
96
97mod ls_remote;
98pub use ls_remote::{LsRemoteFilter, LsRemoteRecord, LsRemoteSource, ls_remote};
99
100mod clone;
101pub use clone::{CloneOptions, CloneOutcome, CloneRequest, CloneServices, CloneSource, clone};
102
103mod bundle;
104pub use bundle::{FetchBundleRequest, fetch_bundle};
105
106mod shallow;
107pub use shallow::{apply_shallow_info, read_shallow, write_shallow};
108
109mod capabilities;
110pub use capabilities::{
111    BUNDLE_FETCH_SUPPORTED, HTTP_PROTOCOL_V2_FETCH, RemoteTransportKind, SSH_CLONE_SUPPORTED,
112    THIN_PACK_PUSH_SUPPORTED, TransportCapabilities,
113};
114
115mod protocol;
116pub use protocol::{
117    TransportPolicyError, check_transport_allowed, is_transport_allowed,
118    transport_scheme_for_remote, transport_scheme_for_url,
119};
120
121mod resolve;
122pub use resolve::{
123    fetch_source_for_url, fetch_url, push_destination_for_url, push_url, resolve_fetch_source,
124    resolve_push_destination, transport_kind_for_url,
125};
126
127/// The object format of the repository whose common `$GIT_DIR` is `common_git_dir`.
128///
129/// Reads `common_git_dir/config`'s `extensions.objectFormat`, defaulting to
130/// SHA-1 when the config is absent or unreadable (matching git). `common_git_dir`
131/// must already be the common git dir; this does no worktree resolution.
132pub fn object_format_for_git_dir(common_git_dir: &Path) -> Result<ObjectFormat> {
133    let Ok(config) = GitConfig::read(common_git_dir.join("config")) else {
134        return Ok(ObjectFormat::Sha1);
135    };
136    config.repository_object_format()
137}
138
139/// Supplies credentials for an authenticated remote, mirroring git's credential
140/// protocol: [`fill`](CredentialProvider::fill) is handed a partial
141/// [`GitCredential`] describing the request (protocol/host/path) and returns a
142/// completed credential, or `None` to proceed unauthenticated.
143///
144/// [`approve`](CredentialProvider::approve) / [`reject`](CredentialProvider::reject)
145/// let a backing store remember or forget a credential after the request
146/// succeeds or fails; the default no-ops suit providers without a store.
147pub trait CredentialProvider {
148    /// Complete `request` into a usable credential, or return `None` to attempt
149    /// the request without authentication.
150    fn fill(&mut self, request: GitCredential) -> Result<Option<GitCredential>>;
151
152    /// Record `credential` as having worked (e.g. store it). Default: no-op.
153    fn approve(&mut self, _credential: &GitCredential) -> Result<()> {
154        Ok(())
155    }
156
157    /// Record `credential` as having failed (e.g. erase it). Default: no-op.
158    fn reject(&mut self, _credential: &GitCredential) -> Result<()> {
159        Ok(())
160    }
161}
162
163/// A [`CredentialProvider`] that never supplies credentials, so every request is
164/// attempted unauthenticated. This is what an embedder targeting public remotes
165/// (e.g. heddle) uses to suppress prompts.
166#[derive(Debug, Default, Clone, Copy)]
167pub struct NoCredentials;
168
169impl CredentialProvider for NoCredentials {
170    fn fill(&mut self, _request: GitCredential) -> Result<Option<GitCredential>> {
171        Ok(None)
172    }
173}
174
175/// Receives human-facing progress and summary events from an operation (the
176/// "To <remote>" push summary, prune notices, "Cloning into…", etc.). The
177/// orchestration returns structured outcomes regardless; this is purely for
178/// presentation, so the default implementations discard everything.
179pub trait ProgressSink {
180    /// A free-form progress or summary line.
181    fn message(&mut self, _message: &str) {}
182}
183
184/// A [`ProgressSink`] that discards every event.
185#[derive(Debug, Default, Clone, Copy)]
186pub struct SilentProgress;
187
188impl ProgressSink for SilentProgress {}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use std::fs;
194    use std::path::{Path, PathBuf};
195    use std::sync::atomic::{AtomicU64, Ordering};
196
197    use sley_config::{ConfigEntry, ConfigSection};
198    use sley_formats::RepositoryLayout;
199    use sley_object::{Commit, EncodedObject, ObjectType, Tree};
200    use sley_odb::{FileObjectDatabase, ObjectWriter};
201    use sley_refs::{FileRefStore, RefTarget, RefUpdate};
202    use sley_transport::{RemoteUrl, parse_remote_url};
203
204    #[test]
205    fn no_credentials_never_fills() {
206        let mut provider = NoCredentials;
207        let request = GitCredential::default();
208        assert!(
209            provider
210                .fill(request)
211                .expect("test operation should succeed")
212                .is_none()
213        );
214    }
215
216    #[test]
217    fn silent_progress_accepts_messages() {
218        let mut progress = SilentProgress;
219        progress.message("Cloning into 'x'...");
220    }
221
222    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
223
224    fn live_env(name: &str) -> Option<String> {
225        match std::env::var(name) {
226            Ok(value) if !value.is_empty() => Some(value),
227            _ => None,
228        }
229    }
230
231    fn live_repo(name: &str) -> PathBuf {
232        let dir = std::env::temp_dir().join(format!(
233            "sley-remote-live-{name}-{}-{}",
234            std::process::id(),
235            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
236        ));
237        let _ = fs::remove_dir_all(&dir);
238        RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
239            .expect("live test repository should initialize");
240        dir.join(".git")
241    }
242
243    fn remote_config(url: &str) -> GitConfig {
244        GitConfig {
245            sections: vec![ConfigSection::new(
246                "remote",
247                Some("origin".into()),
248                vec![
249                    ConfigEntry::new("url", Some(url.into())),
250                    ConfigEntry::new("fetch", Some("+refs/heads/*:refs/remotes/origin/*".into())),
251                ],
252            )],
253            ..GitConfig::default()
254        }
255    }
256
257    fn fetch_options(depth: Option<u32>) -> FetchOptions {
258        FetchOptions {
259            quiet: true,
260            auto_follow_tags: false,
261            fetch_all_tags: false,
262            prune: false,
263            prune_tags: false,
264            dry_run: false,
265            force: false,
266            append: false,
267            write_fetch_head: true,
268            tag_option_explicit: true,
269            prune_option_explicit: true,
270            prune_tags_option_explicit: true,
271            refmap: None,
272            depth,
273            merge_srcs: Vec::new(),
274            filter: None,
275            refetch: false,
276            cloning: false,
277            record_promisor_refs: true,
278            update_shallow: false,
279            deepen_relative: false,
280            update_head_ok: false,
281            deepen_since: None,
282            deepen_not: Vec::new(),
283            ssh_options: None,
284            atomic: false,
285        }
286    }
287
288    fn write_live_commit(git_dir: &Path, branch: &str) {
289        let format = ObjectFormat::Sha1;
290        let db = FileObjectDatabase::from_git_dir(git_dir, format);
291        let tree = db
292            .write_object(EncodedObject::new(
293                ObjectType::Tree,
294                Tree { entries: vec![] }.write(),
295            ))
296            .expect("live commit tree should write");
297        let timestamp = 1 + TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
298        let identity =
299            format!("Sley Remote Live <sley@example.invalid> {timestamp} +0000").into_bytes();
300        let oid = db
301            .write_object(EncodedObject::new(
302                ObjectType::Commit,
303                Commit {
304                    tree,
305                    parents: Vec::new(),
306                    author: identity.clone(),
307                    committer: identity,
308                    encoding: None,
309                    message: format!("sley remote live {branch}\n").into_bytes(),
310                }
311                .write(),
312            ))
313            .expect("live commit should write");
314        let store = FileRefStore::new(git_dir, format);
315        let mut tx = store.transaction();
316        tx.update(RefUpdate {
317            name: format!("refs/heads/{branch}"),
318            expected: None,
319            new: RefTarget::Direct(oid),
320            reflog: None,
321        });
322        tx.update(RefUpdate {
323            name: "HEAD".into(),
324            expected: None,
325            new: RefTarget::Symbolic(format!("refs/heads/{branch}")),
326            reflog: None,
327        });
328        tx.commit().expect("live refs should update");
329    }
330
331    struct EnvCredentials {
332        username: String,
333        password: String,
334    }
335
336    impl CredentialProvider for EnvCredentials {
337        fn fill(&mut self, mut request: GitCredential) -> Result<Option<GitCredential>> {
338            request.username = Some(self.username.clone());
339            request.password = Some(self.password.clone());
340            Ok(Some(request))
341        }
342    }
343
344    fn live_fetch(
345        url_var: &str,
346        branch_var: &str,
347        source: FetchSource,
348        credentials: &mut dyn CredentialProvider,
349        depth: Option<u32>,
350    ) {
351        let Some(url) = live_env(url_var) else {
352            return;
353        };
354        let branch = live_env(branch_var).unwrap_or_else(|| "main".into());
355        let local = live_repo(url_var);
356        let refspec = format!("refs/heads/{branch}:refs/remotes/origin/{branch}");
357        let config = remote_config(&url);
358        let options = fetch_options(depth);
359        let mut progress = SilentProgress;
360
361        let outcome = fetch(
362            FetchRequest {
363                git_dir: &local,
364                format: ObjectFormat::Sha1,
365                config: &config,
366                remote_name: "origin",
367                source: &source,
368                refspecs: &[refspec],
369                options: &options,
370            },
371            FetchServices {
372                credentials,
373                progress: &mut progress,
374                ref_hook: None,
375            },
376        )
377        .expect("live fetch should succeed");
378
379        assert!(!outcome.ref_updates.is_empty());
380        if depth.is_some() {
381            assert!(
382                local.join("shallow").exists(),
383                "shallow fetch should write .git/shallow"
384            );
385        }
386    }
387
388    fn live_push(
389        url_var: &str,
390        branch_prefix_var: &str,
391        destination: PushDestination,
392        credentials: &mut dyn CredentialProvider,
393    ) {
394        let Some(_) = live_env(url_var) else {
395            return;
396        };
397        let branch_prefix =
398            live_env(branch_prefix_var).unwrap_or_else(|| "sley-remote-live".into());
399        let branch = format!(
400            "{branch_prefix}-{}-{}",
401            std::process::id(),
402            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
403        );
404        let local = live_repo(url_var);
405        write_live_commit(&local, &branch);
406        let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
407        let options = PushOptions {
408            quiet: true,
409            force: false,
410            thin: PushThinMode::Auto,
411        };
412        let mut progress = SilentProgress;
413
414        let outcome = push(
415            PushRequest {
416                git_dir: &local,
417                common_git_dir: &local,
418                format: ObjectFormat::Sha1,
419                config: &GitConfig::default(),
420                remote: "origin",
421                destination: &destination,
422                refspecs: &[refspec],
423                options: &options,
424            },
425            PushServices {
426                credentials,
427                progress: &mut progress,
428            },
429        )
430        .expect("live push should succeed");
431
432        assert_eq!(outcome.commands.len(), 1);
433    }
434
435    #[test]
436    fn live_github_https_public_fetch() {
437        let Some(url) = live_env("SLEY_REMOTE_LIVE_GITHUB_HTTPS_PUBLIC_URL") else {
438            return;
439        };
440        let remote = parse_remote_url(&url).expect("live HTTPS URL should parse");
441        let mut credentials = NoCredentials;
442        live_fetch(
443            "SLEY_REMOTE_LIVE_GITHUB_HTTPS_PUBLIC_URL",
444            "SLEY_REMOTE_LIVE_GITHUB_HTTPS_PUBLIC_BRANCH",
445            FetchSource::Http(remote),
446            &mut credentials,
447            None,
448        );
449    }
450
451    #[test]
452    fn live_private_https_auth_fetch_uses_credential_provider() {
453        let (Some(url), Some(username), Some(password)) = (
454            live_env("SLEY_REMOTE_LIVE_PRIVATE_HTTPS_URL"),
455            live_env("SLEY_REMOTE_LIVE_PRIVATE_HTTPS_USERNAME"),
456            live_env("SLEY_REMOTE_LIVE_PRIVATE_HTTPS_PASSWORD"),
457        ) else {
458            return;
459        };
460        let remote = parse_remote_url(&url).expect("live private HTTPS URL should parse");
461        let mut credentials = EnvCredentials { username, password };
462        live_fetch(
463            "SLEY_REMOTE_LIVE_PRIVATE_HTTPS_URL",
464            "SLEY_REMOTE_LIVE_PRIVATE_HTTPS_BRANCH",
465            FetchSource::Http(remote),
466            &mut credentials,
467            None,
468        );
469    }
470
471    #[test]
472    fn live_https_push() {
473        let Some(url) = live_env("SLEY_REMOTE_LIVE_HTTPS_PUSH_URL") else {
474            return;
475        };
476        let remote = parse_remote_url(&url).expect("live HTTPS push URL should parse");
477        let mut no_credentials;
478        let mut env_credentials;
479        let credentials: &mut dyn CredentialProvider = match (
480            live_env("SLEY_REMOTE_LIVE_HTTPS_PUSH_USERNAME"),
481            live_env("SLEY_REMOTE_LIVE_HTTPS_PUSH_PASSWORD"),
482        ) {
483            (Some(username), Some(password)) => {
484                env_credentials = EnvCredentials { username, password };
485                &mut env_credentials
486            }
487            _ => {
488                no_credentials = NoCredentials;
489                &mut no_credentials
490            }
491        };
492        live_push(
493            "SLEY_REMOTE_LIVE_HTTPS_PUSH_URL",
494            "SLEY_REMOTE_LIVE_HTTPS_PUSH_BRANCH_PREFIX",
495            PushDestination::Http(remote),
496            credentials,
497        );
498    }
499
500    #[test]
501    fn live_ssh_fetch() {
502        let Some(url) = live_env("SLEY_REMOTE_LIVE_SSH_FETCH_URL") else {
503            return;
504        };
505        let remote = parse_remote_url(&url).expect("live SSH fetch URL should parse");
506        let mut credentials = NoCredentials;
507        live_fetch(
508            "SLEY_REMOTE_LIVE_SSH_FETCH_URL",
509            "SLEY_REMOTE_LIVE_SSH_FETCH_BRANCH",
510            FetchSource::Ssh(remote),
511            &mut credentials,
512            None,
513        );
514    }
515
516    #[test]
517    fn live_ssh_push() {
518        let Some(url) = live_env("SLEY_REMOTE_LIVE_SSH_PUSH_URL") else {
519            return;
520        };
521        let remote = parse_remote_url(&url).expect("live SSH push URL should parse");
522        let mut credentials = NoCredentials;
523        live_push(
524            "SLEY_REMOTE_LIVE_SSH_PUSH_URL",
525            "SLEY_REMOTE_LIVE_SSH_PUSH_BRANCH_PREFIX",
526            PushDestination::Ssh(remote),
527            &mut credentials,
528        );
529    }
530
531    #[test]
532    fn live_shallow_https_fetch_and_clone() {
533        let Some(url) = live_env("SLEY_REMOTE_LIVE_SHALLOW_HTTPS_URL") else {
534            return;
535        };
536        let branch =
537            live_env("SLEY_REMOTE_LIVE_SHALLOW_HTTPS_BRANCH").unwrap_or_else(|| "main".into());
538        let remote = parse_remote_url(&url).expect("live shallow HTTPS URL should parse");
539        let mut credentials = NoCredentials;
540        live_fetch(
541            "SLEY_REMOTE_LIVE_SHALLOW_HTTPS_URL",
542            "SLEY_REMOTE_LIVE_SHALLOW_HTTPS_BRANCH",
543            FetchSource::Http(remote.clone()),
544            &mut credentials,
545            Some(1),
546        );
547
548        let destination = std::env::temp_dir().join(format!(
549            "sley-remote-live-clone-{}-{}",
550            std::process::id(),
551            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
552        ));
553        let _ = fs::remove_dir_all(&destination);
554        let config = remote_config(&url);
555        let mut configure = |_git_dir: &Path| Ok(config.clone());
556        let mut configure_branch = |_git_dir: &Path, _branch: &str| Ok(config.clone());
557        let options = CloneOptions {
558            origin: "origin",
559            checkout_branch: &branch,
560            remote_head_branch: &branch,
561            single_branch: true,
562            depth: Some(1),
563            deepen_since: None,
564            deepen_not: Vec::new(),
565            committer: b"Sley Remote Live <sley@example.invalid> 1 +0000".to_vec(),
566            detached_head: None,
567            checkout: true,
568            filter: None,
569            // The live test clones a specific branch via --single-branch, so the
570            // branch was explicitly requested (a missing remote tip is a hard error).
571            branch_explicit: true,
572            ref_storage: sley_formats::RefStorageFormat::Files,
573            ssh_options: None,
574        };
575        let mut clone_credentials = NoCredentials;
576        let mut progress = SilentProgress;
577
578        let outcome = clone(
579            CloneRequest {
580                destination: &destination,
581                git_dir_override: None,
582                core_worktree: None,
583                format: ObjectFormat::Sha1,
584                source: &CloneSource::Http(RemoteUrl { ..remote }),
585                options: &options,
586            },
587            CloneServices {
588                configure: &mut configure,
589                configure_branch: &mut configure_branch,
590                credentials: &mut clone_credentials,
591                progress: &mut progress,
592            },
593        )
594        .expect("live shallow HTTPS clone should succeed");
595
596        assert!(outcome.git_dir.join("shallow").exists());
597    }
598}