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