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