diff --git a/Cargo.lock b/Cargo.lock index b8dacede984..58ec39581ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1446,8 +1446,8 @@ dependencies = [ "git-transport", "maybe-async", "nom", - "quick-error", "serde", + "thiserror", ] [[package]] @@ -1504,6 +1504,7 @@ name = "git-repository" version = "0.21.1" dependencies = [ "anyhow", + "async-std", "byte-unit", "clru", "document-features", @@ -1526,6 +1527,7 @@ dependencies = [ "git-path", "git-protocol", "git-ref", + "git-refspec", "git-revision", "git-sec", "git-tempfile", @@ -1646,7 +1648,6 @@ dependencies = [ "git-url", "maybe-async", "pin-project-lite", - "quick-error", "serde", "thiserror", ] @@ -1676,8 +1677,8 @@ dependencies = [ "git-features", "git-path", "home", - "quick-error", "serde", + "thiserror", "url", ] @@ -1765,6 +1766,7 @@ dependencies = [ "git-features", "git-pack", "git-repository", + "git-transport", "git-url", "itertools", "jwalk", diff --git a/Makefile b/Makefile index f31922bc82f..ce0baf06ce7 100644 --- a/Makefile +++ b/Makefile @@ -110,6 +110,7 @@ check: ## Build all code in suitable configurations cd git-transport && cargo check \ && cargo check --features blocking-client \ && cargo check --features async-client \ + && cargo check --features async-client,async-std \ && cargo check --features http-client-curl cd git-transport && if cargo check --all-features 2>/dev/null; then false; else true; fi cd git-protocol && cargo check \ @@ -118,6 +119,7 @@ check: ## Build all code in suitable configurations cd git-protocol && if cargo check --all-features 2>/dev/null; then false; else true; fi cd git-repository && cargo check --no-default-features --features local \ && cargo check --no-default-features --features async-network-client \ + && cargo check --no-default-features --features async-network-client-async-std \ && cargo check --no-default-features --features blocking-network-client \ && cargo check --no-default-features --features blocking-network-client,blocking-http-transport \ && cargo check --no-default-features --features one-stop-shop \ @@ -151,6 +153,8 @@ unit-tests: ## run all unit tests && cargo test --features async-client \ && cargo test cd git-repository && cargo test \ + && cargo test --features async-network-client \ + && cargo test --features blocking-network-client \ && cargo test --features regex cd gitoxide-core && cargo test --lib diff --git a/cargo-smart-release/src/changelog/write.rs b/cargo-smart-release/src/changelog/write.rs index 331b10d759b..be3a014c045 100644 --- a/cargo-smart-release/src/changelog/write.rs +++ b/cargo-smart-release/src/changelog/write.rs @@ -50,7 +50,7 @@ impl From for RepositoryUrl { impl RepositoryUrl { pub fn is_github(&self) -> bool { - self.inner.host.as_ref().map(|h| h == "github.com").unwrap_or(false) + self.inner.host().map(|h| h == "github.com").unwrap_or(false) } fn cleaned_path(&self) -> String { @@ -59,15 +59,14 @@ impl RepositoryUrl { } pub fn github_https(&self) -> Option { - match &self.inner.host { - Some(host) if host == "github.com" => match self.inner.scheme { + match &self.inner.host() { + Some(host) if *host == "github.com" => match self.inner.scheme { Scheme::Http | Scheme::Https | Scheme::Git => { format!("https://github.com{}", self.cleaned_path()).into() } Scheme::Ssh => self .inner - .user - .as_ref() + .user() .map(|user| format!("https://github.com{}/{}", user, self.cleaned_path())), Scheme::Radicle | Scheme::File => None, }, diff --git a/crate-status.md b/crate-status.md index 7f98ea4ac62..7e5fb15b135 100644 --- a/crate-status.md +++ b/crate-status.md @@ -237,6 +237,8 @@ Check out the [performance discussion][git-traverse-performance] as well. ### git-refspec * [x] parse * [ ] matching of references and object names + * [ ] for fetch + * [ ] for push ### git-note @@ -459,9 +461,19 @@ See its [README.md](https://github.com/Byron/gitoxide/blob/main/git-lock/README. * **references** * [x] peel to end * [x] ref-log access - * [ ] clone from remote - * [ ] shallow - * [ ] execute hooks + * [x] remote name + * [x] find remote itself + - [ ] respect `branch..merge` in the returned remote. + * **remotes** + * [ ] clone + * [ ] shallow + * [ ] fetch + * [ ] push + * [ ] ls-refs + * [ ] list, find by name, create in memory. + * [ ] groups + * [ ] [remote and branch files](https://github.com/git/git/blob/master/remote.c#L300) + * [ ] execute hooks * **refs** * [ ] run transaction hooks and handle special repository states like quarantine * [ ] support for different backends like `files` and `reftable` @@ -488,7 +500,6 @@ See its [README.md](https://github.com/Byron/gitoxide/blob/main/git-lock/README. * [x] read and interpolate trusted paths * [x] low-level API for more elaborate access to all details of `git-config` files * [ ] a way to make changes to individual configuration files - * [ ] remotes with push and pull * [x] mailmap * [x] object replacements (`git replace`) * [ ] configuration diff --git a/etc/check-package-size.sh b/etc/check-package-size.sh index f479611f72e..20dc2c8aeb2 100755 --- a/etc/check-package-size.sh +++ b/etc/check-package-size.sh @@ -53,6 +53,6 @@ echo "in root: gitoxide CLI" (enter git-odb && indent cargo diet -n --package-size-limit 120KB) (enter git-protocol && indent cargo diet -n --package-size-limit 50KB) (enter git-packetline && indent cargo diet -n --package-size-limit 35KB) -(enter git-repository && indent cargo diet -n --package-size-limit 140KB) +(enter git-repository && indent cargo diet -n --package-size-limit 150KB) (enter git-transport && indent cargo diet -n --package-size-limit 50KB) (enter gitoxide-core && indent cargo diet -n --package-size-limit 80KB) diff --git a/git-config/src/file/access/comfort.rs b/git-config/src/file/access/comfort.rs index 0f15288a724..b50a0f8f07c 100644 --- a/git-config/src/file/access/comfort.rs +++ b/git-config/src/file/access/comfort.rs @@ -2,7 +2,8 @@ use std::{borrow::Cow, convert::TryFrom}; use bstr::BStr; -use crate::{file::MetadataFilter, value, File}; +use crate::parse::section; +use crate::{file::MetadataFilter, lookup, value, File}; /// Comfortable API for accessing values impl<'event> File<'event> { @@ -80,9 +81,20 @@ impl<'event> File<'event> { key: impl AsRef, filter: &mut MetadataFilter, ) -> Option> { - self.raw_value_filter(section_name, subsection_name, key, filter) - .ok() - .map(|v| crate::Boolean::try_from(v).map(|b| b.into())) + let section_name = section_name.as_ref(); + let key = key.as_ref(); + match self.raw_value_filter(section_name, subsection_name, key, filter) { + Ok(v) => Some(crate::Boolean::try_from(v).map(|b| b.into())), + Err(lookup::existing::Error::KeyMissing) => { + let section = self + .section_filter(section_name, subsection_name, filter) + .ok() + .flatten()?; + let key = section::Key::try_from(key).ok()?; + section.key_and_value_range_by(&key).map(|_| Ok(true)) + } + Err(_err) => None, + } } /// Like [`value()`][File::value()], but returning an `Option` if the integer wasn't found. diff --git a/git-config/src/file/access/mutate.rs b/git-config/src/file/access/mutate.rs index 7810f7c32a3..b70930eca1d 100644 --- a/git-config/src/file/access/mutate.rs +++ b/git-config/src/file/access/mutate.rs @@ -11,7 +11,7 @@ use crate::{ /// Mutating low-level access methods. impl<'event> File<'event> { - /// Returns an mutable section with a given `name` and optional `subsection_name`. + /// Returns an mutable section with a given `name` and optional `subsection_name`, _if it exists_. pub fn section_mut<'a>( &'a mut self, name: impl AsRef, @@ -29,8 +29,46 @@ impl<'event> File<'event> { .expect("BUG: Section did not have id from lookup") .to_mut(nl)) } + /// Returns an mutable section with a given `name` and optional `subsection_name`, _if it exists_, or create a new section. + pub fn section_mut_or_create_new<'a>( + &'a mut self, + name: impl AsRef, + subsection_name: Option<&str>, + ) -> Result, section::header::Error> { + self.section_mut_or_create_new_filter(name, subsection_name, &mut |_| true) + } + + /// Returns an mutable section with a given `name` and optional `subsection_name`, _if it exists_ **and** passes `filter`, or create + /// a new section. + pub fn section_mut_or_create_new_filter<'a>( + &'a mut self, + name: impl AsRef, + subsection_name: Option<&str>, + filter: &mut MetadataFilter, + ) -> Result, section::header::Error> { + let name = name.as_ref(); + match self + .section_ids_by_name_and_subname(name.as_ref(), subsection_name) + .ok() + .and_then(|it| { + it.rev().find(|id| { + let s = &self.sections[id]; + filter(s.meta()) + }) + }) { + Some(id) => { + let nl = self.detect_newline_style_smallvec(); + Ok(self + .sections + .get_mut(&id) + .expect("BUG: Section did not have id from lookup") + .to_mut(nl)) + } + None => self.new_section(name.to_owned(), subsection_name.map(|n| Cow::Owned(n.to_owned()))), + } + } - /// Returns the last found mutable section with a given `name` and optional `subsection_name`, that matches `filter`. + /// Returns the last found mutable section with a given `name` and optional `subsection_name`, that matches `filter`, _if it exists_. /// /// If there are sections matching `section_name` and `subsection_name` but the `filter` rejects all of them, `Ok(None)` /// is returned. @@ -78,7 +116,7 @@ impl<'event> File<'event> { /// # use git_config::parse::section; /// let mut git_config = git_config::File::default(); /// let mut section = git_config.new_section("hello", Some("world".into()))?; - /// section.push(section::Key::try_from("a")?, "b"); + /// section.push(section::Key::try_from("a")?, Some("b".into())); /// let nl = section.newline().to_owned(); /// assert_eq!(git_config.to_string(), format!("[hello \"world\"]{nl}\ta = b{nl}")); /// let _section = git_config.new_section("core", None); diff --git a/git-config/src/file/access/raw.rs b/git-config/src/file/access/raw.rs index 6a6d90a3838..e2b84da6a38 100644 --- a/git-config/src/file/access/raw.rs +++ b/git-config/src/file/access/raw.rs @@ -1,3 +1,4 @@ +use std::convert::TryInto; use std::{borrow::Cow, collections::HashMap}; use bstr::BStr; @@ -329,7 +330,9 @@ impl<'event> File<'event> { } } - /// Sets a value in a given section, optional subsection, and key value. + /// Sets a value in a given `section_name`, optional `subsection_name`, and `key`. + /// Note sections named `section_name` and `subsection_name` (if not `None`) + /// must exist for this method to work. /// /// # Examples /// @@ -351,7 +354,7 @@ impl<'event> File<'event> { /// # use bstr::BStr; /// # use std::convert::TryFrom; /// # let mut git_config = git_config::File::try_from("[core]a=b\n[core]\na=c\na=d").unwrap(); - /// git_config.set_raw_value("core", None, "a", "e".into())?; + /// git_config.set_existing_raw_value("core", None, "a", "e")?; /// assert_eq!(git_config.raw_value("core", None, "a")?, Cow::::Borrowed("e".into())); /// assert_eq!( /// git_config.raw_values("core", None, "a")?, @@ -363,17 +366,76 @@ impl<'event> File<'event> { /// ); /// # Ok::<(), Box>(()) /// ``` - pub fn set_raw_value( + pub fn set_existing_raw_value<'b>( &mut self, section_name: impl AsRef, subsection_name: Option<&str>, key: impl AsRef, - new_value: &BStr, + new_value: impl Into<&'b BStr>, ) -> Result<(), lookup::existing::Error> { self.raw_value_mut(section_name, subsection_name, key.as_ref()) .map(|mut entry| entry.set(new_value)) } + /// Sets a value in a given `section_name`, optional `subsection_name`, and `key`. + /// Creates the section if necessary and the key as well, or overwrites the last existing value otherwise. + /// + /// # Examples + /// + /// Given the config, + /// + /// ```text + /// [core] + /// a = b + /// ``` + /// + /// Setting a new value to the key `core.a` will yield the following: + /// + /// ``` + /// # use git_config::File; + /// # use std::borrow::Cow; + /// # use bstr::BStr; + /// # use std::convert::TryFrom; + /// # let mut git_config = git_config::File::try_from("[core]a=b").unwrap(); + /// let prev = git_config.set_raw_value("core", None, "a", "e")?; + /// git_config.set_raw_value("core", None, "b", "f")?; + /// assert_eq!(prev.expect("present").as_ref(), "b"); + /// assert_eq!(git_config.raw_value("core", None, "a")?, Cow::::Borrowed("e".into())); + /// assert_eq!(git_config.raw_value("core", None, "b")?, Cow::::Borrowed("f".into())); + /// # Ok::<(), Box>(()) + /// ``` + pub fn set_raw_value<'b, Key, E>( + &mut self, + section_name: impl AsRef, + subsection_name: Option<&str>, + key: Key, + new_value: impl Into<&'b BStr>, + ) -> Result>, crate::file::set_raw_value::Error> + where + Key: TryInto, Error = E>, + section::key::Error: From, + { + self.set_raw_value_filter(section_name, subsection_name, key, new_value, &mut |_| true) + } + + /// Similar to [`set_raw_value()`][Self::set_raw_value()], but only sets existing values in sections matching + /// `filter`, creating a new section otherwise. + pub fn set_raw_value_filter<'b, Key, E>( + &mut self, + section_name: impl AsRef, + subsection_name: Option<&str>, + key: Key, + new_value: impl Into<&'b BStr>, + filter: &mut MetadataFilter, + ) -> Result>, crate::file::set_raw_value::Error> + where + Key: TryInto, Error = E>, + section::key::Error: From, + { + let mut section = self.section_mut_or_create_new_filter(section_name, subsection_name, filter)?; + Ok(section.set(key.try_into().map_err(section::key::Error::from)?, new_value)) + } + /// Sets a multivar in a given section, optional subsection, and key value. /// /// This internally zips together the new values and the existing values. @@ -413,7 +475,7 @@ impl<'event> File<'event> { /// "y", /// "z", /// ]; - /// git_config.set_raw_multi_value("core", None, "a", new_values.into_iter())?; + /// git_config.set_existing_raw_multi_value("core", None, "a", new_values.into_iter())?; /// let fetched_config = git_config.raw_values("core", None, "a")?; /// assert!(fetched_config.contains(&Cow::::Borrowed("x".into()))); /// assert!(fetched_config.contains(&Cow::::Borrowed("y".into()))); @@ -433,7 +495,7 @@ impl<'event> File<'event> { /// "x", /// "y", /// ]; - /// git_config.set_raw_multi_value("core", None, "a", new_values.into_iter())?; + /// git_config.set_existing_raw_multi_value("core", None, "a", new_values.into_iter())?; /// let fetched_config = git_config.raw_values("core", None, "a")?; /// assert!(fetched_config.contains(&Cow::::Borrowed("x".into()))); /// assert!(fetched_config.contains(&Cow::::Borrowed("y".into()))); @@ -454,11 +516,11 @@ impl<'event> File<'event> { /// "z", /// "discarded", /// ]; - /// git_config.set_raw_multi_value("core", None, "a", new_values)?; + /// git_config.set_existing_raw_multi_value("core", None, "a", new_values)?; /// assert!(!git_config.raw_values("core", None, "a")?.contains(&Cow::::Borrowed("discarded".into()))); /// # Ok::<(), git_config::lookup::existing::Error>(()) /// ``` - pub fn set_raw_multi_value<'a, Iter, Item>( + pub fn set_existing_raw_multi_value<'a, Iter, Item>( &mut self, section_name: impl AsRef, subsection_name: Option<&str>, diff --git a/git-config/src/file/access/read_only.rs b/git-config/src/file/access/read_only.rs index 4915cb3f8f1..56e274d7b05 100644 --- a/git-config/src/file/access/read_only.rs +++ b/git-config/src/file/access/read_only.rs @@ -40,7 +40,7 @@ impl<'event> File<'event> { /// let config = r#" /// [core] /// a = 10k - /// c + /// c = false /// "#; /// let git_config = git_config::File::try_from(config)?; /// // You can either use the turbofish to determine the type... @@ -103,13 +103,13 @@ impl<'event> File<'event> { /// a_value, /// vec![ /// Boolean(true), - /// Boolean(true), + /// Boolean(false), /// Boolean(false), /// ] /// ); /// // ... or explicitly declare the type to avoid the turbofish /// let c_value: Vec = git_config.values("core", None, "c").unwrap(); - /// assert_eq!(c_value, vec![Boolean(true)]); + /// assert_eq!(c_value, vec![Boolean(false)]); /// # Ok::<(), Box>(()) /// ``` /// diff --git a/git-config/src/file/init/from_env.rs b/git-config/src/file/init/from_env.rs index b721758b55a..aee3c47787a 100644 --- a/git-config/src/file/init/from_env.rs +++ b/git-config/src/file/init/from_env.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, convert::TryFrom}; +use std::convert::TryFrom; use crate::{file, file::init, parse, parse::section, path::interpolate, File}; @@ -58,18 +58,17 @@ impl File<'static> { key_val: key.to_string(), })?; - let mut section = match config.section_mut(key.section_name, key.subsection_name) { - Ok(section) => section, - Err(_) => config.new_section( - key.section_name.to_owned(), - key.subsection_name.map(|subsection| Cow::Owned(subsection.to_owned())), - )?, - }; - - section.push( - section::Key::try_from(key.value_name.to_owned())?, - git_path::os_str_into_bstr(&value).expect("no illformed UTF-8").as_ref(), - ); + config + .section_mut_or_create_new(key.section_name, key.subsection_name)? + .push( + section::Key::try_from(key.value_name.to_owned())?, + Some( + git_path::os_str_into_bstr(&value) + .expect("no illformed UTF-8") + .as_ref() + .into(), + ), + ); } let mut buf = Vec::new(); diff --git a/git-config/src/file/mod.rs b/git-config/src/file/mod.rs index aae24b6f3c3..e799b79b494 100644 --- a/git-config/src/file/mod.rs +++ b/git-config/src/file/mod.rs @@ -38,6 +38,19 @@ pub mod rename_section { } } +/// +pub mod set_raw_value { + /// The error returned by [`File::set_raw_value(…)`][crate::File::set_raw_value()]. + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error(transparent)] + Header(#[from] crate::parse::section::header::Error), + #[error(transparent)] + Key(#[from] crate::parse::section::key::Error), + } +} + /// Additional information about a section. #[derive(Clone, Debug, PartialOrd, PartialEq, Ord, Eq, Hash)] pub struct Metadata { diff --git a/git-config/src/file/mutable/section.rs b/git-config/src/file/mutable/section.rs index fac6f04febd..38fd3ec6f1c 100644 --- a/git-config/src/file/mutable/section.rs +++ b/git-config/src/file/mutable/section.rs @@ -28,16 +28,19 @@ pub struct SectionMut<'a, 'event> { /// Mutating methods. impl<'a, 'event> SectionMut<'a, 'event> { - /// Adds an entry to the end of this section name `key` and `value`. - pub fn push<'b>(&mut self, key: Key<'event>, value: impl Into<&'b BStr>) { + /// Adds an entry to the end of this section name `key` and `value`. If `value` is None`, no equal sign will be written leaving + /// just the key. This is useful for boolean values which are true if merely the key exists. + pub fn push<'b>(&mut self, key: Key<'event>, value: Option<&'b BStr>) { let body = &mut self.section.body.0; if let Some(ws) = &self.whitespace.pre_key { body.push(Event::Whitespace(ws.clone())); } body.push(Event::SectionKey(key)); - body.extend(self.whitespace.key_value_separators()); - body.push(Event::Value(escape_value(value.into()).into())); + if let Some(value) = value { + body.extend(self.whitespace.key_value_separators()); + body.push(Event::Value(escape_value(value).into())); + } if self.implicit_newline { body.push(Event::Newline(BString::from(self.newline.to_vec()).into())); } @@ -86,10 +89,11 @@ impl<'a, 'event> SectionMut<'a, 'event> { pub fn set<'b>(&mut self, key: Key<'event>, value: impl Into<&'b BStr>) -> Option> { match self.key_and_value_range_by(&key) { None => { - self.push(key, value); + self.push(key, Some(value.into())); None } - Some((_, value_range)) => { + Some((key_range, value_range)) => { + let value_range = value_range.unwrap_or(key_range.end - 1..key_range.end); let range_start = value_range.start; let ret = self.remove_internal(value_range); self.section diff --git a/git-config/src/file/section/body.rs b/git-config/src/file/section/body.rs index 875f1c45ed5..9f904fa64ea 100644 --- a/git-config/src/file/section/body.rs +++ b/git-config/src/file/section/body.rs @@ -14,10 +14,13 @@ pub struct Body<'event>(pub(crate) crate::parse::section::Events<'event>); /// Access impl<'event> Body<'event> { /// Retrieves the last matching value in a section with the given key, if present. + /// + /// Note that we consider values without key separator `=` non-existing. #[must_use] pub fn value(&self, key: impl AsRef) -> Option> { let key = Key::from_str_unchecked(key.as_ref()); - let (_, range) = self.key_and_value_range_by(&key)?; + let (_key_range, range) = self.key_and_value_range_by(&key)?; + let range = range?; let mut concatenated = BString::default(); for event in &self.0[range] { @@ -107,9 +110,10 @@ impl<'event> Body<'event> { &self.0 } - /// Returns the the range containing the value events for the `key`. - /// If the value is not found, then this returns an empty range. - pub(crate) fn key_and_value_range_by(&self, key: &Key<'_>) -> Option<(Range, Range)> { + /// Returns the the range containing the value events for the `key`, with value range being `None` if there is no key-value separator + /// and only a 'fake' Value event with an empty string in side. + /// If the value is not found, `None` is returned. + pub(crate) fn key_and_value_range_by(&self, key: &Key<'_>) -> Option<(Range, Option>)> { let mut value_range = Range::default(); let mut key_start = None; for (i, e) in self.0.iter().enumerate().rev() { @@ -138,7 +142,8 @@ impl<'event> Body<'event> { // value end needs to be offset by one so that the last value's index // is included in the range let value_range = value_range.start..value_range.end + 1; - (key_start..value_range.end, value_range) + let key_range = key_start..value_range.end; + (key_range, (value_range.start != key_start + 1).then(|| value_range)) }) } } diff --git a/git-config/src/parse/nom/mod.rs b/git-config/src/parse/nom/mod.rs index c54a963a34b..1a2da7b8516 100644 --- a/git-config/src/parse/nom/mod.rs +++ b/git-config/src/parse/nom/mod.rs @@ -288,6 +288,9 @@ fn config_value<'a>(i: &'a [u8], dispatch: &mut impl FnMut(Event<'a>)) -> IResul let (i, newlines) = value_impl(i, dispatch)?; Ok((i, newlines)) } else { + // This is a special way of denoting 'empty' values which a lot of code depends on. + // Hence, rather to fix this everywhere else, leave it here and fix it where it matters, namely + // when it's about differentiating between a missing key-vaue separator, and one followed by emptiness. dispatch(Event::Value(Cow::Borrowed("".into()))); Ok((i, 0)) } diff --git a/git-config/src/values/boolean.rs b/git-config/src/values/boolean.rs index 365e00f3366..ed10fb2210c 100644 --- a/git-config/src/values/boolean.rs +++ b/git-config/src/values/boolean.rs @@ -69,12 +69,12 @@ impl serde::Serialize for Boolean { } fn parse_true(value: &BStr) -> bool { - value.eq_ignore_ascii_case(b"yes") - || value.eq_ignore_ascii_case(b"on") - || value.eq_ignore_ascii_case(b"true") - || value.is_empty() + value.eq_ignore_ascii_case(b"yes") || value.eq_ignore_ascii_case(b"on") || value.eq_ignore_ascii_case(b"true") } fn parse_false(value: &BStr) -> bool { - value.eq_ignore_ascii_case(b"no") || value.eq_ignore_ascii_case(b"off") || value.eq_ignore_ascii_case(b"false") + value.eq_ignore_ascii_case(b"no") + || value.eq_ignore_ascii_case(b"off") + || value.eq_ignore_ascii_case(b"false") + || value.is_empty() } diff --git a/git-config/tests/file/access/raw/mod.rs b/git-config/tests/file/access/raw/mod.rs index 8198d29b1c9..0afd4700382 100644 --- a/git-config/tests/file/access/raw/mod.rs +++ b/git-config/tests/file/access/raw/mod.rs @@ -1,3 +1,4 @@ mod raw_multi_value; mod raw_value; +mod set_existing_raw_value; mod set_raw_value; diff --git a/git-config/tests/file/access/raw/set_existing_raw_value.rs b/git-config/tests/file/access/raw/set_existing_raw_value.rs new file mode 100644 index 00000000000..7741fd3be96 --- /dev/null +++ b/git-config/tests/file/access/raw/set_existing_raw_value.rs @@ -0,0 +1,60 @@ +fn file(input: &str) -> git_config::File<'static> { + input.parse().unwrap() +} + +fn assert_set_value(value: &str) { + let mut file = file("[a]k=b\n[a]\nk=c\nk=d"); + file.set_existing_raw_value("a", None, "k", value).unwrap(); + assert_eq!(file.raw_value("a", None, "k").unwrap().as_ref(), value); + + let file: git_config::File = file.to_string().parse().unwrap(); + assert_eq!( + file.raw_value("a", None, "k").unwrap().as_ref(), + value, + "{:?} didn't have expected value {:?}", + file.to_string(), + value + ); +} + +#[test] +fn single_line() { + assert_set_value("hello world"); +} + +#[test] +fn starts_with_whitespace() { + assert_set_value("\ta"); + assert_set_value(" a"); +} + +#[test] +fn ends_with_whitespace() { + assert_set_value("a\t"); + assert_set_value("a "); +} + +#[test] +fn quotes_and_backslashes() { + assert_set_value(r#""hello"\"there"\\\b\x"#); +} + +#[test] +fn multi_line() { + assert_set_value("a\nb \n\t c"); +} + +#[test] +fn comment_included() { + assert_set_value(";hello "); + assert_set_value(" # hello"); +} + +#[test] +fn non_existing_values_cannot_be_set() { + let mut file = git_config::File::default(); + assert!( + file.set_existing_raw_value("new", None, "key", "value").is_err(), + "new values are not ever created" + ); +} diff --git a/git-config/tests/file/access/raw/set_raw_value.rs b/git-config/tests/file/access/raw/set_raw_value.rs index 89cf772c5c1..6691a230f95 100644 --- a/git-config/tests/file/access/raw/set_raw_value.rs +++ b/git-config/tests/file/access/raw/set_raw_value.rs @@ -3,8 +3,8 @@ fn file(input: &str) -> git_config::File<'static> { } fn assert_set_value(value: &str) { - let mut file = file("[a]k=b\n[a]\nk=c\nk=d"); - file.set_raw_value("a", None, "k", value.into()).unwrap(); + let mut file = file("[a]\nk=c\nk=d"); + file.set_raw_value("a", None, "k", value).unwrap(); assert_eq!(file.raw_value("a", None, "k").unwrap().as_ref(), value); let file: git_config::File = file.to_string().parse().unwrap(); @@ -49,3 +49,17 @@ fn comment_included() { assert_set_value(";hello "); assert_set_value(" # hello"); } + +#[test] +fn non_existing_values_cannot_be_set() -> crate::Result { + let mut file = git_config::File::default(); + file.set_raw_value("new", None, "key", "value")?; + file.set_raw_value("new", "subsection".into(), "key", "subsection-value")?; + + assert_eq!(file.string("new", None, "key").expect("present").as_ref(), "value"); + assert_eq!( + file.string("new", Some("subsection"), "key").expect("present").as_ref(), + "subsection-value" + ); + Ok(()) +} diff --git a/git-config/tests/file/access/read_only.rs b/git-config/tests/file/access/read_only.rs index acc4e728256..50941ac77ed 100644 --- a/git-config/tests/file/access/read_only.rs +++ b/git-config/tests/file/access/read_only.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, convert::TryFrom, error::Error}; +use std::{borrow::Cow, convert::TryFrom}; use bstr::BStr; use git_config::{ @@ -40,15 +40,15 @@ fn get_value_for_all_provided_values() -> crate::Result { assert!(!config.value::("core", None, "bool-explicit")?.0); assert!(!config.boolean("core", None, "bool-explicit").expect("exists")?); - assert!(config.value::("core", None, "bool-implicit")?.0); assert!( - config - .try_value::("core", None, "bool-implicit") - .expect("exists")? - .0 + config.value::("core", None, "bool-implicit").is_err(), + "this cannot work like in git as the value isn't there for us" + ); + assert!( + config.boolean("core", None, "bool-implicit").expect("present")?, + "this should work" ); - assert!(config.boolean("core", None, "bool-implicit").expect("present")?); assert_eq!(config.string("doesnt", None, "exist"), None); assert_eq!( @@ -161,7 +161,14 @@ fn get_value_looks_up_all_sections_before_failing() -> crate::Result { let file = File::try_from(config)?; // Checks that we check the last entry first still - assert!(file.value::("core", None, "bool-implicit")?.0); + assert!( + !file.value::("core", None, "bool-implicit")?.0, + "this one can't do it, needs special handling" + ); + assert!( + !file.boolean("core", None, "bool-implicit").expect("present")?, + "this should work, but doesn't yet" + ); assert!(!file.value::("core", None, "bool-explicit")?.0); @@ -170,11 +177,11 @@ fn get_value_looks_up_all_sections_before_failing() -> crate::Result { #[test] fn section_names_are_case_insensitive() -> crate::Result { - let config = "[core] bool-implicit"; + let config = "[core] a=true"; let file = File::try_from(config)?; assert_eq!( - file.value::("core", None, "bool-implicit").unwrap(), - file.value::("CORE", None, "bool-implicit").unwrap() + file.value::("core", None, "a").unwrap(), + file.value::("CORE", None, "a").unwrap() ); Ok(()) @@ -196,15 +203,20 @@ fn value_names_are_case_insensitive() -> crate::Result { } #[test] -fn single_section() -> Result<(), Box> { +fn single_section() { let config = File::try_from("[core]\na=b\nc").unwrap(); let first_value = config.string("core", None, "a").unwrap(); - let second_value: Boolean = config.value("core", None, "c")?; - assert_eq!(first_value, cow_str("b")); - assert!(second_value.0); - Ok(()) + assert!( + config.raw_value("core", None, "c").is_err(), + "value is considered false as it is without '=', so it's like not present" + ); + + assert!( + config.boolean("core", None, "c").expect("present").unwrap(), + "asking for a boolean is true true, as per git rules" + ); } #[test] diff --git a/git-config/tests/file/init/from_env.rs b/git-config/tests/file/init/from_env.rs index 0cb9109b596..5ede003d3f2 100644 --- a/git-config/tests/file/init/from_env.rs +++ b/git-config/tests/file/init/from_env.rs @@ -35,19 +35,21 @@ fn parse_error_with_invalid_count() { #[test] #[serial] -fn single_key_value_pair() { +fn single_key_value_pair() -> crate::Result { let _env = Env::new() .set("GIT_CONFIG_COUNT", "1") .set("GIT_CONFIG_KEY_0", "core.key") .set("GIT_CONFIG_VALUE_0", "value"); - let config = File::from_env(Default::default()).unwrap().unwrap(); + let config = File::from_env(Default::default())?.unwrap(); + assert_eq!(config.raw_value("core", None, "key")?, Cow::<[u8]>::Borrowed(b"value")); assert_eq!( - config.raw_value("core", None, "key").unwrap(), - Cow::<[u8]>::Borrowed(b"value") + config.section("core", None)?.meta(), + &git_config::file::Metadata::from(git_config::Source::Env), + "source if configured correctly" ); - assert_eq!(config.num_values(), 1); + Ok(()) } #[test] diff --git a/git-config/tests/file/mutable/section.rs b/git-config/tests/file/mutable/section.rs index 2add8166b84..0f878bb8fde 100644 --- a/git-config/tests/file/mutable/section.rs +++ b/git-config/tests/file/mutable/section.rs @@ -1,3 +1,33 @@ +#[test] +fn section_mut_must_exist_as_section_is_not_created_automatically() { + let mut config = multi_value_section(); + assert!(config.section_mut("foo", None).is_err()); +} + +#[test] +fn section_mut_or_create_new_is_infallible() -> crate::Result { + let mut config = multi_value_section(); + let section = config.section_mut_or_create_new("name", Some("subsection"))?; + assert_eq!(section.header().name(), "name"); + assert_eq!(section.header().subsection_name().expect("set"), "subsection"); + Ok(()) +} + +#[test] +fn section_mut_or_create_new_filter_may_reject_existing_sections() -> crate::Result { + let mut config = multi_value_section(); + let section = config.section_mut_or_create_new_filter("a", None, &mut |_| false)?; + assert_eq!(section.header().name(), "a"); + assert_eq!(section.header().subsection_name(), None); + assert_eq!(section.to_bstring(), "[a]\n"); + assert_eq!( + section.meta(), + &git_config::file::Metadata::api(), + "new sections are of source 'API'" + ); + Ok(()) +} + mod remove { use super::multi_value_section; @@ -85,10 +115,20 @@ mod set { } mod push { - use std::convert::TryFrom; + use std::convert::{TryFrom, TryInto}; use git_config::parse::section::Key; + #[test] + fn none_as_value_omits_the_key_value_separator() -> crate::Result { + let mut file = git_config::File::default(); + let mut section = file.section_mut_or_create_new("a", Some("sub"))?; + section.push("key".try_into()?, None); + let expected = format!("[a \"sub\"]{nl}\tkey{nl}", nl = section.newline()); + assert_eq!(file.to_bstring(), expected); + Ok(()) + } + #[test] fn whitespace_is_derived_from_whitespace_before_first_value() -> crate::Result { for (input, expected_pre_key, expected_sep) in [ @@ -139,7 +179,7 @@ mod push { let mut config = git_config::File::default(); let mut section = config.new_section("a", None).unwrap(); section.set_implicit_newline(false); - section.push(Key::try_from("k").unwrap(), value); + section.push(Key::try_from("k").unwrap(), Some(value.into())); let expected = expected .replace("$head", &format!("[a]{nl}", nl = section.newline())) .replace("$nl", §ion.newline().to_string()); @@ -163,7 +203,7 @@ mod set_leading_whitespace { let nl = section.newline().to_owned(); section.set_leading_whitespace(Some(Cow::Owned(BString::from(format!("{nl}\t"))))); - section.push(Key::try_from("a")?, "v"); + section.push(Key::try_from("a")?, Some("v".into())); assert_eq!(config.to_string(), format!("[core]{nl}{nl}\ta = v{nl}")); Ok(()) diff --git a/git-config/tests/values/boolean.rs b/git-config/tests/values/boolean.rs index 61488748b1d..8a3bb80f140 100644 --- a/git-config/tests/values/boolean.rs +++ b/git-config/tests/values/boolean.rs @@ -10,6 +10,7 @@ fn from_str_false() -> crate::Result { assert!(!Boolean::try_from(b("off"))?.0); assert!(!Boolean::try_from(b("false"))?.0); assert!(!Boolean::try_from(b("0"))?.0); + assert!(!Boolean::try_from(b(""))?.0); Ok(()) } @@ -18,7 +19,6 @@ fn from_str_true() -> crate::Result { assert_eq!(Boolean::try_from(b("yes")).map(Into::into), Ok(true)); assert_eq!(Boolean::try_from(b("on")), Ok(Boolean(true))); assert_eq!(Boolean::try_from(b("true")), Ok(Boolean(true))); - assert_eq!(Boolean::try_from(b("")).map(|b| b.is_true()), Ok(true)); assert!(Boolean::try_from(b("1"))?.0); assert!(Boolean::try_from(b("+10"))?.0); assert!(Boolean::try_from(b("-1"))?.0); diff --git a/git-credentials/src/helper.rs b/git-credentials/src/helper.rs index 003e8c69a4e..6b4cdc0742e 100644 --- a/git-credentials/src/helper.rs +++ b/git-credentials/src/helper.rs @@ -1,3 +1,4 @@ +use bstr::{BStr, BString}; use std::{ io::{self, Write}, process::{Command, Stdio}, @@ -31,11 +32,11 @@ quick_error! { #[derive(Clone, Debug)] pub enum Action<'a> { /// Provide credentials using the given repository URL (as &str) as context. - Fill(&'a str), - /// Approve the credentials as identified by the previous input as `Vec`. - Approve(Vec), - /// Reject the credentials as identified by the previous input as `Vec`. - Reject(Vec), + Fill(&'a BStr), + /// Approve the credentials as identified by the previous input provided as `BString`. + Approve(BString), + /// Reject the credentials as identified by the previous input provided as `BString`. + Reject(BString), } impl<'a> Action<'a> { @@ -54,7 +55,7 @@ impl<'a> Action<'a> { /// A handle to [approve][NextAction::approve()] or [reject][NextAction::reject()] the outcome of the initial action. #[derive(Clone, Debug)] pub struct NextAction { - previous_output: Vec, + previous_output: BString, } impl NextAction { @@ -124,20 +125,20 @@ pub fn action(action: Action<'_>) -> Result { password: find("password")?, }, next: NextAction { - previous_output: stdout, + previous_output: stdout.into(), }, })) } } /// Encode `url` to `out` for consumption by a `git credentials` helper program. -pub fn encode_message(url: &str, mut out: impl io::Write) -> io::Result<()> { +pub fn encode_message(url: &BStr, mut out: impl io::Write) -> io::Result<()> { validate(url)?; writeln!(out, "url={}\n", url) } -fn validate(url: &str) -> io::Result<()> { - if url.contains('\u{0}') || url.contains('\n') { +fn validate(url: &BStr) -> io::Result<()> { + if url.contains(&0) || url.contains(&b'\n') { return Err(io::Error::new( io::ErrorKind::Other, "token to encode must not contain newlines or null bytes", @@ -155,7 +156,9 @@ pub fn decode_message(mut input: impl io::Read) -> io::Result>, + /// The server capabilities. + pub capabilities: Capabilities, +} + +mod error { + use crate::credentials; + use crate::fetch::refs; + use git_transport::client; + + /// The error returned by [`handshake()`][crate::fetch::handshake()]. + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error(transparent)] + Credentials(#[from] credentials::helper::Error), + #[error(transparent)] + Transport(#[from] client::Error), + #[error("The transport didn't accept the advertised server version {actual_version:?} and closed the connection client side")] + TransportProtocolPolicyViolation { actual_version: git_transport::Protocol }, + #[error(transparent)] + ParseRefs(#[from] refs::parse::Error), + } +} +pub use error::Error; + +pub(crate) mod function { + use super::{Error, Outcome}; + use crate::credentials; + use crate::fetch::refs; + use git_features::progress; + use git_features::progress::Progress; + use git_transport::client::SetServiceResponse; + use git_transport::{client, Service}; + use maybe_async::maybe_async; + + /// Perform a handshake with the server on the other side of `transport`, with `authenticate` being used if authentication + /// turns out to be required. `extra_parameters` are the parameters `(name, optional value)` to add to the handshake, + /// each time it is performed in case authentication is required. + /// `progress` is used to inform about what's currently happening. + #[maybe_async] + pub async fn handshake( + mut transport: T, + mut authenticate: AuthFn, + extra_parameters: Vec<(String, Option)>, + progress: &mut impl Progress, + ) -> Result + where + AuthFn: FnMut(credentials::helper::Action<'_>) -> credentials::helper::Result, + T: client::Transport, + { + let (server_protocol_version, refs, capabilities) = { + progress.init(None, progress::steps()); + progress.set_name("handshake"); + progress.step(); + + let extra_parameters: Vec<_> = extra_parameters + .iter() + .map(|(k, v)| (k.as_str(), v.as_ref().map(|s| s.as_str()))) + .collect(); + let supported_versions: Vec<_> = transport.supported_protocol_versions().into(); + + let result = transport.handshake(Service::UploadPack, &extra_parameters).await; + let SetServiceResponse { + actual_protocol, + capabilities, + refs, + } = match result { + Ok(v) => Ok(v), + Err(client::Error::Io { ref err }) if err.kind() == std::io::ErrorKind::PermissionDenied => { + drop(result); // needed to workaround this: https://github.com/rust-lang/rust/issues/76149 + let url = transport.to_url(); + progress.set_name("authentication"); + let credentials::helper::Outcome { identity, next } = + authenticate(credentials::helper::Action::Fill(url.as_str().into()))? + .expect("FILL provides an identity"); + transport.set_identity(identity)?; + progress.step(); + progress.set_name("handshake (authenticated)"); + match transport.handshake(Service::UploadPack, &extra_parameters).await { + Ok(v) => { + authenticate(next.approve())?; + Ok(v) + } + // Still no permission? Reject the credentials. + Err(client::Error::Io { err }) if err.kind() == std::io::ErrorKind::PermissionDenied => { + authenticate(next.reject())?; + Err(client::Error::Io { err }) + } + // Otherwise, do nothing, as we don't know if it actually got to try the credentials. + // If they were previously stored, they remain. In the worst case, the user has to enter them again + // next time they try. + Err(err) => Err(err), + } + } + Err(err) => Err(err), + }?; + + if !supported_versions.is_empty() && !supported_versions.contains(&actual_protocol) { + return Err(Error::TransportProtocolPolicyViolation { + actual_version: actual_protocol, + }); + } + + let parsed_refs = match refs { + Some(mut refs) => { + assert_eq!( + actual_protocol, + git_transport::Protocol::V1, + "Only V1 auto-responds with refs" + ); + Some( + refs::from_v1_refs_received_as_part_of_handshake_and_capabilities( + &mut refs, + capabilities.iter(), + ) + .await?, + ) + } + None => None, + }; + (actual_protocol, parsed_refs, capabilities) + }; // this scope is needed, see https://github.com/rust-lang/rust/issues/76149 + + Ok(Outcome { + server_protocol_version, + refs, + capabilities, + }) + } +} diff --git a/git-protocol/src/fetch/mod.rs b/git-protocol/src/fetch/mod.rs index a65e497bd52..effa154c6c1 100644 --- a/git-protocol/src/fetch/mod.rs +++ b/git-protocol/src/fetch/mod.rs @@ -20,10 +20,33 @@ mod error; pub use error::Error; /// pub mod refs; +pub use refs::function::refs; pub use refs::Ref; /// pub mod response; pub use response::Response; +/// +pub mod handshake; +pub use handshake::function::handshake; + +/// Send a message to indicate the remote side that there is nothing more to expect from us, indicating a graceful shutdown. +#[maybe_async::maybe_async] +pub async fn indicate_end_of_interaction( + mut transport: impl git_transport::client::Transport, +) -> Result<(), git_transport::client::Error> { + // An empty request marks the (early) end of the interaction. Only relevant in stateful transports though. + if transport.connection_persists_across_multiple_requests() { + transport + .request( + git_transport::client::WriteMode::Binary, + git_transport::client::MessageKind::Flush, + )? + .into_read() + .await?; + } + Ok(()) +} + #[cfg(test)] mod tests; diff --git a/git-protocol/src/fetch/refs.rs b/git-protocol/src/fetch/refs.rs deleted file mode 100644 index 989759d43c1..00000000000 --- a/git-protocol/src/fetch/refs.rs +++ /dev/null @@ -1,391 +0,0 @@ -use std::io; - -use bstr::BString; -use quick_error::quick_error; - -quick_error! { - /// The error returned when parsing References/refs from the server response. - #[derive(Debug)] - #[allow(missing_docs)] - pub enum Error { - Io(err: io::Error) { - display("An IO error occurred while reading refs from the server") - from() - source(err) - } - Id(err: git_hash::decode::Error) { - display("Failed to hex-decode object hash") - from() - source(err) - } - MalformedSymref(symref: BString) { - display("'{}' could not be parsed. A symref is expected to look like :.", symref) - } - MalformedV1RefLine(line: String) { - display("'{}' could not be parsed. A V1 ref line should be ' '.", line) - } - MalformedV2RefLine(line: String) { - display("'{}' could not be parsed. A V2 ref line should be ' [ (peeled|symref-target):'.", line) - } - UnkownAttribute(attribute: String, line: String) { - display("The ref attribute '{}' is unknown. Found in line '{}'", attribute, line) - } - InvariantViolation(message: &'static str) { - display("{}", message) - } - } -} - -/// A git reference, commonly referred to as 'ref', as returned by a git server before sending a pack. -#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)] -#[cfg_attr(feature = "serde1", derive(serde::Serialize, serde::Deserialize))] -pub enum Ref { - /// A ref pointing to a `tag` object, which in turns points to an `object`, usually a commit - Peeled { - /// The path at which the ref is located, like `/refs/heads/main`. - path: BString, - /// The hash of the tag the ref points to. - tag: git_hash::ObjectId, - /// The hash of the object the `tag` points to. - object: git_hash::ObjectId, - }, - /// A ref pointing to a commit object - Direct { - /// The path at which the ref is located, like `/refs/heads/main`. - path: BString, - /// The hash of the object the ref points to. - object: git_hash::ObjectId, - }, - /// A symbolic ref pointing to `target` ref, which in turn points to an `object` - Symbolic { - /// The path at which the symbolic ref is located, like `/refs/heads/main`. - path: BString, - /// The path of the ref the symbolic ref points to, see issue [#205] for details - /// - /// [#205]: https://github.com/Byron/gitoxide/issues/205 - target: BString, - /// The hash of the object the `target` ref points to. - object: git_hash::ObjectId, - }, -} - -impl Ref { - /// Provide shared fields referring to the ref itself, namely `(path, object id)`. - /// In case of peeled refs, the tag object itself is returned as it is what the path refers to. - pub fn unpack(&self) -> (&BString, &git_hash::ObjectId) { - match self { - Ref::Direct { path, object, .. } - | Ref::Peeled { path, tag: object, .. } // the tag acts as reference - | Ref::Symbolic { path, object, .. } => (path, object), - } - } -} - -#[cfg(any(feature = "blocking-client", feature = "async-client"))] -pub(crate) mod shared { - use bstr::{BString, ByteSlice}; - - use crate::fetch::{refs, Ref}; - - impl From for Ref { - fn from(v: InternalRef) -> Self { - match v { - InternalRef::Symbolic { - path, - target: Some(target), - object, - } => Ref::Symbolic { path, target, object }, - InternalRef::Symbolic { - path, - target: None, - object, - } => Ref::Direct { path, object }, - InternalRef::Peeled { path, tag, object } => Ref::Peeled { path, tag, object }, - InternalRef::Direct { path, object } => Ref::Direct { path, object }, - InternalRef::SymbolicForLookup { .. } => { - unreachable!("this case should have been removed during processing") - } - } - } - } - - #[cfg_attr(test, derive(PartialEq, Eq, Debug, Clone))] - pub(crate) enum InternalRef { - /// A ref pointing to a `tag` object, which in turns points to an `object`, usually a commit - Peeled { - path: BString, - tag: git_hash::ObjectId, - object: git_hash::ObjectId, - }, - /// A ref pointing to a commit object - Direct { path: BString, object: git_hash::ObjectId }, - /// A symbolic ref pointing to `target` ref, which in turn points to an `object` - Symbolic { - path: BString, - /// It is `None` if the target is unreachable as it points to another namespace than the one is currently set - /// on the server (i.e. based on the repository at hand or the user performing the operation). - /// - /// The latter is more of an edge case, please [this issue][#205] for details. - target: Option, - object: git_hash::ObjectId, - }, - /// extracted from V1 capabilities, which contain some important symbolic refs along with their targets - /// These don't contain the Id - SymbolicForLookup { path: BString, target: Option }, - } - - impl InternalRef { - fn unpack_direct(self) -> Option<(BString, git_hash::ObjectId)> { - match self { - InternalRef::Direct { path, object } => Some((path, object)), - _ => None, - } - } - fn lookup_symbol_has_path(&self, predicate_path: &str) -> bool { - matches!(self, InternalRef::SymbolicForLookup { path, .. } if path == predicate_path) - } - } - - pub(crate) fn from_capabilities<'a>( - capabilities: impl Iterator>, - ) -> Result, refs::Error> { - let mut out_refs = Vec::new(); - let symref_values = capabilities.filter_map(|c| { - if c.name() == b"symref".as_bstr() { - c.value().map(ToOwned::to_owned) - } else { - None - } - }); - for symref in symref_values { - let (left, right) = symref.split_at( - symref - .find_byte(b':') - .ok_or_else(|| refs::Error::MalformedSymref(symref.to_owned()))?, - ); - if left.is_empty() || right.is_empty() { - return Err(refs::Error::MalformedSymref(symref.to_owned())); - } - out_refs.push(InternalRef::SymbolicForLookup { - path: left.into(), - target: match &right[1..] { - b"(null)" => None, - name => Some(name.into()), - }, - }) - } - Ok(out_refs) - } - - pub(in crate::fetch::refs) fn parse_v1( - num_initial_out_refs: usize, - out_refs: &mut Vec, - line: &str, - ) -> Result<(), refs::Error> { - let trimmed = line.trim_end(); - let (hex_hash, path) = trimmed.split_at( - trimmed - .find(' ') - .ok_or_else(|| refs::Error::MalformedV1RefLine(trimmed.to_owned()))?, - ); - let path = &path[1..]; - if path.is_empty() { - return Err(refs::Error::MalformedV1RefLine(trimmed.to_owned())); - } - match path.strip_suffix("^{}") { - Some(stripped) => { - let (previous_path, tag) = - out_refs - .pop() - .and_then(InternalRef::unpack_direct) - .ok_or(refs::Error::InvariantViolation( - "Expecting peeled refs to be preceded by direct refs", - ))?; - if previous_path != stripped { - return Err(refs::Error::InvariantViolation( - "Expecting peeled refs to have the same base path as the previous, unpeeled one", - )); - } - out_refs.push(InternalRef::Peeled { - path: previous_path, - tag, - object: git_hash::ObjectId::from_hex(hex_hash.as_bytes())?, - }); - } - None => { - let object = git_hash::ObjectId::from_hex(hex_hash.as_bytes())?; - match out_refs - .iter() - .take(num_initial_out_refs) - .position(|r| r.lookup_symbol_has_path(path)) - { - Some(position) => match out_refs.swap_remove(position) { - InternalRef::SymbolicForLookup { path: _, target } => out_refs.push(InternalRef::Symbolic { - path: path.into(), - object, - target, - }), - _ => unreachable!("Bug in lookup_symbol_has_path - must return lookup symbols"), - }, - None => out_refs.push(InternalRef::Direct { - object, - path: path.into(), - }), - }; - } - } - Ok(()) - } - - pub(in crate::fetch::refs) fn parse_v2(line: &str) -> Result { - let trimmed = line.trim_end(); - let mut tokens = trimmed.splitn(3, ' '); - match (tokens.next(), tokens.next()) { - (Some(hex_hash), Some(path)) => { - let id = git_hash::ObjectId::from_hex(hex_hash.as_bytes())?; - if path.is_empty() { - return Err(refs::Error::MalformedV2RefLine(trimmed.to_owned())); - } - Ok(if let Some(attribute) = tokens.next() { - let mut tokens = attribute.splitn(2, ':'); - match (tokens.next(), tokens.next()) { - (Some(attribute), Some(value)) => { - if value.is_empty() { - return Err(refs::Error::MalformedV2RefLine(trimmed.to_owned())); - } - match attribute { - "peeled" => Ref::Peeled { - path: path.into(), - object: git_hash::ObjectId::from_hex(value.as_bytes())?, - tag: id, - }, - "symref-target" => match value { - "(null)" => Ref::Direct { - path: path.into(), - object: id, - }, - name => Ref::Symbolic { - path: path.into(), - object: id, - target: name.into(), - }, - }, - _ => { - return Err(refs::Error::UnkownAttribute(attribute.to_owned(), trimmed.to_owned())) - } - } - } - _ => return Err(refs::Error::MalformedV2RefLine(trimmed.to_owned())), - } - } else { - Ref::Direct { - object: id, - path: path.into(), - } - }) - } - _ => Err(refs::Error::MalformedV2RefLine(trimmed.to_owned())), - } - } -} - -#[cfg(feature = "async-client")] -mod async_io { - use futures_io::AsyncBufRead; - use futures_lite::AsyncBufReadExt; - - use crate::fetch::{refs, Ref}; - - /// Parse refs from the given input line by line. Protocol V2 is required for this to succeed. - pub async fn from_v2_refs(in_refs: &mut (dyn AsyncBufRead + Unpin)) -> Result, refs::Error> { - let mut out_refs = Vec::new(); - let mut line = String::new(); - loop { - line.clear(); - let bytes_read = in_refs.read_line(&mut line).await?; - if bytes_read == 0 { - break; - } - out_refs.push(refs::shared::parse_v2(&line)?); - } - Ok(out_refs) - } - - /// Parse refs from the return stream of the handshake as well as the server capabilities, also received as part of the - /// handshake. - /// Together they form a complete set of refs. - /// - /// # Note - /// - /// Symbolic refs are shoe-horned into server capabilities whereas refs (without symbolic ones) are sent automatically as - /// part of the handshake. Both symbolic and peeled refs need to be combined to fit into the [`Ref`] type provided here. - pub async fn from_v1_refs_received_as_part_of_handshake_and_capabilities<'a>( - in_refs: &mut (dyn AsyncBufRead + Unpin), - capabilities: impl Iterator>, - ) -> Result, refs::Error> { - let mut out_refs = refs::shared::from_capabilities(capabilities)?; - let number_of_possible_symbolic_refs_for_lookup = out_refs.len(); - let mut line = String::new(); - loop { - line.clear(); - let bytes_read = in_refs.read_line(&mut line).await?; - if bytes_read == 0 { - break; - } - refs::shared::parse_v1(number_of_possible_symbolic_refs_for_lookup, &mut out_refs, &line)?; - } - Ok(out_refs.into_iter().map(Into::into).collect()) - } -} -#[cfg(feature = "async-client")] -pub use async_io::{from_v1_refs_received_as_part_of_handshake_and_capabilities, from_v2_refs}; - -#[cfg(feature = "blocking-client")] -mod blocking_io { - use std::io; - - use crate::fetch::{refs, Ref}; - - /// Parse refs from the given input line by line. Protocol V2 is required for this to succeed. - pub fn from_v2_refs(in_refs: &mut dyn io::BufRead) -> Result, refs::Error> { - let mut out_refs = Vec::new(); - let mut line = String::new(); - loop { - line.clear(); - let bytes_read = in_refs.read_line(&mut line)?; - if bytes_read == 0 { - break; - } - out_refs.push(refs::shared::parse_v2(&line)?); - } - Ok(out_refs) - } - - /// Parse refs from the return stream of the handshake as well as the server capabilities, also received as part of the - /// handshake. - /// Together they form a complete set of refs. - /// - /// # Note - /// - /// Symbolic refs are shoe-horned into server capabilities whereas refs (without symbolic ones) are sent automatically as - /// part of the handshake. Both symbolic and peeled refs need to be combined to fit into the [`Ref`] type provided here. - pub fn from_v1_refs_received_as_part_of_handshake_and_capabilities<'a>( - in_refs: &mut dyn io::BufRead, - capabilities: impl Iterator>, - ) -> Result, refs::Error> { - let mut out_refs = refs::shared::from_capabilities(capabilities)?; - let number_of_possible_symbolic_refs_for_lookup = out_refs.len(); - let mut line = String::new(); - loop { - line.clear(); - let bytes_read = in_refs.read_line(&mut line)?; - if bytes_read == 0 { - break; - } - refs::shared::parse_v1(number_of_possible_symbolic_refs_for_lookup, &mut out_refs, &line)?; - } - Ok(out_refs.into_iter().map(Into::into).collect()) - } -} -#[cfg(feature = "blocking-client")] -pub use blocking_io::{from_v1_refs_received_as_part_of_handshake_and_capabilities, from_v2_refs}; diff --git a/git-protocol/src/fetch/refs/async_io.rs b/git-protocol/src/fetch/refs/async_io.rs new file mode 100644 index 00000000000..3fa1a99ce1b --- /dev/null +++ b/git-protocol/src/fetch/refs/async_io.rs @@ -0,0 +1,45 @@ +use futures_io::AsyncBufRead; +use futures_lite::AsyncBufReadExt; + +use crate::fetch::{refs, refs::parse::Error, Ref}; + +/// Parse refs from the given input line by line. Protocol V2 is required for this to succeed. +pub async fn from_v2_refs(in_refs: &mut (dyn AsyncBufRead + Unpin)) -> Result, Error> { + let mut out_refs = Vec::new(); + let mut line = String::new(); + loop { + line.clear(); + let bytes_read = in_refs.read_line(&mut line).await?; + if bytes_read == 0 { + break; + } + out_refs.push(refs::shared::parse_v2(&line)?); + } + Ok(out_refs) +} + +/// Parse refs from the return stream of the handshake as well as the server capabilities, also received as part of the +/// handshake. +/// Together they form a complete set of refs. +/// +/// # Note +/// +/// Symbolic refs are shoe-horned into server capabilities whereas refs (without symbolic ones) are sent automatically as +/// part of the handshake. Both symbolic and peeled refs need to be combined to fit into the [`Ref`] type provided here. +pub async fn from_v1_refs_received_as_part_of_handshake_and_capabilities<'a>( + in_refs: &mut (dyn AsyncBufRead + Unpin), + capabilities: impl Iterator>, +) -> Result, refs::parse::Error> { + let mut out_refs = refs::shared::from_capabilities(capabilities)?; + let number_of_possible_symbolic_refs_for_lookup = out_refs.len(); + let mut line = String::new(); + loop { + line.clear(); + let bytes_read = in_refs.read_line(&mut line).await?; + if bytes_read == 0 { + break; + } + refs::shared::parse_v1(number_of_possible_symbolic_refs_for_lookup, &mut out_refs, &line)?; + } + Ok(out_refs.into_iter().map(Into::into).collect()) +} diff --git a/git-protocol/src/fetch/refs/blocking_io.rs b/git-protocol/src/fetch/refs/blocking_io.rs new file mode 100644 index 00000000000..af2130bdc0c --- /dev/null +++ b/git-protocol/src/fetch/refs/blocking_io.rs @@ -0,0 +1,45 @@ +use std::io; + +use crate::fetch::refs::parse::Error; +use crate::fetch::{refs, Ref}; + +/// Parse refs from the given input line by line. Protocol V2 is required for this to succeed. +pub fn from_v2_refs(in_refs: &mut dyn io::BufRead) -> Result, Error> { + let mut out_refs = Vec::new(); + let mut line = String::new(); + loop { + line.clear(); + let bytes_read = in_refs.read_line(&mut line)?; + if bytes_read == 0 { + break; + } + out_refs.push(refs::shared::parse_v2(&line)?); + } + Ok(out_refs) +} + +/// Parse refs from the return stream of the handshake as well as the server capabilities, also received as part of the +/// handshake. +/// Together they form a complete set of refs. +/// +/// # Note +/// +/// Symbolic refs are shoe-horned into server capabilities whereas refs (without symbolic ones) are sent automatically as +/// part of the handshake. Both symbolic and peeled refs need to be combined to fit into the [`Ref`] type provided here. +pub fn from_v1_refs_received_as_part_of_handshake_and_capabilities<'a>( + in_refs: &mut dyn io::BufRead, + capabilities: impl Iterator>, +) -> Result, Error> { + let mut out_refs = refs::shared::from_capabilities(capabilities)?; + let number_of_possible_symbolic_refs_for_lookup = out_refs.len(); + let mut line = String::new(); + loop { + line.clear(); + let bytes_read = in_refs.read_line(&mut line)?; + if bytes_read == 0 { + break; + } + refs::shared::parse_v1(number_of_possible_symbolic_refs_for_lookup, &mut out_refs, &line)?; + } + Ok(out_refs.into_iter().map(Into::into).collect()) +} diff --git a/git-protocol/src/fetch/refs/function.rs b/git-protocol/src/fetch/refs/function.rs new file mode 100644 index 00000000000..5e2d979f187 --- /dev/null +++ b/git-protocol/src/fetch/refs/function.rs @@ -0,0 +1,59 @@ +use super::Error; +use crate::fetch::refs::from_v2_refs; +use crate::fetch::{indicate_end_of_interaction, Command, LsRefsAction, Ref}; +use bstr::BString; +use git_features::progress::Progress; +use git_transport::client::{Capabilities, Transport, TransportV2Ext}; +use git_transport::Protocol; +use maybe_async::maybe_async; + +/// Invoke an ls-refs command on `transport` (assuming `protocol_version` 2 or panic), which requires a prior handshake that yielded +/// server `capabilities`. `prepare_ls_refs(arguments, features)` can be used to alter the _ls-refs_. `progress` is used to provide feedback. +#[maybe_async] +pub async fn refs( + mut transport: impl Transport, + protocol_version: Protocol, + capabilities: &Capabilities, + mut prepare_ls_refs: impl FnMut( + &Capabilities, + &mut Vec, + &mut Vec<(&str, Option<&str>)>, + ) -> std::io::Result, + progress: &mut impl Progress, +) -> Result, Error> { + assert_eq!( + protocol_version, + Protocol::V2, + "Only V2 needs a separate request to get specific refs" + ); + + let ls_refs = Command::LsRefs; + let mut ls_features = ls_refs.default_features(protocol_version, capabilities); + let mut ls_args = ls_refs.initial_arguments(&ls_features); + let refs = match prepare_ls_refs(capabilities, &mut ls_args, &mut ls_features) { + Ok(LsRefsAction::Skip) => Vec::new(), + Ok(LsRefsAction::Continue) => { + ls_refs.validate_argument_prefixes_or_panic(protocol_version, capabilities, &ls_args, &ls_features); + + progress.step(); + progress.set_name("list refs"); + let mut remote_refs = transport + .invoke( + ls_refs.as_str(), + ls_features.into_iter(), + if ls_args.is_empty() { + None + } else { + Some(ls_args.into_iter()) + }, + ) + .await?; + from_v2_refs(&mut remote_refs).await? + } + Err(err) => { + indicate_end_of_interaction(transport).await?; + return Err(err.into()); + } + }; + Ok(refs) +} diff --git a/git-protocol/src/fetch/refs/mod.rs b/git-protocol/src/fetch/refs/mod.rs new file mode 100644 index 00000000000..a847d6107a4 --- /dev/null +++ b/git-protocol/src/fetch/refs/mod.rs @@ -0,0 +1,105 @@ +use bstr::BString; + +mod error { + use crate::fetch::refs::parse; + + /// The error returned by [refs()][crate::fetch::refs()]. + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Transport(#[from] git_transport::client::Error), + #[error(transparent)] + Parse(#[from] parse::Error), + } +} +pub use error::Error; + +/// +pub mod parse { + use bstr::BString; + + /// The error returned when parsing References/refs from the server response. + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Id(#[from] git_hash::decode::Error), + #[error("{symref:?} could not be parsed. A symref is expected to look like :.")] + MalformedSymref { symref: BString }, + #[error("{0:?} could not be parsed. A V1 ref line should be ' '.")] + MalformedV1RefLine(String), + #[error( + "{0:?} could not be parsed. A V2 ref line should be ' [ (peeled|symref-target):'." + )] + MalformedV2RefLine(String), + #[error("The ref attribute {attribute:?} is unknown. Found in line {line:?}")] + UnkownAttribute { attribute: String, line: String }, + #[error("{message}")] + InvariantViolation { message: &'static str }, + } +} + +/// A git reference, commonly referred to as 'ref', as returned by a git server before sending a pack. +#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)] +#[cfg_attr(feature = "serde1", derive(serde::Serialize, serde::Deserialize))] +pub enum Ref { + /// A ref pointing to a `tag` object, which in turns points to an `object`, usually a commit + Peeled { + /// The path at which the ref is located, like `/refs/heads/main`. + path: BString, + /// The hash of the tag the ref points to. + tag: git_hash::ObjectId, + /// The hash of the object the `tag` points to. + object: git_hash::ObjectId, + }, + /// A ref pointing to a commit object + Direct { + /// The path at which the ref is located, like `/refs/heads/main`. + path: BString, + /// The hash of the object the ref points to. + object: git_hash::ObjectId, + }, + /// A symbolic ref pointing to `target` ref, which in turn points to an `object` + Symbolic { + /// The path at which the symbolic ref is located, like `/refs/heads/main`. + path: BString, + /// The path of the ref the symbolic ref points to, see issue [#205] for details + /// + /// [#205]: https://github.com/Byron/gitoxide/issues/205 + target: BString, + /// The hash of the object the `target` ref points to. + object: git_hash::ObjectId, + }, +} + +impl Ref { + /// Provide shared fields referring to the ref itself, namely `(path, object id)`. + /// In case of peeled refs, the tag object itself is returned as it is what the path refers to. + pub fn unpack(&self) -> (&BString, &git_hash::ObjectId) { + match self { + Ref::Direct { path, object, .. } + | Ref::Peeled { path, tag: object, .. } // the tag acts as reference + | Ref::Symbolic { path, object, .. } => (path, object), + } + } +} + +pub(crate) mod function; + +#[cfg(any(feature = "blocking-client", feature = "async-client"))] +pub(crate) mod shared; + +#[cfg(feature = "async-client")] +mod async_io; +#[cfg(feature = "async-client")] +pub use async_io::{from_v1_refs_received_as_part_of_handshake_and_capabilities, from_v2_refs}; + +#[cfg(feature = "blocking-client")] +mod blocking_io; +#[cfg(feature = "blocking-client")] +pub use blocking_io::{from_v1_refs_received_as_part_of_handshake_and_capabilities, from_v2_refs}; diff --git a/git-protocol/src/fetch/refs/shared.rs b/git-protocol/src/fetch/refs/shared.rs new file mode 100644 index 00000000000..46a8ad53620 --- /dev/null +++ b/git-protocol/src/fetch/refs/shared.rs @@ -0,0 +1,207 @@ +use bstr::{BString, ByteSlice}; + +use crate::fetch::{refs::parse::Error, Ref}; + +impl From for Ref { + fn from(v: InternalRef) -> Self { + match v { + InternalRef::Symbolic { + path, + target: Some(target), + object, + } => Ref::Symbolic { path, target, object }, + InternalRef::Symbolic { + path, + target: None, + object, + } => Ref::Direct { path, object }, + InternalRef::Peeled { path, tag, object } => Ref::Peeled { path, tag, object }, + InternalRef::Direct { path, object } => Ref::Direct { path, object }, + InternalRef::SymbolicForLookup { .. } => { + unreachable!("this case should have been removed during processing") + } + } + } +} + +#[cfg_attr(test, derive(PartialEq, Eq, Debug, Clone))] +pub(crate) enum InternalRef { + /// A ref pointing to a `tag` object, which in turns points to an `object`, usually a commit + Peeled { + path: BString, + tag: git_hash::ObjectId, + object: git_hash::ObjectId, + }, + /// A ref pointing to a commit object + Direct { path: BString, object: git_hash::ObjectId }, + /// A symbolic ref pointing to `target` ref, which in turn points to an `object` + Symbolic { + path: BString, + /// It is `None` if the target is unreachable as it points to another namespace than the one is currently set + /// on the server (i.e. based on the repository at hand or the user performing the operation). + /// + /// The latter is more of an edge case, please [this issue][#205] for details. + target: Option, + object: git_hash::ObjectId, + }, + /// extracted from V1 capabilities, which contain some important symbolic refs along with their targets + /// These don't contain the Id + SymbolicForLookup { path: BString, target: Option }, +} + +impl InternalRef { + fn unpack_direct(self) -> Option<(BString, git_hash::ObjectId)> { + match self { + InternalRef::Direct { path, object } => Some((path, object)), + _ => None, + } + } + fn lookup_symbol_has_path(&self, predicate_path: &str) -> bool { + matches!(self, InternalRef::SymbolicForLookup { path, .. } if path == predicate_path) + } +} + +pub(crate) fn from_capabilities<'a>( + capabilities: impl Iterator>, +) -> Result, Error> { + let mut out_refs = Vec::new(); + let symref_values = capabilities.filter_map(|c| { + if c.name() == b"symref".as_bstr() { + c.value().map(ToOwned::to_owned) + } else { + None + } + }); + for symref in symref_values { + let (left, right) = symref.split_at(symref.find_byte(b':').ok_or_else(|| Error::MalformedSymref { + symref: symref.to_owned(), + })?); + if left.is_empty() || right.is_empty() { + return Err(Error::MalformedSymref { + symref: symref.to_owned(), + }); + } + out_refs.push(InternalRef::SymbolicForLookup { + path: left.into(), + target: match &right[1..] { + b"(null)" => None, + name => Some(name.into()), + }, + }) + } + Ok(out_refs) +} + +pub(in crate::fetch::refs) fn parse_v1( + num_initial_out_refs: usize, + out_refs: &mut Vec, + line: &str, +) -> Result<(), Error> { + let trimmed = line.trim_end(); + let (hex_hash, path) = trimmed.split_at( + trimmed + .find(' ') + .ok_or_else(|| Error::MalformedV1RefLine(trimmed.to_owned()))?, + ); + let path = &path[1..]; + if path.is_empty() { + return Err(Error::MalformedV1RefLine(trimmed.to_owned())); + } + match path.strip_suffix("^{}") { + Some(stripped) => { + let (previous_path, tag) = + out_refs + .pop() + .and_then(InternalRef::unpack_direct) + .ok_or(Error::InvariantViolation { + message: "Expecting peeled refs to be preceded by direct refs", + })?; + if previous_path != stripped { + return Err(Error::InvariantViolation { + message: "Expecting peeled refs to have the same base path as the previous, unpeeled one", + }); + } + out_refs.push(InternalRef::Peeled { + path: previous_path, + tag, + object: git_hash::ObjectId::from_hex(hex_hash.as_bytes())?, + }); + } + None => { + let object = git_hash::ObjectId::from_hex(hex_hash.as_bytes())?; + match out_refs + .iter() + .take(num_initial_out_refs) + .position(|r| r.lookup_symbol_has_path(path)) + { + Some(position) => match out_refs.swap_remove(position) { + InternalRef::SymbolicForLookup { path: _, target } => out_refs.push(InternalRef::Symbolic { + path: path.into(), + object, + target, + }), + _ => unreachable!("Bug in lookup_symbol_has_path - must return lookup symbols"), + }, + None => out_refs.push(InternalRef::Direct { + object, + path: path.into(), + }), + }; + } + } + Ok(()) +} + +pub(in crate::fetch::refs) fn parse_v2(line: &str) -> Result { + let trimmed = line.trim_end(); + let mut tokens = trimmed.splitn(3, ' '); + match (tokens.next(), tokens.next()) { + (Some(hex_hash), Some(path)) => { + let id = git_hash::ObjectId::from_hex(hex_hash.as_bytes())?; + if path.is_empty() { + return Err(Error::MalformedV2RefLine(trimmed.to_owned())); + } + Ok(if let Some(attribute) = tokens.next() { + let mut tokens = attribute.splitn(2, ':'); + match (tokens.next(), tokens.next()) { + (Some(attribute), Some(value)) => { + if value.is_empty() { + return Err(Error::MalformedV2RefLine(trimmed.to_owned())); + } + match attribute { + "peeled" => Ref::Peeled { + path: path.into(), + object: git_hash::ObjectId::from_hex(value.as_bytes())?, + tag: id, + }, + "symref-target" => match value { + "(null)" => Ref::Direct { + path: path.into(), + object: id, + }, + name => Ref::Symbolic { + path: path.into(), + object: id, + target: name.into(), + }, + }, + _ => { + return Err(Error::UnkownAttribute { + attribute: attribute.to_owned(), + line: trimmed.to_owned(), + }) + } + } + } + _ => return Err(Error::MalformedV2RefLine(trimmed.to_owned())), + } + } else { + Ref::Direct { + object: id, + path: path.into(), + } + }) + } + _ => Err(Error::MalformedV2RefLine(trimmed.to_owned())), + } +} diff --git a/git-protocol/src/fetch/response/async_io.rs b/git-protocol/src/fetch/response/async_io.rs index 6472b5b67a0..4758ec17bd4 100644 --- a/git-protocol/src/fetch/response/async_io.rs +++ b/git-protocol/src/fetch/response/async_io.rs @@ -121,7 +121,7 @@ impl Response { // what follows is the packfile itself, which can be read with a sideband enabled reader break 'section true; } - _ => return Err(response::Error::UnknownSectionHeader(line)), + _ => return Err(response::Error::UnknownSectionHeader { header: line }), } }; Ok(Response { diff --git a/git-protocol/src/fetch/response/blocking_io.rs b/git-protocol/src/fetch/response/blocking_io.rs index f63143b3794..9dd5c8b09de 100644 --- a/git-protocol/src/fetch/response/blocking_io.rs +++ b/git-protocol/src/fetch/response/blocking_io.rs @@ -120,7 +120,7 @@ impl Response { // what follows is the packfile itself, which can be read with a sideband enabled reader break 'section true; } - _ => return Err(response::Error::UnknownSectionHeader(line)), + _ => return Err(response::Error::UnknownSectionHeader { header: line }), } }; Ok(Response { diff --git a/git-protocol/src/fetch/response/mod.rs b/git-protocol/src/fetch/response/mod.rs index 80c0a69066a..37e0943ecb8 100644 --- a/git-protocol/src/fetch/response/mod.rs +++ b/git-protocol/src/fetch/response/mod.rs @@ -1,50 +1,35 @@ -use std::io; - use bstr::BString; use git_transport::{client, Protocol}; -use quick_error::quick_error; use crate::fetch::command::Feature; -quick_error! { - /// The error used in the [response module][crate::fetch::response]. - #[derive(Debug)] - #[allow(missing_docs)] - pub enum Error { - Io(err: io::Error) { - display("Failed to read from line reader") - source(err) - } - UploadPack(err: git_transport::packetline::read::Error) { - display("Upload pack reported an error") - source(err) - } - Transport(err: client::Error) { - display("An error occurred when decoding a line") - from() - source(err) - } - MissingServerCapability(feature: &'static str) { - display("Currently we require feature '{}', which is not supported by the server", feature) - } - UnknownLineType(line: String) { - display("Encountered an unknown line prefix in '{}'", line) - } - UnknownSectionHeader(header: String) { - display("Unknown or unsupported header: '{}'", header) - } - } +/// The error returned in the [response module][crate::fetch::response]. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("Failed to read from line reader")] + Io(std::io::Error), + #[error(transparent)] + UploadPack(#[from] git_transport::packetline::read::Error), + #[error(transparent)] + Transport(#[from] client::Error), + #[error("Currently we require feature {feature:?}, which is not supported by the server")] + MissingServerCapability { feature: &'static str }, + #[error("Encountered an unknown line prefix in {line:?}")] + UnknownLineType { line: String }, + #[error("Unknown or unsupported header: {header:?}")] + UnknownSectionHeader { header: String }, } impl From for Error { - fn from(err: io::Error) -> Self { - if err.kind() == io::ErrorKind::Other { + fn from(err: std::io::Error) -> Self { + if err.kind() == std::io::ErrorKind::Other { match err.into_inner() { Some(err) => match err.downcast::() { Ok(err) => Error::UploadPack(*err), - Err(err) => Error::Io(io::Error::new(io::ErrorKind::Other, err)), + Err(err) => Error::Io(std::io::Error::new(std::io::ErrorKind::Other, err)), }, - None => Error::Io(io::ErrorKind::Other.into()), + None => Error::Io(std::io::ErrorKind::Other.into()), } } else { Error::Io(err) @@ -89,15 +74,15 @@ impl ShallowUpdate { pub fn from_line(line: &str) -> Result { match line.trim_end().split_once(' ') { Some((prefix, id)) => { - let id = - git_hash::ObjectId::from_hex(id.as_bytes()).map_err(|_| Error::UnknownLineType(line.to_owned()))?; + let id = git_hash::ObjectId::from_hex(id.as_bytes()) + .map_err(|_| Error::UnknownLineType { line: line.to_owned() })?; Ok(match prefix { "shallow" => ShallowUpdate::Shallow(id), "unshallow" => ShallowUpdate::Unshallow(id), - _ => return Err(Error::UnknownLineType(line.to_owned())), + _ => return Err(Error::UnknownLineType { line: line.to_owned() }), }) } - None => Err(Error::UnknownLineType(line.to_owned())), + None => Err(Error::UnknownLineType { line: line.to_owned() }), } } } @@ -113,21 +98,21 @@ impl Acknowledgement { "ACK" => { let id = match id { Some(id) => git_hash::ObjectId::from_hex(id.as_bytes()) - .map_err(|_| Error::UnknownLineType(line.to_owned()))?, - None => return Err(Error::UnknownLineType(line.to_owned())), + .map_err(|_| Error::UnknownLineType { line: line.to_owned() })?, + None => return Err(Error::UnknownLineType { line: line.to_owned() }), }; if let Some(description) = description { match description { "common" => {} "ready" => return Ok(Acknowledgement::Ready), - _ => return Err(Error::UnknownLineType(line.to_owned())), + _ => return Err(Error::UnknownLineType { line: line.to_owned() }), } } Acknowledgement::Common(id) } - _ => return Err(Error::UnknownLineType(line.to_owned())), + _ => return Err(Error::UnknownLineType { line: line.to_owned() }), }), - (None, _, _) => Err(Error::UnknownLineType(line.to_owned())), + (None, _, _) => Err(Error::UnknownLineType { line: line.to_owned() }), } } /// Returns the hash of the acknowledged object if this instance acknowledges a common one. @@ -144,11 +129,11 @@ impl WantedRef { pub fn from_line(line: &str) -> Result { match line.trim_end().split_once(' ') { Some((id, path)) => { - let id = - git_hash::ObjectId::from_hex(id.as_bytes()).map_err(|_| Error::UnknownLineType(line.to_owned()))?; + let id = git_hash::ObjectId::from_hex(id.as_bytes()) + .map_err(|_| Error::UnknownLineType { line: line.to_owned() })?; Ok(WantedRef { id, path: path.into() }) } - None => Err(Error::UnknownLineType(line.to_owned())), + None => Err(Error::UnknownLineType { line: line.to_owned() }), } } } @@ -177,14 +162,18 @@ impl Response { let has = |name: &str| features.iter().any(|f| f.0 == name); // Let's focus on V2 standards, and simply not support old servers to keep our code simpler if !has("multi_ack_detailed") { - return Err(Error::MissingServerCapability("multi_ack_detailed")); + return Err(Error::MissingServerCapability { + feature: "multi_ack_detailed", + }); } // It's easy to NOT do sideband for us, but then again, everyone supports it. // CORRECTION: If side-band is off, it would send the packfile without packet line encoding, // which is nothing we ever want to deal with (despite it being more efficient). In V2, this // is not even an option anymore, sidebands are always present. if !has("side-band") && !has("side-band-64k") { - return Err(Error::MissingServerCapability("side-band OR side-band-64k")); + return Err(Error::MissingServerCapability { + feature: "side-band OR side-band-64k", + }); } } Protocol::V2 => {} diff --git a/git-protocol/src/fetch_fn.rs b/git-protocol/src/fetch_fn.rs index a0597341257..3558fad142f 100644 --- a/git-protocol/src/fetch_fn.rs +++ b/git-protocol/src/fetch_fn.rs @@ -1,16 +1,11 @@ -use std::io; - -use git_features::{progress, progress::Progress}; -use git_transport::{ - client, - client::{SetServiceResponse, TransportV2Ext}, - Service, -}; +use git_features::progress::Progress; +use git_transport::client; use maybe_async::maybe_async; +use crate::fetch::{handshake, indicate_end_of_interaction}; use crate::{ credentials, - fetch::{refs, Action, Arguments, Command, Delegate, Error, LsRefsAction, Response}, + fetch::{Action, Arguments, Command, Delegate, Error, Response}, }; /// A way to indicate how to treat the connection underlying the transport, potentially allowing to reuse it. @@ -21,10 +16,10 @@ pub enum FetchConnection { /// When indicating the end-of-fetch, this flag is only relevant in protocol V2. /// Generally it only applies when using persistent transports. /// - /// In most explicit client side failures modes the end-of-operation' notification will be sent to the server automatically. + /// In most explicit client side failure modes the end-of-operation' notification will be sent to the server automatically. TerminateOnSuccessfulCompletion, - /// Indicate that persistent transport connections can be reused by not sending an 'end-of-operation' notification to the server. + /// Indicate that persistent transport connections can be reused by _not_ sending an 'end-of-operation' notification to the server. /// This is useful if multiple `fetch(…)` calls are used in succession. /// /// Note that this has no effect in case of non-persistent connections, like the ones over HTTP. @@ -53,7 +48,7 @@ impl Default for FetchConnection { pub async fn fetch( mut transport: T, mut delegate: D, - mut authenticate: F, + authenticate: F, mut progress: impl Progress, fetch_mode: FetchConnection, ) -> Result<(), Error> @@ -62,129 +57,40 @@ where D: Delegate, T: client::Transport, { - let (protocol_version, parsed_refs, capabilities) = { - progress.init(None, progress::steps()); - progress.set_name("handshake"); - progress.step(); - - let extra_parameters = delegate.handshake_extra_parameters(); - let extra_parameters: Vec<_> = extra_parameters - .iter() - .map(|(k, v)| (k.as_str(), v.as_ref().map(|s| s.as_str()))) - .collect(); - let supported_versions: Vec<_> = transport.supported_protocol_versions().into(); - - let result = transport.handshake(Service::UploadPack, &extra_parameters).await; - let SetServiceResponse { - actual_protocol, - capabilities, - refs, - } = match result { - Ok(v) => Ok(v), - Err(client::Error::Io { ref err }) if err.kind() == io::ErrorKind::PermissionDenied => { - drop(result); // needed to workaround this: https://github.com/rust-lang/rust/issues/76149 - let url = transport.to_url(); - progress.set_name("authentication"); - let credentials::helper::Outcome { identity, next } = - authenticate(credentials::helper::Action::Fill(&url))?.expect("FILL provides an identity"); - transport.set_identity(identity)?; - progress.step(); - progress.set_name("handshake (authenticated)"); - match transport.handshake(Service::UploadPack, &extra_parameters).await { - Ok(v) => { - authenticate(next.approve())?; - Ok(v) - } - // Still no permission? Reject the credentials. - Err(client::Error::Io { err }) if err.kind() == io::ErrorKind::PermissionDenied => { - authenticate(next.reject())?; - Err(client::Error::Io { err }) - } - // Otherwise, do nothing, as we don't know if it actually got to try the credentials. - // If they were previously stored, they remain. In the worst case, the user has to enter them again - // next time they try. - Err(err) => Err(err), - } - } - Err(err) => Err(err), - }?; - - if !supported_versions.is_empty() && !supported_versions.contains(&actual_protocol) { - return Err(Error::TransportProtocolPolicyViolation { - actual_version: actual_protocol, - }); - } - - let parsed_refs = match refs { - Some(mut refs) => { - assert_eq!( - actual_protocol, - git_transport::Protocol::V1, - "Only V1 auto-responds with refs" - ); - Some( - refs::from_v1_refs_received_as_part_of_handshake_and_capabilities(&mut refs, capabilities.iter()) - .await?, - ) - } - None => None, - }; - (actual_protocol, parsed_refs, capabilities) - }; // this scope is needed, see https://github.com/rust-lang/rust/issues/76149 - - let parsed_refs = match parsed_refs { + let handshake::Outcome { + server_protocol_version: protocol_version, + refs, + capabilities, + } = crate::fetch::handshake( + &mut transport, + authenticate, + delegate.handshake_extra_parameters(), + &mut progress, + ) + .await?; + + let refs = match refs { Some(refs) => refs, None => { - assert_eq!( + crate::fetch::refs( + &mut transport, protocol_version, - git_transport::Protocol::V2, - "Only V2 needs a separate request to get specific refs" - ); - - let ls_refs = Command::LsRefs; - let mut ls_features = ls_refs.default_features(protocol_version, &capabilities); - let mut ls_args = ls_refs.initial_arguments(&ls_features); - match delegate.prepare_ls_refs(&capabilities, &mut ls_args, &mut ls_features) { - Ok(LsRefsAction::Skip) => Vec::new(), - Ok(LsRefsAction::Continue) => { - ls_refs.validate_argument_prefixes_or_panic( - protocol_version, - &capabilities, - &ls_args, - &ls_features, - ); - - progress.step(); - progress.set_name("list refs"); - let mut remote_refs = transport - .invoke( - ls_refs.as_str(), - ls_features.into_iter(), - if ls_args.is_empty() { - None - } else { - Some(ls_args.into_iter()) - }, - ) - .await?; - refs::from_v2_refs(&mut remote_refs).await? - } - Err(err) => { - indicate_end_of_interaction(transport).await?; - return Err(err.into()); - } - } + &capabilities, + |a, b, c| delegate.prepare_ls_refs(a, b, c), + &mut progress, + ) + .await? } }; let fetch = Command::Fetch; let mut fetch_features = fetch.default_features(protocol_version, &capabilities); - match delegate.prepare_fetch(protocol_version, &capabilities, &mut fetch_features, &parsed_refs) { + match delegate.prepare_fetch(protocol_version, &capabilities, &mut fetch_features, &refs) { Ok(Action::Cancel) => { return if matches!(protocol_version, git_transport::Protocol::V1) || matches!(fetch_mode, FetchConnection::TerminateOnSuccessfulCompletion) { - indicate_end_of_interaction(transport).await + indicate_end_of_interaction(transport).await.map_err(Into::into) } else { Ok(()) }; @@ -207,7 +113,7 @@ where progress.step(); progress.set_name(format!("negotiate (round {})", round)); round += 1; - let action = delegate.negotiate(&parsed_refs, &mut arguments, previous_response.as_ref())?; + let action = delegate.negotiate(&refs, &mut arguments, previous_response.as_ref())?; let mut reader = arguments.send(&mut transport, action == Action::Cancel).await?; if sideband_all { setup_remote_progress(&mut progress, &mut reader); @@ -219,7 +125,7 @@ where if !sideband_all { setup_remote_progress(&mut progress, &mut reader); } - delegate.receive_pack(reader, progress, &parsed_refs, &response).await?; + delegate.receive_pack(reader, progress, &refs, &response).await?; break 'negotiation; } else { match action { @@ -236,18 +142,6 @@ where Ok(()) } -#[maybe_async] -async fn indicate_end_of_interaction(mut transport: impl client::Transport) -> Result<(), Error> { - // An empty request marks the (early) end of the interaction. Only relevant in stateful transports though. - if transport.connection_persists_across_multiple_requests() { - transport - .request(client::WriteMode::Binary, client::MessageKind::Flush)? - .into_read() - .await?; - } - Ok(()) -} - fn setup_remote_progress( progress: &mut impl Progress, reader: &mut Box, diff --git a/git-protocol/src/lib.rs b/git-protocol/src/lib.rs index 8ded18e04c5..136c8cdf4a9 100644 --- a/git-protocol/src/lib.rs +++ b/git-protocol/src/lib.rs @@ -10,6 +10,12 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] #![deny(missing_docs, rust_2018_idioms, unsafe_code)] +#[cfg(feature = "async-trait")] +pub use async_trait; +#[cfg(feature = "futures-io")] +pub use futures_io; +pub use maybe_async; + pub use git_credentials as credentials; /// A convenience export allowing users of git-protocol to use the transport layer without their own cargo dependency. pub use git_transport as transport; diff --git a/git-protocol/tests/fetch/v2.rs b/git-protocol/tests/fetch/v2.rs index e4dd73e50e8..3f6d9c6eb50 100644 --- a/git-protocol/tests/fetch/v2.rs +++ b/git-protocol/tests/fetch/v2.rs @@ -131,11 +131,11 @@ async fn ls_remote_abort_in_prep_ls_refs() -> crate::Result { b"0044git-upload-pack does/not/matter\x00\x00version=2\x00value-only\x00key=value\x000000".as_bstr() ); match err { - fetch::Error::Io(err) => { + fetch::Error::Refs(fetch::refs::Error::Io(err)) => { assert_eq!(err.kind(), std::io::ErrorKind::Other); assert_eq!(err.get_ref().expect("other error").to_string(), "hello world"); } - _ => panic!("should not have another error here"), + err => panic!("should not have another error here, got: {}", err), } Ok(()) } diff --git a/git-refspec/src/instruction.rs b/git-refspec/src/instruction.rs index ceceb9db79d..b2caa699f86 100644 --- a/git-refspec/src/instruction.rs +++ b/git-refspec/src/instruction.rs @@ -13,7 +13,7 @@ impl Instruction<'_> { /// Note that all sources can either be a ref-name, partial or full, or a rev-spec, unless specified otherwise, on the local side. /// Destinations can only be a partial or full ref names on the remote side. -#[derive(PartialEq, Eq, Copy, Clone, Hash, Debug)] +#[derive(PartialOrd, Ord, PartialEq, Eq, Copy, Clone, Hash, Debug)] pub enum Push<'a> { /// Push all local branches to the matching destination on the remote, which has to exist to be updated. AllMatchingBranches { @@ -39,7 +39,7 @@ pub enum Push<'a> { /// Any source can either be a ref name (full or partial) or a fully spelled out hex-sha for an object, on the remote side. /// /// Destinations can only be a partial or full ref-names on the local side. -#[derive(PartialEq, Eq, Copy, Clone, Hash, Debug)] +#[derive(PartialOrd, Ord, PartialEq, Eq, Copy, Clone, Hash, Debug)] pub enum Fetch<'a> { /// Fetch a ref or refs and write the result into the `FETCH_HEAD` without updating local branches. Only { diff --git a/git-refspec/src/lib.rs b/git-refspec/src/lib.rs index 6713e9f1f69..f53fdcdc013 100644 --- a/git-refspec/src/lib.rs +++ b/git-refspec/src/lib.rs @@ -10,7 +10,7 @@ pub use parse::function::parse; pub mod instruction; /// A refspec with references to the memory it was parsed from. -#[derive(PartialEq, Eq, Copy, Clone, Hash, Debug)] +#[derive(Eq, Copy, Clone, Debug)] pub struct RefSpecRef<'a> { mode: types::Mode, op: parse::Operation, @@ -19,7 +19,7 @@ pub struct RefSpecRef<'a> { } /// An owned refspec. -#[derive(PartialEq, Eq, Clone, Hash, Debug)] +#[derive(Eq, Clone, Debug)] pub struct RefSpec { mode: types::Mode, op: parse::Operation, diff --git a/git-refspec/src/parse.rs b/git-refspec/src/parse.rs index 2eef2873975..9c48178de41 100644 --- a/git-refspec/src/parse.rs +++ b/git-refspec/src/parse.rs @@ -27,7 +27,7 @@ pub enum Error { } /// Define how the parsed refspec should be used. -#[derive(PartialEq, Eq, Copy, Clone, Hash, Debug)] +#[derive(PartialOrd, Ord, PartialEq, Eq, Copy, Clone, Hash, Debug)] pub enum Operation { /// The `src` side is local and the `dst` side is remote. Push, diff --git a/git-refspec/src/spec.rs b/git-refspec/src/spec.rs index 14fd2a090e2..f118b92a484 100644 --- a/git-refspec/src/spec.rs +++ b/git-refspec/src/spec.rs @@ -16,12 +16,62 @@ impl RefSpec { mod impls { use crate::{RefSpec, RefSpecRef}; + use std::cmp::Ordering; + use std::hash::{Hash, Hasher}; impl From> for RefSpec { fn from(v: RefSpecRef<'_>) -> Self { v.to_owned() } } + + impl Hash for RefSpec { + fn hash(&self, state: &mut H) { + self.to_ref().hash(state) + } + } + + impl Hash for RefSpecRef<'_> { + fn hash(&self, state: &mut H) { + self.instruction().hash(state) + } + } + + impl PartialEq for RefSpec { + fn eq(&self, other: &Self) -> bool { + self.to_ref().eq(&other.to_ref()) + } + } + + impl PartialEq for RefSpecRef<'_> { + fn eq(&self, other: &Self) -> bool { + self.instruction().eq(&other.instruction()) + } + } + + impl PartialOrd for RefSpecRef<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + self.instruction().partial_cmp(&other.instruction()) + } + } + + impl PartialOrd for RefSpec { + fn partial_cmp(&self, other: &Self) -> Option { + self.to_ref().partial_cmp(&other.to_ref()) + } + } + + impl Ord for RefSpecRef<'_> { + fn cmp(&self, other: &Self) -> Ordering { + self.instruction().cmp(&other.instruction()) + } + } + + impl Ord for RefSpec { + fn cmp(&self, other: &Self) -> Ordering { + self.to_ref().cmp(&other.to_ref()) + } + } } /// Access diff --git a/git-refspec/src/types.rs b/git-refspec/src/types.rs index f7c453a0a71..0a0e24e36cc 100644 --- a/git-refspec/src/types.rs +++ b/git-refspec/src/types.rs @@ -1,7 +1,7 @@ use crate::instruction; /// The way to interpret a refspec. -#[derive(PartialEq, Eq, Copy, Clone, Hash, Debug)] +#[derive(PartialOrd, Ord, PartialEq, Eq, Copy, Clone, Hash, Debug)] pub(crate) enum Mode { /// Apply standard rules for refspecs which are including refs with specific rules related to allowing fast forwards of destinations. Normal, @@ -12,7 +12,7 @@ pub(crate) enum Mode { } /// Tells what to do and is derived from a [`RefSpec`][crate::RefSpecRef]. -#[derive(PartialEq, Eq, Copy, Clone, Hash, Debug)] +#[derive(PartialOrd, Ord, PartialEq, Eq, Copy, Clone, Hash, Debug)] pub enum Instruction<'a> { /// An instruction for pushing. Push(instruction::Push<'a>), diff --git a/git-refspec/tests/impls/mod.rs b/git-refspec/tests/impls/mod.rs new file mode 100644 index 00000000000..97f200ae86b --- /dev/null +++ b/git-refspec/tests/impls/mod.rs @@ -0,0 +1,27 @@ +use git_refspec::parse::Operation; +use git_refspec::RefSpec; +use std::collections::{BTreeSet, HashSet}; +use std::iter::FromIterator; + +fn pair() -> Vec { + let lhs = git_refspec::parse("refs/heads/foo".into(), Operation::Push).unwrap(); + let rhs = git_refspec::parse("refs/heads/foo:refs/heads/foo".into(), Operation::Push).unwrap(); + vec![lhs.to_owned(), rhs.to_owned()] +} + +#[test] +fn cmp() { + assert_eq!(BTreeSet::from_iter(pair()).len(), 1) +} + +#[test] +fn hash() { + let set: HashSet<_> = pair().into_iter().collect(); + assert_eq!(set.len(), 1) +} + +#[test] +fn eq() { + let specs = pair(); + assert_eq!(&specs[0], &specs[1]); +} diff --git a/git-refspec/tests/refspec.rs b/git-refspec/tests/refspec.rs index 06f1a3c69d4..ee35817f145 100644 --- a/git-refspec/tests/refspec.rs +++ b/git-refspec/tests/refspec.rs @@ -1 +1,2 @@ +mod impls; mod parse; diff --git a/git-repository/Cargo.toml b/git-repository/Cargo.toml index 57201bff77e..ced4885011e 100644 --- a/git-repository/Cargo.toml +++ b/git-repository/Cargo.toml @@ -31,17 +31,19 @@ default = ["max-performance", "one-stop-shop"] #! Either `async-*` or `blocking-*` versions of these toggles may be enabled at a time. ## Make `git-protocol` available along with an async client. -async-network-client = ["git-protocol/async-client"] +async-network-client = ["git-protocol/async-client", "unstable"] +## Use this if your crate uses `async-std` as runtime, and enable basic runtime integration when connecting to remote servers. +async-network-client-async-std = ["async-std", "async-network-client", "git-transport/async-std"] ## Make `git-protocol` available along with a blocking client. -blocking-network-client = ["git-protocol/blocking-client"] +blocking-network-client = ["git-protocol/blocking-client", "unstable"] ## Stacks with `blocking-network-client` to provide support for HTTP/S, and implies blocking networking as a whole. blocking-http-transport = ["git-transport/http-client-curl"] #! ### Reducing dependencies #! The following toggles can be left disabled to save on dependencies. -## Provide additional non-networked functionality like `git-url` and `git-diff`. -local = [ "git-url", "git-diff" ] +## Provide additional non-networked functionality. +local = [ "git-diff" ] ## Turns on access to all stable features that are unrelated to networking. one-stop-shop = [ "local" ] @@ -83,6 +85,7 @@ git-lock = { version = "^2.0.0", path = "../git-lock" } git-validate = { version = "^0.5.4", path = "../git-validate" } git-sec = { version = "^0.3.0", path = "../git-sec", features = ["thiserror"] } git-date = { version = "^0.0.4", path = "../git-date" } +git-refspec = { version = "^0.1.0", path = "../git-refspec" } git-config = { version = "^0.6.1", path = "../git-config" } git-odb = { version = "^0.31.1", path = "../git-odb" } @@ -93,7 +96,7 @@ git-pack = { version = "^0.21.1", path = "../git-pack", features = ["object-cach git-revision = { version = "^0.4.1", path = "../git-revision" } git-path = { version = "^0.4.0", path = "../git-path" } -git-url = { version = "^0.7.1", path = "../git-url", optional = true } +git-url = { version = "^0.7.1", path = "../git-url" } git-traverse = { version = "^0.16.1", path = "../git-traverse" } git-protocol = { version = "^0.18.1", path = "../git-protocol", optional = true } git-transport = { version = "^0.19.1", path = "../git-transport", optional = true } @@ -115,6 +118,7 @@ byte-unit = "4.0" log = "0.4.14" serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"]} smallvec = "1.9.0" +async-std = { version = "1.12.0", optional = true } ## For use in rev-parse, which provides searching commits by running a regex on their message. ## diff --git a/git-repository/src/config/cache.rs b/git-repository/src/config/cache.rs index 8943985f4cd..b3653557cd3 100644 --- a/git-repository/src/config/cache.rs +++ b/git-repository/src/config/cache.rs @@ -3,7 +3,7 @@ use std::{convert::TryFrom, path::PathBuf}; use git_config::{Boolean, Integer}; use super::{Cache, Error}; -use crate::{bstr::ByteSlice, repository, repository::identity, revision::spec::parse::ObjectKindHint}; +use crate::{bstr::ByteSlice, remote, repository, repository::identity, revision::spec::parse::ObjectKindHint}; /// A utility to deal with the cyclic dependency between the ref store and the configuration. The ref-store needs the /// object hash kind, and the configuration needs the current branch name to resolve conditional includes with `onbranch`. @@ -218,10 +218,12 @@ impl Cache { is_bare, ignore_case, hex_len, + filter_config_section, excludes_file, xdg_config_home_env, home_env, personas: Default::default(), + url_rewrite: Default::default(), git_prefix, }) } @@ -252,12 +254,23 @@ impl Cache { } } +impl std::fmt::Debug for Cache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Cache").finish_non_exhaustive() + } +} + /// Access impl Cache { - pub fn personas(&self) -> &identity::Personas { + pub(crate) fn personas(&self) -> &identity::Personas { self.personas .get_or_init(|| identity::Personas::from_config_and_env(&self.resolved, &self.git_prefix)) } + + pub(crate) fn url_rewrite(&self) -> &remote::url::Rewrite { + self.url_rewrite + .get_or_init(|| remote::url::Rewrite::from_config(&self.resolved, self.filter_config_section)) + } } pub(crate) fn interpolate_context<'a>( diff --git a/git-repository/src/config/mod.rs b/git-repository/src/config/mod.rs index 2dea9c5adda..c880b2019ba 100644 --- a/git-repository/src/config/mod.rs +++ b/git-repository/src/config/mod.rs @@ -1,10 +1,11 @@ pub use git_config::*; use git_features::threading::OnceCell; -use crate::{bstr::BString, permission, repository::identity, revision::spec, Repository}; +use crate::{bstr::BString, permission, remote, repository::identity, revision::spec, Repository}; pub(crate) mod cache; mod snapshot; +pub use snapshot::apply_cli_overrides; /// A platform to access configuration values as read from disk. /// @@ -13,6 +14,16 @@ pub struct Snapshot<'repo> { pub(crate) repo: &'repo Repository, } +/// A platform to access configuration values and modify them in memory, while making them available when this platform is dropped. +/// Note that the values will only affect this instance of the parent repository, and not other clones that may exist. +/// +/// Note that these values won't update even if the underlying file(s) change. +// TODO: make it possible to load snapshots with reloading via .config() and write mutated snapshots back to disk. +pub struct SnapshotMut<'repo> { + pub(crate) repo: &'repo mut Repository, + pub(crate) config: git_config::File<'static>, +} + pub(crate) mod section { pub fn is_trusted(meta: &git_config::file::Metadata) -> bool { meta.trust == git_sec::Trust::Full || meta.source.kind() != git_config::source::Kind::Repository @@ -44,7 +55,7 @@ pub enum Error { } /// Utility type to keep pre-obtained configuration values. -#[derive(Debug, Clone)] +#[derive(Clone)] pub(crate) struct Cache { pub resolved: crate::Config, /// The hex-length to assume when shortening object ids. If `None`, it should be computed based on the approximate object count. @@ -59,6 +70,10 @@ pub(crate) struct Cache { pub reflog: Option, /// identities for later use, lazy initialization. pub personas: OnceCell, + /// A lazily loaded rewrite list for remote urls + pub url_rewrite: OnceCell, + /// The config section filter from the options used to initialize this instance. Keep these in sync! + filter_config_section: fn(&git_config::file::Metadata) -> bool, /// The object kind to pick if a prefix is ambiguous. pub object_kind_hint: Option, /// If true, we are on a case-insensitive file system. diff --git a/git-repository/src/config/snapshot.rs b/git-repository/src/config/snapshot.rs index 7bbcb31a88d..0dbbc83d409 100644 --- a/git-repository/src/config/snapshot.rs +++ b/git-repository/src/config/snapshot.rs @@ -1,7 +1,4 @@ -use std::{ - borrow::Cow, - fmt::{Debug, Formatter}, -}; +use std::borrow::Cow; use crate::{ bstr::BStr, @@ -75,11 +72,7 @@ impl<'repo> Snapshot<'repo> { key.section_name, key.subsection_name, key.value_name, - &mut self - .repo - .options - .filter_config_section - .unwrap_or(crate::config::section::is_trusted), + &mut self.repo.filter_config_section(), )?; let install_dir = self.repo.install_dir().ok(); @@ -88,6 +81,59 @@ impl<'repo> Snapshot<'repo> { } } +/// +pub mod apply_cli_overrides { + use crate::bstr::{BString, ByteSlice}; + use crate::config::SnapshotMut; + use std::convert::TryFrom; + + /// The error returned by [SnapshotMut::apply_cli_overrides()][crate::config::SnapshotMut::apply_cli_overrides()]. + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error("{input:?} is not a valid configuration key. Examples are 'core.abbrev' or 'remote.origin.url'")] + InvalidKey { input: BString }, + #[error("Key {key:?} could not be parsed")] + SectionKey { + key: BString, + source: git_config::parse::section::key::Error, + }, + #[error(transparent)] + SectionHeader(#[from] git_config::parse::section::header::Error), + } + + impl SnapshotMut<'_> { + /// Apply configuration values of the form `core.abbrev=5` or `remote.origin.url = foo` or `core.bool-implicit-true` + /// to the repository configuration, marked with [source CLI][git_config::Source::Cli]. + pub fn apply_cli_overrides( + &mut self, + values: impl IntoIterator>, + ) -> Result<(), Error> { + let mut file = git_config::File::new(git_config::file::Metadata::from(git_config::Source::Cli)); + for key_value in values { + let key_value = key_value.into(); + let mut tokens = key_value.splitn(2, |b| *b == b'=').map(|v| v.trim()); + let key = tokens.next().expect("always one value").as_bstr(); + let value = tokens.next(); + let key = git_config::parse::key(key.to_str().map_err(|_| Error::InvalidKey { input: key.into() })?) + .ok_or_else(|| Error::InvalidKey { input: key.into() })?; + let mut section = file.section_mut_or_create_new(key.section_name, key.subsection_name)?; + section.push( + git_config::parse::section::Key::try_from(key.value_name.to_owned()).map_err(|err| { + Error::SectionKey { + source: err, + key: key.value_name.into(), + } + })?, + value.map(|v| v.as_bstr()), + ); + } + self.config.append(file); + Ok(()) + } + } +} + /// Utilities and additional access impl<'repo> Snapshot<'repo> { /// Returns the underlying configuration implementation for a complete API, despite being a little less convenient. @@ -98,8 +144,40 @@ impl<'repo> Snapshot<'repo> { } } -impl Debug for Snapshot<'_> { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.repo.config.resolved.to_string()) +mod _impls { + use crate::config::{Snapshot, SnapshotMut}; + use std::fmt::{Debug, Formatter}; + use std::ops::{Deref, DerefMut}; + + impl Debug for Snapshot<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.repo.config.resolved.to_string()) + } + } + + impl Debug for SnapshotMut<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.config.to_string()) + } + } + + impl Drop for SnapshotMut<'_> { + fn drop(&mut self) { + self.repo.config.resolved = std::mem::take(&mut self.config).into(); + } + } + + impl Deref for SnapshotMut<'_> { + type Target = git_config::File<'static>; + + fn deref(&self) -> &Self::Target { + &self.config + } + } + + impl DerefMut for SnapshotMut<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.config + } } } diff --git a/git-repository/src/create.rs b/git-repository/src/create.rs index 8fb1c2ed5f0..14050eb79e8 100644 --- a/git-repository/src/create.rs +++ b/git-repository/src/create.rs @@ -183,13 +183,13 @@ pub fn into( let caps = fs_capabilities.unwrap_or_else(|| git_worktree::fs::Capabilities::probe(&dot_git)); let mut core = config.new_section("core", None).expect("valid section name"); - core.push(key("repositoryformatversion"), "0"); - core.push(key("filemode"), bool(caps.executable_bit)); - core.push(key("bare"), bool(bare)); - core.push(key("logallrefupdates"), bool(!bare)); - core.push(key("symlinks"), bool(caps.symlink)); - core.push(key("ignorecase"), bool(caps.ignore_case)); - core.push(key("precomposeunicode"), bool(caps.precompose_unicode)); + core.push(key("repositoryformatversion"), Some("0".into())); + core.push(key("filemode"), Some(bool(caps.executable_bit).into())); + core.push(key("bare"), Some(bool(bare).into())); + core.push(key("logallrefupdates"), Some(bool(!bare).into())); + core.push(key("symlinks"), Some(bool(caps.symlink).into())); + core.push(key("ignorecase"), Some(bool(caps.ignore_case).into())); + core.push(key("precomposeunicode"), Some(bool(caps.precompose_unicode).into())); } let mut cursor = PathCursor(&mut dot_git); let config_path = cursor.at("config"); diff --git a/git-repository/src/head.rs b/git-repository/src/head.rs deleted file mode 100644 index 5b0205befc3..00000000000 --- a/git-repository/src/head.rs +++ /dev/null @@ -1,240 +0,0 @@ -//! -use std::convert::TryInto; - -use git_hash::ObjectId; -use git_ref::FullNameRef; - -use crate::{ - ext::{ObjectIdExt, ReferenceExt}, - Head, -}; - -/// Represents the kind of `HEAD` reference. -#[derive(Clone)] -pub enum Kind { - /// The existing reference the symbolic HEAD points to. - /// - /// This is the common case. - Symbolic(git_ref::Reference), - /// The yet-to-be-created reference the symbolic HEAD refers to. - /// - /// This is the case in a newly initialized repository. - Unborn(git_ref::FullName), - /// The head points to an object directly, not to a symbolic reference. - /// - /// This state is less common and can occur when checking out commits directly. - Detached { - /// The object to which the head points to - target: ObjectId, - /// Possibly the final destination of `target` after following the object chain from tag objects to commits. - peeled: Option, - }, -} - -impl Kind { - /// Attach this instance to a `repo` to produce a [`Head`]. - pub fn attach(self, repo: &crate::Repository) -> Head<'_> { - Head { kind: self, repo } - } -} - -impl<'repo> Head<'repo> { - /// Returns the name of this references, always `HEAD`. - pub fn name(&self) -> &'static FullNameRef { - // TODO: use a statically checked version of this when available. - "HEAD".try_into().expect("HEAD is valid") - } - - /// Returns the full reference name of this head if it is not detached, or `None` otherwise. - pub fn referent_name(&self) -> Option<&FullNameRef> { - Some(match &self.kind { - Kind::Symbolic(r) => r.name.as_ref(), - Kind::Unborn(name) => name.as_ref(), - Kind::Detached { .. } => return None, - }) - } - /// Returns true if this instance is detached, and points to an object directly. - pub fn is_detached(&self) -> bool { - matches!(self.kind, Kind::Detached { .. }) - } - - // TODO: tests - /// Returns the id the head points to, which isn't possible on unborn heads. - pub fn id(&self) -> Option> { - match &self.kind { - Kind::Symbolic(r) => r.target.try_id().map(|oid| oid.to_owned().attach(self.repo)), - Kind::Detached { peeled, target } => { - (*peeled).unwrap_or_else(|| target.to_owned()).attach(self.repo).into() - } - Kind::Unborn(_) => None, - } - } - - /// Try to transform this instance into the symbolic reference that it points to, or return `None` if head is detached or unborn. - pub fn try_into_referent(self) -> Option> { - match self.kind { - Kind::Symbolic(r) => r.attach(self.repo).into(), - _ => None, - } - } -} -/// -pub mod log { - use std::convert::TryInto; - - use git_hash::ObjectId; - - use crate::{ - bstr::{BString, ByteSlice}, - Head, - }; - - impl<'repo> Head<'repo> { - /// Return a platform for obtaining iterators on the reference log associated with the `HEAD` reference. - pub fn log_iter(&self) -> git_ref::file::log::iter::Platform<'static, 'repo> { - git_ref::file::log::iter::Platform { - store: &self.repo.refs, - name: "HEAD".try_into().expect("HEAD is always valid"), - buf: Vec::new(), - } - } - - /// Return a list of all branch names that were previously checked out with the first-ever checked out branch - /// being the first entry of the list, and the most recent is the last, along with the commit they were pointing to - /// at the time. - pub fn prior_checked_out_branches(&self) -> std::io::Result>> { - Ok(self.log_iter().all()?.map(|log| { - log.filter_map(Result::ok) - .filter_map(|line| { - line.message - .strip_prefix(b"checkout: moving from ") - .and_then(|from_to| from_to.find(" to ").map(|pos| &from_to[..pos])) - .map(|from_branch| (from_branch.as_bstr().to_owned(), line.previous_oid())) - }) - .collect() - })) - } - } -} - -/// -pub mod peel { - use crate::{ - ext::{ObjectIdExt, ReferenceExt}, - Head, - }; - - mod error { - use crate::{object, reference}; - - /// The error returned by [Head::peel_to_id_in_place()][super::Head::peel_to_id_in_place()] and [Head::into_fully_peeled_id()][super::Head::into_fully_peeled_id()]. - #[derive(Debug, thiserror::Error)] - #[allow(missing_docs)] - pub enum Error { - #[error(transparent)] - FindExistingObject(#[from] object::find::existing::OdbError), - #[error(transparent)] - PeelReference(#[from] reference::peel::Error), - } - } - pub use error::Error; - - use crate::head::Kind; - - /// - pub mod to_commit { - use crate::object; - - /// The error returned by [Head::peel_to_commit_in_place()][super::Head::peel_to_commit_in_place()]. - #[derive(Debug, thiserror::Error)] - #[allow(missing_docs)] - pub enum Error { - #[error(transparent)] - Peel(#[from] super::Error), - #[error("Branch '{name}' does not have any commits")] - Unborn { name: git_ref::FullName }, - #[error(transparent)] - ObjectKind(#[from] object::try_into::Error), - } - } - - impl<'repo> Head<'repo> { - // TODO: tests - /// Peel this instance to make obtaining its final target id possible, while returning an error on unborn heads. - pub fn peeled(mut self) -> Result { - self.peel_to_id_in_place().transpose()?; - Ok(self) - } - - // TODO: tests - /// Follow the symbolic reference of this head until its target object and peel it by following tag objects until there is no - /// more object to follow, and return that object id. - /// - /// Returns `None` if the head is unborn. - pub fn peel_to_id_in_place(&mut self) -> Option, Error>> { - Some(match &mut self.kind { - Kind::Unborn(_name) => return None, - Kind::Detached { - peeled: Some(peeled), .. - } => Ok((*peeled).attach(self.repo)), - Kind::Detached { peeled: None, target } => { - match target - .attach(self.repo) - .object() - .map_err(Into::into) - .and_then(|obj| obj.peel_tags_to_end().map_err(Into::into)) - .map(|peeled| peeled.id) - { - Ok(peeled) => { - self.kind = Kind::Detached { - peeled: Some(peeled), - target: *target, - }; - Ok(peeled.attach(self.repo)) - } - Err(err) => Err(err), - } - } - Kind::Symbolic(r) => { - let mut nr = r.clone().attach(self.repo); - let peeled = nr.peel_to_id_in_place().map_err(Into::into); - *r = nr.detach(); - peeled - } - }) - } - - // TODO: tests - // TODO: something similar in `crate::Reference` - /// Follow the symbolic reference of this head until its target object and peel it by following tag objects until there is no - /// more object to follow, transform the id into a commit if possible and return that. - /// - /// Returns an error if the head is unborn or if it doesn't point to a commit. - pub fn peel_to_commit_in_place(&mut self) -> Result, to_commit::Error> { - let id = self.peel_to_id_in_place().ok_or_else(|| to_commit::Error::Unborn { - name: self.referent_name().expect("unborn").to_owned(), - })??; - id.object() - .map_err(|err| to_commit::Error::Peel(Error::FindExistingObject(err))) - .and_then(|object| object.try_into_commit().map_err(Into::into)) - } - - /// Consume this instance and transform it into the final object that it points to, or `None` if the `HEAD` - /// reference is yet to be born. - pub fn into_fully_peeled_id(self) -> Option, Error>> { - Some(match self.kind { - Kind::Unborn(_name) => return None, - Kind::Detached { - peeled: Some(peeled), .. - } => Ok(peeled.attach(self.repo)), - Kind::Detached { peeled: None, target } => target - .attach(self.repo) - .object() - .map_err(Into::into) - .and_then(|obj| obj.peel_tags_to_end().map_err(Into::into)) - .map(|obj| obj.id.attach(self.repo)), - Kind::Symbolic(r) => r.attach(self.repo).peel_to_id_in_place().map_err(Into::into), - }) - } - } -} diff --git a/git-repository/src/head/log.rs b/git-repository/src/head/log.rs new file mode 100644 index 00000000000..b4a00319e9c --- /dev/null +++ b/git-repository/src/head/log.rs @@ -0,0 +1,35 @@ +use std::convert::TryInto; + +use git_hash::ObjectId; + +use crate::{ + bstr::{BString, ByteSlice}, + Head, +}; + +impl<'repo> Head<'repo> { + /// Return a platform for obtaining iterators on the reference log associated with the `HEAD` reference. + pub fn log_iter(&self) -> git_ref::file::log::iter::Platform<'static, 'repo> { + git_ref::file::log::iter::Platform { + store: &self.repo.refs, + name: "HEAD".try_into().expect("HEAD is always valid"), + buf: Vec::new(), + } + } + + /// Return a list of all branch names that were previously checked out with the first-ever checked out branch + /// being the first entry of the list, and the most recent is the last, along with the commit they were pointing to + /// at the time. + pub fn prior_checked_out_branches(&self) -> std::io::Result>> { + Ok(self.log_iter().all()?.map(|log| { + log.filter_map(Result::ok) + .filter_map(|line| { + line.message + .strip_prefix(b"checkout: moving from ") + .and_then(|from_to| from_to.find(" to ").map(|pos| &from_to[..pos])) + .map(|from_branch| (from_branch.as_bstr().to_owned(), line.previous_oid())) + }) + .collect() + })) + } +} diff --git a/git-repository/src/head/mod.rs b/git-repository/src/head/mod.rs new file mode 100644 index 00000000000..b5559eb1c48 --- /dev/null +++ b/git-repository/src/head/mod.rs @@ -0,0 +1,111 @@ +//! +use std::convert::TryInto; + +use git_hash::ObjectId; +use git_ref::FullNameRef; + +use crate::{ + ext::{ObjectIdExt, ReferenceExt}, + Head, +}; + +/// Represents the kind of `HEAD` reference. +#[derive(Clone)] +pub enum Kind { + /// The existing reference the symbolic HEAD points to. + /// + /// This is the common case. + Symbolic(git_ref::Reference), + /// The yet-to-be-created reference the symbolic HEAD refers to. + /// + /// This is the case in a newly initialized repository. + Unborn(git_ref::FullName), + /// The head points to an object directly, not to a symbolic reference. + /// + /// This state is less common and can occur when checking out commits directly. + Detached { + /// The object to which the head points to + target: ObjectId, + /// Possibly the final destination of `target` after following the object chain from tag objects to commits. + peeled: Option, + }, +} + +impl Kind { + /// Attach this instance to a `repo` to produce a [`Head`]. + pub fn attach(self, repo: &crate::Repository) -> Head<'_> { + Head { kind: self, repo } + } +} + +/// Access +impl<'repo> Head<'repo> { + /// Returns the name of this references, always `HEAD`. + pub fn name(&self) -> &'static FullNameRef { + // TODO: use a statically checked version of this when available. + "HEAD".try_into().expect("HEAD is valid") + } + + /// Returns the full reference name of this head if it is not detached, or `None` otherwise. + pub fn referent_name(&self) -> Option<&FullNameRef> { + Some(match &self.kind { + Kind::Symbolic(r) => r.name.as_ref(), + Kind::Unborn(name) => name.as_ref(), + Kind::Detached { .. } => return None, + }) + } + /// Returns true if this instance is detached, and points to an object directly. + pub fn is_detached(&self) -> bool { + matches!(self.kind, Kind::Detached { .. }) + } + + // TODO: tests + /// Returns the id the head points to, which isn't possible on unborn heads. + pub fn id(&self) -> Option> { + match &self.kind { + Kind::Symbolic(r) => r.target.try_id().map(|oid| oid.to_owned().attach(self.repo)), + Kind::Detached { peeled, target } => { + (*peeled).unwrap_or_else(|| target.to_owned()).attach(self.repo).into() + } + Kind::Unborn(_) => None, + } + } + + /// Try to transform this instance into the symbolic reference that it points to, or return `None` if head is detached or unborn. + pub fn try_into_referent(self) -> Option> { + match self.kind { + Kind::Symbolic(r) => r.attach(self.repo).into(), + _ => None, + } + } +} + +mod remote { + use super::Head; + use crate::{remote, Remote}; + + /// Remote + impl<'repo> Head<'repo> { + /// Return the remote with which the currently checked our reference can be handled as configured by `branch..remote|pushRemote` + /// or fall back to the non-branch specific remote configuration. + /// + /// This is equivalent to calling [`Reference::remote(…)`][crate::Reference::remote()] and + /// [`Repository::remote_default_name()`][crate::Repository::remote_default_name()] in order. + pub fn into_remote( + self, + direction: remote::Direction, + ) -> Option, remote::find::existing::Error>> { + let repo = self.repo; + self.try_into_referent()?.remote(direction).or_else(|| { + repo.remote_default_name(direction) + .map(|name| repo.find_remote(name.as_ref())) + }) + } + } +} + +/// +pub mod log; + +/// +pub mod peel; diff --git a/git-repository/src/head/peel.rs b/git-repository/src/head/peel.rs new file mode 100644 index 00000000000..7f13180e0fb --- /dev/null +++ b/git-repository/src/head/peel.rs @@ -0,0 +1,119 @@ +use crate::{ + ext::{ObjectIdExt, ReferenceExt}, + Head, +}; + +mod error { + use crate::{object, reference}; + + /// The error returned by [Head::peel_to_id_in_place()][super::Head::peel_to_id_in_place()] and [Head::into_fully_peeled_id()][super::Head::into_fully_peeled_id()]. + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error(transparent)] + FindExistingObject(#[from] object::find::existing::OdbError), + #[error(transparent)] + PeelReference(#[from] reference::peel::Error), + } +} + +pub use error::Error; + +use crate::head::Kind; + +/// +pub mod to_commit { + use crate::object; + + /// The error returned by [Head::peel_to_commit_in_place()][super::Head::peel_to_commit_in_place()]. + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error(transparent)] + Peel(#[from] super::Error), + #[error("Branch '{name}' does not have any commits")] + Unborn { name: git_ref::FullName }, + #[error(transparent)] + ObjectKind(#[from] object::try_into::Error), + } +} + +impl<'repo> Head<'repo> { + // TODO: tests + /// Peel this instance to make obtaining its final target id possible, while returning an error on unborn heads. + pub fn peeled(mut self) -> Result { + self.peel_to_id_in_place().transpose()?; + Ok(self) + } + + // TODO: tests + /// Follow the symbolic reference of this head until its target object and peel it by following tag objects until there is no + /// more object to follow, and return that object id. + /// + /// Returns `None` if the head is unborn. + pub fn peel_to_id_in_place(&mut self) -> Option, Error>> { + Some(match &mut self.kind { + Kind::Unborn(_name) => return None, + Kind::Detached { + peeled: Some(peeled), .. + } => Ok((*peeled).attach(self.repo)), + Kind::Detached { peeled: None, target } => { + match target + .attach(self.repo) + .object() + .map_err(Into::into) + .and_then(|obj| obj.peel_tags_to_end().map_err(Into::into)) + .map(|peeled| peeled.id) + { + Ok(peeled) => { + self.kind = Kind::Detached { + peeled: Some(peeled), + target: *target, + }; + Ok(peeled.attach(self.repo)) + } + Err(err) => Err(err), + } + } + Kind::Symbolic(r) => { + let mut nr = r.clone().attach(self.repo); + let peeled = nr.peel_to_id_in_place().map_err(Into::into); + *r = nr.detach(); + peeled + } + }) + } + + // TODO: tests + // TODO: something similar in `crate::Reference` + /// Follow the symbolic reference of this head until its target object and peel it by following tag objects until there is no + /// more object to follow, transform the id into a commit if possible and return that. + /// + /// Returns an error if the head is unborn or if it doesn't point to a commit. + pub fn peel_to_commit_in_place(&mut self) -> Result, to_commit::Error> { + let id = self.peel_to_id_in_place().ok_or_else(|| to_commit::Error::Unborn { + name: self.referent_name().expect("unborn").to_owned(), + })??; + id.object() + .map_err(|err| to_commit::Error::Peel(Error::FindExistingObject(err))) + .and_then(|object| object.try_into_commit().map_err(Into::into)) + } + + /// Consume this instance and transform it into the final object that it points to, or `None` if the `HEAD` + /// reference is yet to be born. + pub fn into_fully_peeled_id(self) -> Option, Error>> { + Some(match self.kind { + Kind::Unborn(_name) => return None, + Kind::Detached { + peeled: Some(peeled), .. + } => Ok(peeled.attach(self.repo)), + Kind::Detached { peeled: None, target } => target + .attach(self.repo) + .object() + .map_err(Into::into) + .and_then(|obj| obj.peel_tags_to_end().map_err(Into::into)) + .map(|obj| obj.id.attach(self.repo)), + Kind::Symbolic(r) => r.attach(self.repo).peel_to_id_in_place().map_err(Into::into), + }) + } +} diff --git a/git-repository/src/lib.rs b/git-repository/src/lib.rs index bf719160e11..e7986c145f5 100644 --- a/git-repository/src/lib.rs +++ b/git-repository/src/lib.rs @@ -108,6 +108,7 @@ //! * [`traverse`] //! * [`diff`] //! * [`parallel`] +//! * [`refspec`] //! * [`Progress`] //! * [`progress`] //! * [`interrupt`] @@ -152,15 +153,14 @@ pub use git_odb as odb; #[cfg(all(feature = "unstable", feature = "git-protocol"))] pub use git_protocol as protocol; pub use git_ref as refs; +pub use git_refspec as refspec; pub use git_sec as sec; #[cfg(feature = "unstable")] pub use git_tempfile as tempfile; #[cfg(feature = "unstable")] pub use git_traverse as traverse; -#[cfg(all(feature = "unstable", feature = "git-url"))] pub use git_url as url; #[doc(inline)] -#[cfg(all(feature = "unstable", feature = "git-url"))] pub use git_url::Url; pub use hash::{oid, ObjectId}; @@ -188,7 +188,8 @@ pub(crate) type Config = OwnShared>; /// mod types; pub use types::{ - Commit, Head, Id, Kind, Object, ObjectDetached, Reference, Repository, Tag, ThreadSafeRepository, Tree, Worktree, + Commit, Head, Id, Kind, Object, ObjectDetached, Reference, Remote, Repository, Tag, ThreadSafeRepository, Tree, + Worktree, }; pub mod commit; @@ -276,6 +277,9 @@ pub mod worktree; pub mod revision; +/// +pub mod remote; + /// pub mod init { use std::path::Path; @@ -341,7 +345,8 @@ pub mod discover; /// pub mod env { - use std::ffi::OsString; + use crate::bstr::{BString, ByteVec}; + use std::ffi::{OsStr, OsString}; /// Equivalent to `std::env::args_os()`, but with precomposed unicode on MacOS and other apple platforms. #[cfg(not(target_vendor = "apple"))] @@ -360,6 +365,13 @@ pub mod env { None => arg, }) } + + /// Convert the given `input` into a `BString`, useful as `parse(try_from_os_str = )` function. + pub fn os_str_to_bstring(input: &OsStr) -> Result { + Vec::from_os_string(input.into()) + .map(Into::into) + .map_err(|_| input.to_string_lossy().into_owned()) + } } mod kind; diff --git a/git-repository/src/open.rs b/git-repository/src/open.rs index a239cfa23e7..3c7bc0274c5 100644 --- a/git-repository/src/open.rs +++ b/git-repository/src/open.rs @@ -66,6 +66,7 @@ pub struct Options { pub(crate) replacement_objects: ReplacementObjects, pub(crate) permissions: Permissions, pub(crate) git_dir_trust: Option, + /// Warning: this one is copied to to config::Cache - don't change it after repo open or keep in sync. pub(crate) filter_config_section: Option bool>, pub(crate) lossy_config: Option, pub(crate) lenient_config: bool, @@ -355,11 +356,13 @@ impl ThreadSafeRepository { let home = std::env::var_os("HOME") .map(PathBuf::from) .and_then(|home| env.home.check(home).ok().flatten()); + + let mut filter_config_section = filter_config_section.unwrap_or(config::section::is_trusted); let config = config::Cache::from_stage_one( repo_config, common_dir_ref, head.as_ref().and_then(|head| head.target.try_name()), - filter_config_section.unwrap_or(config::section::is_trusted), + filter_config_section, git_install_dir.as_deref(), home.as_deref(), env.clone(), @@ -373,12 +376,10 @@ impl ThreadSafeRepository { // core.worktree might be used to overwrite the worktree directory if !config.is_bare { - if let Some(wt) = config.resolved.path_filter( - "core", - None, - "worktree", - &mut filter_config_section.unwrap_or(config::section::is_trusted), - ) { + if let Some(wt) = config + .resolved + .path_filter("core", None, "worktree", &mut filter_config_section) + { let wt_path = wt .interpolate(interpolate_context(git_install_dir.as_deref(), home.as_deref())) .map_err(config::Error::PathInterpolation)?; diff --git a/git-repository/src/path.rs b/git-repository/src/path.rs index b78a3e3c3d1..418affa8c6a 100644 --- a/git-repository/src/path.rs +++ b/git-repository/src/path.rs @@ -1,6 +1,5 @@ use std::path::PathBuf; -#[cfg(all(feature = "unstable"))] pub use git_path::*; pub(crate) fn install_dir() -> std::io::Result { diff --git a/git-repository/src/reference/mod.rs b/git-repository/src/reference/mod.rs index 358681c48f3..66fdf6f287d 100644 --- a/git-repository/src/reference/mod.rs +++ b/git-repository/src/reference/mod.rs @@ -6,6 +6,7 @@ use git_ref::file::ReferenceExt; use crate::{Id, Reference}; pub mod iter; +mod remote; mod errors; pub use errors::{edit, find, head_commit, head_id, peel}; @@ -18,6 +19,20 @@ pub use git_ref::{Category, Kind}; /// Access impl<'repo> Reference<'repo> { + /// Returns the attached id we point to, or `None` if this is a symbolic ref. + pub fn try_id(&self) -> Option> { + match self.inner.target { + git_ref::Target::Symbolic(_) => None, + git_ref::Target::Peeled(oid) => oid.to_owned().attach(self.repo).into(), + } + } + + /// Returns the attached id we point to, or panic if this is a symbolic ref. + pub fn id(&self) -> Id<'repo> { + self.try_id() + .expect("BUG: tries to obtain object id from symbolic target") + } + /// Return the target to which this reference points to. pub fn target(&self) -> git_ref::TargetRef<'_> { self.inner.target.to_ref() @@ -44,21 +59,9 @@ impl<'repo> Reference<'repo> { pub(crate) fn from_ref(reference: git_ref::Reference, repo: &'repo crate::Repository) -> Self { Reference { inner: reference, repo } } +} - /// Returns the attached id we point to, or `None` if this is a symbolic ref. - pub fn try_id(&self) -> Option> { - match self.inner.target { - git_ref::Target::Symbolic(_) => None, - git_ref::Target::Peeled(oid) => oid.to_owned().attach(self.repo).into(), - } - } - - /// Returns the attached id we point to, or panic if this is a symbolic ref. - pub fn id(&self) -> crate::Id<'repo> { - self.try_id() - .expect("BUG: tries to obtain object id from symbolic target") - } - +impl<'repo> Reference<'repo> { /// Follow all symbolic targets this reference might point to and peel the underlying object /// to the end of the chain, and return it. /// diff --git a/git-repository/src/reference/remote.rs b/git-repository/src/reference/remote.rs new file mode 100644 index 00000000000..643a9a00a6b --- /dev/null +++ b/git-repository/src/reference/remote.rs @@ -0,0 +1,46 @@ +use crate::bstr::{ByteSlice, ByteVec}; +use crate::{remote, Reference}; +use std::borrow::Cow; + +/// Remotes +impl<'repo> Reference<'repo> { + /// Find the name of our remote for `direction` as configured in `branch..remote|pushRemote` respectively. + /// If `Some()` it can be used in [`Repository::find_remote(…)`][crate::Repository::find_remote()], or if `None` then + /// [Repository::remote_default_name()][crate::Repository::remote_default_name()] could be used in its place. + /// + /// Return `None` if no remote is configured. + /// + /// # Note + /// + /// - it's recommended to use the [`remote(…)`][Self::remote()] method as it will configure the remote with additional + /// information. + /// - `branch..pushRemote` falls back to `branch..remote`. + pub fn remote_name(&self, direction: remote::Direction) -> Option> { + let name = self.name().shorten().to_str().ok()?; + let config = &self.repo.config.resolved; + (direction == remote::Direction::Push) + .then(|| { + config + .string("branch", Some(name), "pushRemote") + .or_else(|| config.string("remote", None, "pushDefault")) + }) + .flatten() + .or_else(|| config.string("branch", Some(name), "remote")) + .and_then(|name| match name { + Cow::Borrowed(n) => n.to_str().ok().map(Cow::Borrowed), + Cow::Owned(n) => Vec::from(n).into_string().ok().map(Cow::Owned), + }) + } + + /// Like [`remote_name(…)`][Self::remote_name()], but configures the returned `Remote` with additional information like + /// + /// - `branch..merge` to know which branch on the remote side corresponds to this one for merging when pulling. + pub fn remote( + &self, + direction: remote::Direction, + ) -> Option, remote::find::existing::Error>> { + let name = self.remote_name(direction)?; + // TODO: use `branch..merge` + self.repo.find_remote(name.as_ref()).into() + } +} diff --git a/git-repository/src/remote/access.rs b/git-repository/src/remote/access.rs new file mode 100644 index 00000000000..277f9c5f6b0 --- /dev/null +++ b/git-repository/src/remote/access.rs @@ -0,0 +1,62 @@ +use crate::{remote, Remote}; +use git_refspec::RefSpec; + +/// Access +impl Remote<'_> { + /// Return the name of this remote or `None` if it wasn't persisted to disk yet. + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// Return the set of ref-specs used for `direction`, which may be empty, in order of occurrence in the configuration. + pub fn refspecs(&self, direction: remote::Direction) -> &[RefSpec] { + match direction { + remote::Direction::Fetch => &self.fetch_specs, + remote::Direction::Push => &self.push_specs, + } + } + + /// Return the url used for the given `direction` with rewrites from `url..insteadOf|pushInsteadOf`, unless the instance + /// was created with one of the `_without_url_rewrite()` methods. + /// For pushing, this is the `remote..pushUrl` or the `remote..url` used for fetching, and for fetching it's + /// the `remote..url`. + /// Note that it's possible to only have the push url set, in which case there will be no way to fetch from the remote as + /// the push-url isn't used for that. + pub fn url(&self, direction: remote::Direction) -> Option<&git_url::Url> { + match direction { + remote::Direction::Fetch => self.url_alias.as_ref().or(self.url.as_ref()), + remote::Direction::Push => self + .push_url_alias + .as_ref() + .or(self.push_url.as_ref()) + .or_else(|| self.url(remote::Direction::Fetch)), + } + } +} + +/// Modification +impl Remote<'_> { + /// Read `url..insteadOf|pushInsteadOf` configuration variables and apply them to our urls, changing them in place. + /// + /// This happens only once, and one if them may be changed even when reporting an error. + /// If both urls fail, only the first error (for fetch urls) is reported. + pub fn rewrite_urls(&mut self) -> Result<&mut Self, remote::init::Error> { + let url_err = match remote::init::rewrite_url(&self.repo.config, self.url.as_ref(), remote::Direction::Fetch) { + Ok(url) => { + self.url_alias = url; + None + } + Err(err) => err.into(), + }; + let push_url_err = + match remote::init::rewrite_url(&self.repo.config, self.push_url.as_ref(), remote::Direction::Push) { + Ok(url) => { + self.push_url_alias = url; + None + } + Err(err) => err.into(), + }; + url_err.or(push_url_err).map(Err::<&mut Self, _>).transpose()?; + Ok(self) + } +} diff --git a/git-repository/src/remote/build.rs b/git-repository/src/remote/build.rs new file mode 100644 index 00000000000..08404cfaa52 --- /dev/null +++ b/git-repository/src/remote/build.rs @@ -0,0 +1,68 @@ +use crate::bstr::BStr; +use crate::{remote, Remote}; +use std::convert::TryInto; + +/// Builder methods +impl Remote<'_> { + /// Set the `url` to be used when pushing data to a remote. + pub fn push_url(self, url: Url) -> Result + where + Url: TryInto, + git_url::parse::Error: From, + { + self.push_url_inner(url, true) + } + + /// Set the `url` to be used when pushing data to a remote, without applying rewrite rules in case these could be faulty, + /// eliminating one failure mode. + pub fn push_url_without_url_rewrite(self, url: Url) -> Result + where + Url: TryInto, + git_url::parse::Error: From, + { + self.push_url_inner(url, false) + } + + fn push_url_inner(mut self, push_url: Url, should_rewrite_urls: bool) -> Result + where + Url: TryInto, + git_url::parse::Error: From, + { + let push_url = push_url + .try_into() + .map_err(|err| remote::init::Error::Url(err.into()))?; + self.push_url = push_url.into(); + + let (_, push_url_alias) = should_rewrite_urls + .then(|| remote::init::rewrite_urls(&self.repo.config, None, self.push_url.as_ref())) + .unwrap_or(Ok((None, None)))?; + self.push_url_alias = push_url_alias; + + Ok(self) + } + + /// Add `spec` as refspec for `direction` to our list if it's unique. + pub fn with_refspec<'a>( + mut self, + spec: impl Into<&'a BStr>, + direction: remote::Direction, + ) -> Result { + use remote::Direction::*; + let spec = git_refspec::parse( + spec.into(), + match direction { + Push => git_refspec::parse::Operation::Push, + Fetch => git_refspec::parse::Operation::Fetch, + }, + )? + .to_owned(); + let specs = match direction { + Push => &mut self.push_specs, + Fetch => &mut self.fetch_specs, + }; + if !specs.contains(&spec) { + specs.push(spec); + } + Ok(self) + } +} diff --git a/git-repository/src/remote/connect.rs b/git-repository/src/remote/connect.rs new file mode 100644 index 00000000000..a79fef1bb8f --- /dev/null +++ b/git-repository/src/remote/connect.rs @@ -0,0 +1,95 @@ +use crate::remote::Connection; +use crate::{Progress, Remote}; +use git_protocol::transport::client::Transport; + +mod error { + use crate::bstr::BString; + use crate::remote; + + /// The error returned by [connect()][crate::Remote::connect()]. + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error(transparent)] + Connect(#[from] git_protocol::transport::client::connect::Error), + #[error("The {} url was missing - don't know where to establish a connection to", direction.as_str())] + MissingUrl { direction: remote::Direction }, + #[error("Protocol named {given:?} is not a valid protocol. Choose between 1 and 2")] + UnknownProtocol { given: BString }, + #[error("Could not verify that file:// url is a valid git directory before attempting to use it")] + FileUrl(#[from] git_discover::is_git::Error), + } +} +pub use error::Error; + +/// Establishing connections to remote hosts +impl<'repo> Remote<'repo> { + /// Create a new connection using `transport` to communicate, with `progress` to indicate changes. + /// + /// Note that this method expects the `transport` to be created by the user, which would involve the [`url()`][Self::url()]. + /// It's meant to be used when async operation is needed with runtimes of the user's choice. + pub fn to_connection_with_transport(&self, transport: T, progress: P) -> Connection<'_, 'repo, T, P> + where + T: Transport, + P: Progress, + { + Connection { + remote: self, + transport, + progress, + } + } + + /// Connect to the url suitable for `direction` and return a handle through which operations can be performed. + /// + /// Note that the `protocol.version` configuration key affects the transport protocol used to connect, + /// with `2` being the default. + #[cfg(any(feature = "blocking-network-client", feature = "async-network-client-async-std"))] + #[git_protocol::maybe_async::maybe_async] + pub async fn connect

( + &self, + direction: crate::remote::Direction, + progress: P, + ) -> Result, P>, Error> + where + P: Progress, + { + use git_protocol::transport::Protocol; + fn sanitize(mut url: git_url::Url) -> Result { + if url.scheme == git_url::Scheme::File { + let mut dir = git_path::from_bstr(url.path.as_ref()); + let kind = git_discover::is_git(dir.as_ref()).or_else(|_| { + dir.to_mut().push(git_discover::DOT_GIT_DIR); + git_discover::is_git(dir.as_ref()) + })?; + let (git_dir, _work_dir) = git_discover::repository::Path::from_dot_git_dir(dir.into_owned(), kind) + .into_repository_and_work_tree_directories(); + url.path = git_path::into_bstr(git_dir).into_owned(); + } + Ok(url) + } + + let protocol = self + .repo + .config + .resolved + .integer("protocol", None, "version") + .unwrap_or(Ok(2)) + .map_err(|err| Error::UnknownProtocol { given: err.input }) + .and_then(|num| { + Ok(match num { + 1 => Protocol::V1, + 2 => Protocol::V2, + num => { + return Err(Error::UnknownProtocol { + given: num.to_string().into(), + }) + } + }) + })?; + + let url = self.url(direction).ok_or(Error::MissingUrl { direction })?.to_owned(); + let transport = git_protocol::transport::connect(sanitize(url)?, protocol).await?; + Ok(self.to_connection_with_transport(transport, progress)) + } +} diff --git a/git-repository/src/remote/connection/list_refs.rs b/git-repository/src/remote/connection/list_refs.rs new file mode 100644 index 00000000000..1d2945d64e3 --- /dev/null +++ b/git-repository/src/remote/connection/list_refs.rs @@ -0,0 +1,69 @@ +use crate::remote::connection::HandshakeWithRefs; +use crate::remote::{Connection, Direction}; +use git_features::progress::Progress; +use git_protocol::transport::client::Transport; + +mod error { + #[derive(Debug, thiserror::Error)] + pub enum Error { + #[error(transparent)] + Handshake(#[from] git_protocol::fetch::handshake::Error), + #[error(transparent)] + ListRefs(#[from] git_protocol::fetch::refs::Error), + #[error(transparent)] + Transport(#[from] git_protocol::transport::client::Error), + } +} +pub use error::Error; + +impl<'a, 'repo, T, P> Connection<'a, 'repo, T, P> +where + T: Transport, + P: Progress, +{ + /// List all references on the remote. + /// + /// Note that this doesn't fetch the objects mentioned in the tips nor does it make any change to underlying repository. + #[git_protocol::maybe_async::maybe_async] + pub async fn list_refs(mut self) -> Result, Error> { + let res = self.fetch_refs().await?; + git_protocol::fetch::indicate_end_of_interaction(&mut self.transport).await?; + Ok(res.refs) + } + + #[git_protocol::maybe_async::maybe_async] + async fn fetch_refs(&mut self) -> Result { + let mut outcome = git_protocol::fetch::handshake( + &mut self.transport, + git_protocol::credentials::helper, + Vec::new(), + &mut self.progress, + ) + .await?; + let refs = match outcome.refs.take() { + Some(refs) => refs, + None => { + git_protocol::fetch::refs( + &mut self.transport, + outcome.server_protocol_version, + &outcome.capabilities, + |_a, _b, _c| Ok(git_protocol::fetch::delegate::LsRefsAction::Continue), + &mut self.progress, + ) + .await? + } + }; + Ok(HandshakeWithRefs { outcome, refs }) + } + + /// List all references on the remote that have been filtered through our remote's [`refspecs`][crate::Remote::refspecs()] + /// for _fetching_ or _pushing_ depending on `direction`. + /// + /// This comes in the form of information of all matching tips on the remote and the object they point to, along with + /// with the local tracking branch of these tips (if available). + /// + /// Note that this doesn't fetch the objects mentioned in the tips nor does it make any change to underlying repository. + pub fn list_refs_by_refspec(&mut self, _direction: Direction) -> ! { + todo!() + } +} diff --git a/git-repository/src/remote/connection/mod.rs b/git-repository/src/remote/connection/mod.rs new file mode 100644 index 00000000000..7705e75e410 --- /dev/null +++ b/git-repository/src/remote/connection/mod.rs @@ -0,0 +1,32 @@ +use crate::Remote; + +pub(crate) struct HandshakeWithRefs { + #[allow(dead_code)] + outcome: git_protocol::fetch::handshake::Outcome, + refs: Vec, +} + +/// A type to represent an ongoing connection to a remote host, typically with the connection already established. +/// +/// It can be used to perform a variety of operations with the remote without worrying about protocol details, +/// much like a remote procedure call. +pub struct Connection<'a, 'repo, T, P> { + pub(crate) remote: &'a Remote<'repo>, + pub(crate) transport: T, + pub(crate) progress: P, +} + +mod access { + use crate::remote::Connection; + use crate::Remote; + + /// Access + impl<'a, 'repo, T, P> Connection<'a, 'repo, T, P> { + /// Drop the transport and additional state to regain the original remote. + pub fn remote(&self) -> &Remote<'repo> { + self.remote + } + } +} + +mod list_refs; diff --git a/git-repository/src/remote/errors.rs b/git-repository/src/remote/errors.rs new file mode 100644 index 00000000000..806a4758161 --- /dev/null +++ b/git-repository/src/remote/errors.rs @@ -0,0 +1,40 @@ +/// +pub mod find { + use crate::bstr::BString; + use crate::remote; + + /// The error returned by [`Repository::find_remote(…)`][crate::Repository::find_remote()]. + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error("{spec:?} {kind} ref-spec failed to parse")] + RefSpec { + spec: BString, + kind: &'static str, + source: git_refspec::parse::Error, + }, + #[error("Neither 'url` nor 'pushUrl' fields were set in the remote's configuration.")] + UrlMissing, + #[error("The {kind} url couldn't be parsed")] + Url { + kind: &'static str, + url: BString, + source: git_url::parse::Error, + }, + #[error(transparent)] + Init(#[from] remote::init::Error), + } + + /// + pub mod existing { + /// The error returned by [`Repository::find_remote(…)`][crate::Repository::find_remote()]. + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error(transparent)] + Find(#[from] super::Error), + #[error("The remote named {name:?} did not exist")] + NotFound { name: String }, + } + } +} diff --git a/git-repository/src/remote/init.rs b/git-repository/src/remote/init.rs new file mode 100644 index 00000000000..6ad08423382 --- /dev/null +++ b/git-repository/src/remote/init.rs @@ -0,0 +1,108 @@ +use crate::{config, remote, Remote, Repository}; +use git_refspec::RefSpec; +use std::convert::TryInto; + +mod error { + use crate::bstr::BString; + + /// The error returned by [`Repository::remote_at(…)`][crate::Repository::remote_at()]. + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error(transparent)] + Url(#[from] git_url::parse::Error), + #[error("The rewritten {kind} url {rewritten_url:?} failed to parse")] + RewrittenUrlInvalid { + kind: &'static str, + rewritten_url: BString, + source: git_url::parse::Error, + }, + } +} +pub use error::Error; + +/// Initialization +impl<'repo> Remote<'repo> { + pub(crate) fn from_preparsed_config( + name: Option, + url: Option, + push_url: Option, + fetch_specs: Vec, + push_specs: Vec, + should_rewrite_urls: bool, + repo: &'repo Repository, + ) -> Result { + debug_assert!( + url.is_some() || push_url.is_some(), + "BUG: fetch or push url must be set at least" + ); + let (url_alias, push_url_alias) = should_rewrite_urls + .then(|| rewrite_urls(&repo.config, url.as_ref(), push_url.as_ref())) + .unwrap_or(Ok((None, None)))?; + Ok(Remote { + name, + url, + url_alias, + push_url, + push_url_alias, + fetch_specs, + push_specs, + repo, + }) + } + + pub(crate) fn from_fetch_url( + url: Url, + should_rewrite_urls: bool, + repo: &'repo Repository, + ) -> Result + where + Url: TryInto, + git_url::parse::Error: From, + { + let url = url.try_into().map_err(|err| Error::Url(err.into()))?; + let (url_alias, _) = should_rewrite_urls + .then(|| rewrite_urls(&repo.config, Some(&url), None)) + .unwrap_or(Ok((None, None)))?; + Ok(Remote { + name: None, + url: Some(url), + url_alias, + push_url: None, + push_url_alias: None, + fetch_specs: Vec::new(), + push_specs: Vec::new(), + repo, + }) + } +} + +pub(crate) fn rewrite_url( + config: &config::Cache, + url: Option<&git_url::Url>, + direction: remote::Direction, +) -> Result, Error> { + url.and_then(|url| config.url_rewrite().longest(url, direction)) + .map(|url| { + git_url::parse(url.as_ref()).map_err(|err| Error::RewrittenUrlInvalid { + kind: match direction { + remote::Direction::Fetch => "fetch", + remote::Direction::Push => "push", + }, + source: err, + rewritten_url: url, + }) + }) + .transpose() +} + +pub(crate) fn rewrite_urls( + config: &config::Cache, + url: Option<&git_url::Url>, + push_url: Option<&git_url::Url>, +) -> Result<(Option, Option), Error> { + let url_alias = rewrite_url(config, url, remote::Direction::Fetch)?; + let push_url_alias = rewrite_url(config, push_url, remote::Direction::Push)?; + + Ok((url_alias, push_url_alias)) +} diff --git a/git-repository/src/remote/mod.rs b/git-repository/src/remote/mod.rs new file mode 100644 index 00000000000..9fc9666c66a --- /dev/null +++ b/git-repository/src/remote/mod.rs @@ -0,0 +1,38 @@ +/// The direction of an operation carried out (or to be carried out) through a remote. +#[derive(Debug, Eq, PartialEq, Copy, Clone, Hash)] +pub enum Direction { + /// Push local changes to the remote. + Push, + /// Fetch changes from the remote to the local repository. + Fetch, +} + +impl Direction { + /// Return ourselves as string suitable for use as verb in an english sentence. + pub fn as_str(&self) -> &'static str { + match self { + Direction::Push => "push", + Direction::Fetch => "fetch", + } + } +} + +mod build; + +mod errors; +pub use errors::find; + +/// +pub mod init; + +/// +#[cfg(any(feature = "async-network-client", feature = "blocking-network-client"))] +pub mod connect; + +#[cfg(any(feature = "async-network-client", feature = "blocking-network-client"))] +mod connection; +#[cfg(any(feature = "async-network-client", feature = "blocking-network-client"))] +pub use connection::Connection; + +mod access; +pub(crate) mod url; diff --git a/git-repository/src/remote/url.rs b/git-repository/src/remote/url.rs new file mode 100644 index 00000000000..f39ceda803d --- /dev/null +++ b/git-repository/src/remote/url.rs @@ -0,0 +1,96 @@ +use crate::bstr::{BStr, BString, ByteVec}; +use crate::remote::Direction; +use git_features::threading::OwnShared; + +#[derive(Debug, Clone)] +pub(crate) struct Replace { + find: BString, + with: OwnShared, +} + +#[derive(Default, Debug, Clone)] +pub(crate) struct Rewrite { + url_rewrite: Vec, + push_url_rewrite: Vec, +} + +/// Init +impl Rewrite { + pub fn from_config( + config: &git_config::File<'static>, + mut filter: fn(&git_config::file::Metadata) -> bool, + ) -> Rewrite { + config + .sections_by_name_and_filter("url", &mut filter) + .map(|sections| { + let mut url_rewrite = Vec::new(); + let mut push_url_rewrite = Vec::new(); + for section in sections { + let replace = match section.header().subsection_name() { + Some(base) => OwnShared::new(base.to_owned()), + None => continue, + }; + + for instead_of in section.values("insteadOf") { + url_rewrite.push(Replace { + with: OwnShared::clone(&replace), + find: instead_of.into_owned(), + }); + } + for instead_of in section.values("pushInsteadOf") { + push_url_rewrite.push(Replace { + with: OwnShared::clone(&replace), + find: instead_of.into_owned(), + }); + } + } + Rewrite { + url_rewrite, + push_url_rewrite, + } + }) + .unwrap_or_default() + } +} + +/// Access +impl Rewrite { + fn replacements_for(&self, direction: Direction) -> &[Replace] { + match direction { + Direction::Fetch => &self.url_rewrite, + Direction::Push => &self.push_url_rewrite, + } + } + + pub fn longest(&self, url: &git_url::Url, direction: Direction) -> Option { + if self.replacements_for(direction).is_empty() { + None + } else { + let mut url = url.to_bstring(); + self.rewrite_url_in_place(&mut url, direction).then(|| url) + } + } + + /// Rewrite the given `url` of `direction` and return `true` if a replacement happened. + /// + /// Note that the result must still be checked for validity, it might not be a valid URL as we do a syntax-unaware replacement. + pub fn rewrite_url_in_place(&self, url: &mut BString, direction: Direction) -> bool { + self.replacements_for(direction) + .iter() + .fold(None::<(usize, &BStr)>, |mut acc, replace| { + if url.starts_with(replace.find.as_ref()) { + let (bytes_matched, prev_rewrite_with) = + acc.get_or_insert((replace.find.len(), replace.with.as_slice().into())); + if *bytes_matched < replace.find.len() { + *bytes_matched = replace.find.len(); + *prev_rewrite_with = replace.with.as_slice().into(); + } + }; + acc + }) + .map(|(bytes_matched, replace_with)| { + url.replace_range(..bytes_matched, replace_with); + }) + .is_some() + } +} diff --git a/git-repository/src/repository/config.rs b/git-repository/src/repository/config.rs index dd5cf205356..120fc16fdbf 100644 --- a/git-repository/src/repository/config.rs +++ b/git-repository/src/repository/config.rs @@ -1,13 +1,20 @@ +use crate::bstr::ByteSlice; use crate::config; +use std::collections::BTreeSet; -/// Configuration +/// General Configuration impl crate::Repository { - /// Return /// Return a snapshot of the configuration as seen upon opening the repository. pub fn config_snapshot(&self) -> config::Snapshot<'_> { config::Snapshot { repo: self } } + /// Return a mutable snapshot of the configuration as seen upon opening the repository. + pub fn config_snapshot_mut(&mut self) -> config::SnapshotMut<'_> { + let config = self.config.resolved.as_ref().clone(); + config::SnapshotMut { repo: self, config } + } + /// The options used to open the repository. pub fn open_options(&self) -> &crate::open::Options { &self.options @@ -18,3 +25,113 @@ impl crate::Repository { self.config.object_hash } } + +mod remote { + use crate::bstr::ByteSlice; + use crate::remote; + use std::borrow::Cow; + use std::collections::BTreeSet; + + impl crate::Repository { + /// Returns a sorted list unique of symbolic names of remotes that + /// we deem [trustworthy][crate::open::Options::filter_config_section()]. + pub fn remote_names(&self) -> BTreeSet<&str> { + self.subsection_names_of("remote") + } + + /// Obtain the branch-independent name for a remote for use in the given `direction`, or `None` if it could not be determined. + /// + /// For _fetching_, use the only configured remote, or default to `origin` if it exists. + /// For _pushing_, use the `remote.pushDefault` trusted configuration key, or fall back to the rules for _fetching_. + /// + /// # Notes + /// + /// It's up to the caller to determine what to do if the current `head` is unborn or detached. + pub fn remote_default_name(&self, direction: remote::Direction) -> Option> { + let name = (direction == remote::Direction::Push) + .then(|| { + self.config + .resolved + .string_filter("remote", None, "pushDefault", &mut self.filter_config_section()) + .and_then(|s| match s { + Cow::Borrowed(s) => s.to_str().ok().map(Cow::Borrowed), + Cow::Owned(s) => s.to_str().ok().map(|s| Cow::Owned(s.into())), + }) + }) + .flatten(); + name.or_else(|| { + let names = self.remote_names(); + match names.len() { + 0 => None, + 1 => names.iter().next().copied().map(Cow::Borrowed), + _more_than_one => names.get("origin").copied().map(Cow::Borrowed), + } + }) + } + } +} + +mod branch { + use std::collections::BTreeSet; + use std::{borrow::Cow, convert::TryInto}; + + use git_ref::FullNameRef; + use git_validate::reference::name::Error as ValidateNameError; + + use crate::bstr::BStr; + + impl crate::Repository { + /// Return a set of unique short branch names for which custom configuration exists in the configuration, + /// if we deem them [trustworthy][crate::open::Options::filter_config_section()]. + pub fn branch_names(&self) -> BTreeSet<&str> { + self.subsection_names_of("branch") + } + + /// Returns a reference to the remote associated with the given `short_branch_name`, + /// always `main` instead of `refs/heads/main`. + /// + /// The remote-ref is the one we track on the remote side for merging and pushing. + /// Returns `None` if the remote reference was not found. + /// May return an error if the reference is invalid. + pub fn branch_remote_ref( + &self, + short_branch_name: &str, + ) -> Option, ValidateNameError>> { + self.config + .resolved + .string("branch", Some(short_branch_name), "merge") + .map(|v| match v { + Cow::Borrowed(v) => v.try_into().map(Cow::Borrowed), + Cow::Owned(v) => v.try_into().map(Cow::Owned), + }) + } + + /// Returns the name of the remote associated with the given `short_branch_name`, typically `main` instead of `refs/heads/main`. + /// In some cases, the returned name will be an URL. + /// Returns `None` if the remote was not found. + pub fn branch_remote_name(&self, short_branch_name: &str) -> Option> { + self.config.resolved.string("branch", Some(short_branch_name), "remote") + } + } +} + +impl crate::Repository { + pub(crate) fn filter_config_section(&self) -> fn(&git_config::file::Metadata) -> bool { + self.options + .filter_config_section + .unwrap_or(config::section::is_trusted) + } + + fn subsection_names_of<'a>(&'a self, header_name: &'a str) -> BTreeSet<&'a str> { + self.config + .resolved + .sections_by_name(header_name) + .map(|it| { + let filter = self.filter_config_section(); + it.filter(move |s| filter(s.meta())) + .filter_map(|section| section.header().subsection_name().and_then(|b| b.to_str().ok())) + .collect() + }) + .unwrap_or_default() + } +} diff --git a/git-repository/src/repository/remote.rs b/git-repository/src/repository/remote.rs index 7d032d4e944..ef6af86f9b2 100644 --- a/git-repository/src/repository/remote.rs +++ b/git-repository/src/repository/remote.rs @@ -1,28 +1,144 @@ -use std::{borrow::Cow, convert::TryInto}; +use crate::remote::find; +use crate::{remote, Remote}; +use std::convert::TryInto; -use git_ref::FullNameRef; -use git_validate::reference::name::Error as ValidateNameError; +impl crate::Repository { + /// Create a new remote available at the given `url`. + pub fn remote_at(&self, url: Url) -> Result, remote::init::Error> + where + Url: TryInto, + git_url::parse::Error: From, + { + Remote::from_fetch_url(url, true, self) + } + + /// Create a new remote available at the given `url`, but don't rewrite the url according to rewrite rules. + /// This eliminates a failure mode in case the rewritten URL is faulty, allowing to selectively [apply rewrite + /// rules][Remote::rewrite_urls()] later and do so non-destructively. + pub fn remote_at_without_url_rewrite(&self, url: Url) -> Result, remote::init::Error> + where + Url: TryInto, + git_url::parse::Error: From, + { + Remote::from_fetch_url(url, false, self) + } -use crate::bstr::BStr; + /// Find the remote with the given `name` or report an error, similar to [`try_find_remote(…)`][Self::try_find_remote()]. + /// + /// Note that we will include remotes only if we deem them [trustworthy][crate::open::Options::filter_config_section()]. + pub fn find_remote(&self, name: &str) -> Result, find::existing::Error> { + Ok(self + .try_find_remote(name) + .ok_or_else(|| find::existing::Error::NotFound { name: name.into() })??) + } -impl crate::Repository { - /// Returns a reference to the remote associated with the given `short_branch_name`, typically `main` instead of `refs/heads/main`. - /// Returns `None` if the remote reference was not found. - /// May return an error if the reference is invalid. - pub fn remote_ref(&self, short_branch_name: &str) -> Option, ValidateNameError>> { - self.config - .resolved - .string("branch", Some(short_branch_name), "merge") - .map(|v| match v { - Cow::Borrowed(v) => v.try_into().map(Cow::Borrowed), - Cow::Owned(v) => v.try_into().map(Cow::Owned), - }) + /// Find the remote with the given `name` or return `None` if it doesn't exist, for the purpose of fetching or pushing + /// data to a remote. + /// + /// There are various error kinds related to partial information or incorrectly formatted URLs or ref-specs. + /// Also note that the created `Remote` may have neither fetch nor push ref-specs set at all. + /// + /// Note that ref-specs are de-duplicated right away which may change their order. This doesn't affect matching in any way + /// as negations/excludes are applied after includes. + /// + /// We will only include information if we deem it [trustworthy][crate::open::Options::filter_config_section()]. + pub fn try_find_remote(&self, name: &str) -> Option, find::Error>> { + self.try_find_remote_inner(name, true) } - /// Returns the name of the remote associated with the given `short_branch_name`, typically `main` instead of `refs/heads/main`. - /// In some cases, the returned name will be an URL. - /// Returns `None` if the remote was not found. - pub fn branch_remote_name(&self, short_branch_name: &str) -> Option> { - self.config.resolved.string("branch", Some(short_branch_name), "remote") + /// Similar to [try_find_remote()][Self::try_find_remote()], but removes a failure mode if rewritten URLs turn out to be invalid + /// as it skips rewriting them. + /// Use this in conjunction with [`Remote::rewrite_urls()`] to non-destructively apply the rules and keep the failed urls unchanged. + pub fn try_find_remote_without_url_rewrite(&self, name: &str) -> Option, find::Error>> { + self.try_find_remote_inner(name, false) + } + + fn try_find_remote_inner(&self, name: &str, rewrite_urls: bool) -> Option, find::Error>> { + let mut filter = self.filter_config_section(); + let mut config_url = |field: &str, kind: &'static str| { + self.config + .resolved + .string_filter("remote", name.into(), field, &mut filter) + .map(|url| { + git_url::parse::parse(url.as_ref()).map_err(|err| find::Error::Url { + kind, + url: url.into_owned(), + source: err, + }) + }) + }; + let url = config_url("url", "fetch"); + let push_url = config_url("pushUrl", "push"); + + let mut config_spec = |op: git_refspec::parse::Operation| { + let kind = match op { + git_refspec::parse::Operation::Fetch => "fetch", + git_refspec::parse::Operation::Push => "push", + }; + self.config + .resolved + .strings_filter("remote", name.into(), kind, &mut filter) + .map(|specs| { + specs + .into_iter() + .map(|spec| { + git_refspec::parse(spec.as_ref(), op) + .map(|spec| spec.to_owned()) + .map_err(|err| find::Error::RefSpec { + spec: spec.into_owned(), + kind, + source: err, + }) + }) + .collect::, _>>() + .map(|mut specs| { + specs.sort(); + specs.dedup(); + specs + }) + }) + }; + let fetch_specs = config_spec(git_refspec::parse::Operation::Fetch); + let push_specs = config_spec(git_refspec::parse::Operation::Push); + + match (url, fetch_specs, push_url, push_specs) { + (None, None, None, None) => None, + (None, _, None, _) => Some(Err(find::Error::UrlMissing)), + (url, fetch_specs, push_url, push_specs) => { + let url = match url { + Some(Ok(v)) => Some(v), + Some(Err(err)) => return Some(Err(err)), + None => None, + }; + let push_url = match push_url { + Some(Ok(v)) => Some(v), + Some(Err(err)) => return Some(Err(err)), + None => None, + }; + let fetch_specs = match fetch_specs { + Some(Ok(v)) => v, + Some(Err(err)) => return Some(Err(err)), + None => Vec::new(), + }; + let push_specs = match push_specs { + Some(Ok(v)) => v, + Some(Err(err)) => return Some(Err(err)), + None => Vec::new(), + }; + + Some( + Remote::from_preparsed_config( + name.to_owned().into(), + url, + push_url, + fetch_specs, + push_specs, + rewrite_urls, + self, + ) + .map_err(Into::into), + ) + } + } } } diff --git a/git-repository/src/types.rs b/git-repository/src/types.rs index 18bb9107dcd..6c2a401f3aa 100644 --- a/git-repository/src/types.rs +++ b/git-repository/src/types.rs @@ -179,3 +179,28 @@ pub struct ThreadSafeRepository { /// The index of this instances worktree. pub(crate) index: crate::worktree::IndexStorage, } + +/// A remote which represents a way to interact with hosts for remote clones of the parent repository. +#[derive(Debug, Clone, PartialEq)] +pub struct Remote<'repo> { + /// The remotes symbolic name, only present if persisted in git configuration files. + pub(crate) name: Option, + /// The url of the host to talk to, after application of replacements. If it is unset, the `push_url` must be set. + /// and fetches aren't possible. + pub(crate) url: Option, + /// The rewritten `url`, if it was rewritten. + pub(crate) url_alias: Option, + /// The url to use for pushing specifically. + pub(crate) push_url: Option, + /// The rewritten `push_url`, if it was rewritten. + pub(crate) push_url_alias: Option, + /// Refspecs for use when fetching. + pub(crate) fetch_specs: Vec, + /// Refspecs for use when pushing. + pub(crate) push_specs: Vec, + // /// Delete local tracking branches that don't exist on the remote anymore. + // pub(crate) prune: bool, + // /// Delete tags that don't exist on the remote anymore, equivalent to pruning the refspec `refs/tags/*:refs/tags/*`. + // pub(crate) prune_tags: bool, + pub(crate) repo: &'repo Repository, +} diff --git a/git-repository/tests/fixtures/generated-archives/.gitignore b/git-repository/tests/fixtures/generated-archives/.gitignore index 2353812249c..9250e9825f6 100644 --- a/git-repository/tests/fixtures/generated-archives/.gitignore +++ b/git-repository/tests/fixtures/generated-archives/.gitignore @@ -1,2 +1,3 @@ /make_worktree_repo.tar.xz +/make_remote_repos.tar.xz /make_core_worktree_repo.tar.xz diff --git a/git-repository/tests/fixtures/make_remote_repos.sh b/git-repository/tests/fixtures/make_remote_repos.sh new file mode 100644 index 00000000000..4716a0a4325 --- /dev/null +++ b/git-repository/tests/fixtures/make_remote_repos.sh @@ -0,0 +1,173 @@ +set -eu -o pipefail + +function tick () { + if test -z "${tick+set}" + then + tick=1112911993 + else + tick=$(($tick + 60)) + fi + GIT_COMMITTER_DATE="$tick -0700" + GIT_AUTHOR_DATE="$tick -0700" + export GIT_COMMITTER_DATE GIT_AUTHOR_DATE +} + +GIT_AUTHOR_EMAIL=author@example.com +GIT_AUTHOR_NAME='A U Thor' +GIT_AUTHOR_DATE='1112354055 +0200' +TEST_COMMITTER_LOCALNAME=committer +TEST_COMMITTER_DOMAIN=example.com +GIT_COMMITTER_EMAIL=committer@example.com +GIT_COMMITTER_NAME='C O Mitter' +GIT_COMMITTER_DATE='1112354055 +0200' + +# runup to the correct count for ambigous commits +tick; tick; tick; tick; tick + +git init base +( + cd base + tick + + echo g > file + git add file && git commit -m $'G\n\n initial message' + git branch g + + tick + git checkout --orphan=h + echo h > file + git add file && git commit -m H + + tick + git checkout main + git merge h --allow-unrelated-histories || : + { echo g && echo h && echo d; } > file + git add file + git commit -m D + git branch d + + tick + git checkout --orphan=i + echo i > file + git add file && git commit -m I + git tag -m I-tag i-tag + + tick + git checkout --orphan=j + echo j > file + git add file && git commit -m J + + tick + git checkout i + git merge j --allow-unrelated-histories || : + { echo i && echo j && echo f; } > file + git add file + git commit -m F + git branch f + + tick + git checkout --orphan=e + echo e > file + git add file && git commit -m E + + tick + git checkout main + git merge e i --allow-unrelated-histories || : + { echo g && echo h && echo i && echo j && echo d && echo e && echo f && echo b; } > file + git add file && git commit -m B + git tag -m b-tag b-tag && git branch b + + tick + git checkout i + echo c >> file + git add file && git commit -m $'C\n\n message recent' + git branch c + git reset --hard i-tag + + tick + git checkout main + git merge c || : + { echo g && echo h && echo i && echo j && echo d && echo e && echo f && echo b && echo c && echo a; } > file + git add file && git commit -m A + git branch a +) + +git clone --shared base clone +(cd clone + git remote add myself . +) + +git clone --shared base push-default +(cd push-default + + git remote add myself . + git remote rename origin new-origin + git config remote.pushDefault myself +) + +git clone --shared base push-url +(cd push-url + git config remote.origin.pushUrl . + git config remote.origin.push refs/tags/*:refs/tags/* +) + +git clone --shared base many-fetchspecs +(cd many-fetchspecs + git config --add remote.origin.fetch @ + git config --add remote.origin.fetch refs/tags/*:refs/tags/* + git config --add remote.origin.fetch HEAD +) + +git clone --shared base branch-push-remote +(cd branch-push-remote + + git remote rename origin new-origin + git remote add myself . + git config branch.main.pushRemote myself +) + +git init --bare url-rewriting +(cd url-rewriting + + git remote add origin https://github.com/foobar/gitoxide + cat <> config + +[remote "origin"] + pushUrl = "file://dev/null" + +[url "ssh://"] + insteadOf = "https://" + pushInsteadOf = "file://" + +[url "https://github.com/byron/"] + insteadOf = https://github.com/foobar/ + pushInsteadOf = ssh://example.com/ +EOF + + { + git remote get-url origin + git remote get-url origin --push + } > baseline.git +) + +git init --bare bad-url-rewriting +(cd bad-url-rewriting + + git remote add origin https://github.com/foobar/gitoxide + cat <> config + +[remote "origin"] + pushUrl = "file://dev/null" + +[url "foo://"] + pushInsteadOf = "file://" + +[url "https://github.com/byron/"] + insteadOf = https://github.com/foobar/ +EOF + + { + git remote get-url origin + git remote get-url origin --push + } > baseline.git +) diff --git a/git-repository/tests/git-with-regex.rs b/git-repository/tests/git-with-regex.rs index 7a817b59fb1..0be6ff1efe4 100644 --- a/git-repository/tests/git-with-regex.rs +++ b/git-repository/tests/git-with-regex.rs @@ -2,9 +2,11 @@ mod util; use util::*; mod commit; +mod head; mod id; mod init; mod object; mod reference; +mod remote; mod repository; mod revision; diff --git a/git-repository/tests/git.rs b/git-repository/tests/git.rs index 00e91d969c1..305de9db9dd 100644 --- a/git-repository/tests/git.rs +++ b/git-repository/tests/git.rs @@ -6,6 +6,8 @@ use util::*; #[cfg(not(feature = "regex"))] mod commit; #[cfg(not(feature = "regex"))] +mod head; +#[cfg(not(feature = "regex"))] mod id; #[cfg(not(feature = "regex"))] mod init; @@ -14,6 +16,8 @@ mod object; #[cfg(not(feature = "regex"))] mod reference; #[cfg(not(feature = "regex"))] +mod remote; +#[cfg(not(feature = "regex"))] mod repository; #[cfg(not(feature = "regex"))] mod revision; diff --git a/git-repository/tests/head/mod.rs b/git-repository/tests/head/mod.rs new file mode 100644 index 00000000000..8bd1534d86d --- /dev/null +++ b/git-repository/tests/head/mod.rs @@ -0,0 +1,14 @@ +mod remote { + use crate::remote; + use git_repository as git; + + #[test] + fn unborn_is_none() -> crate::Result { + let repo = remote::repo("url-rewriting"); + assert_eq!( + repo.head()?.into_remote(git::remote::Direction::Fetch).transpose()?, + None + ); + Ok(()) + } +} diff --git a/git-repository/tests/reference/mod.rs b/git-repository/tests/reference/mod.rs index d5c69bd9a23..7339fc66165 100644 --- a/git-repository/tests/reference/mod.rs +++ b/git-repository/tests/reference/mod.rs @@ -63,3 +63,77 @@ mod find { Ok(()) } } + +mod remote { + use crate::remote; + use git_repository as git; + + #[test] + fn push_defaults_to_fetch() -> crate::Result { + let repo = remote::repo("many-fetchspecs"); + let head = repo.head()?; + let branch = head.clone().try_into_referent().expect("history"); + assert_eq!( + branch + .remote_name(git::remote::Direction::Push) + .expect("fallback to fetch"), + branch.remote_name(git::remote::Direction::Fetch).expect("configured"), + "push falls back to fetch" + ); + assert_eq!( + branch + .remote(git::remote::Direction::Push) + .expect("configured")? + .name() + .expect("set"), + "origin" + ); + assert_eq!( + head.into_remote(git::remote::Direction::Push) + .expect("same with branch")? + .name() + .expect("set"), + "origin" + ); + Ok(()) + } + + #[test] + fn separate_push_and_fetch() -> crate::Result { + for name in ["push-default", "branch-push-remote"] { + let repo = remote::repo(name); + let head = repo.head()?; + let branch = head.clone().try_into_referent().expect("history"); + + assert_eq!(branch.remote_name(git::remote::Direction::Push).expect("set"), "myself"); + assert_eq!( + branch.remote_name(git::remote::Direction::Fetch).expect("set"), + "new-origin" + ); + + assert_ne!( + branch.remote(git::remote::Direction::Push).transpose()?, + branch.remote(git::remote::Direction::Fetch).transpose()? + ); + assert_ne!( + head.clone().into_remote(git::remote::Direction::Push).transpose()?, + head.into_remote(git::remote::Direction::Fetch).transpose()? + ); + } + Ok(()) + } + + #[test] + fn not_configured() -> crate::Result { + let repo = remote::repo("base"); + let head = repo.head()?; + let branch = head.clone().try_into_referent().expect("history"); + + assert_eq!(branch.remote_name(git::remote::Direction::Push), None); + assert_eq!(branch.remote_name(git::remote::Direction::Fetch), None); + assert_eq!(branch.remote(git::remote::Direction::Fetch).transpose()?, None); + assert_eq!(head.into_remote(git::remote::Direction::Fetch).transpose()?, None); + + Ok(()) + } +} diff --git a/git-repository/tests/remote/list_refs.rs b/git-repository/tests/remote/list_refs.rs new file mode 100644 index 00000000000..ccdeb0baa24 --- /dev/null +++ b/git-repository/tests/remote/list_refs.rs @@ -0,0 +1,27 @@ +#[cfg(feature = "blocking-network-client")] +mod blocking_io { + use crate::remote; + use git_features::progress; + use git_repository as git; + use git_repository::remote::Direction::Fetch; + + #[test] + fn all() { + for version in [ + None, + Some(git::protocol::transport::Protocol::V2), + Some(git::protocol::transport::Protocol::V1), + ] { + let mut repo = remote::repo("clone"); + if let Some(version) = version { + repo.config_snapshot_mut() + .set_raw_value("protocol", None, "version", (version as u8).to_string().as_str()) + .unwrap(); + } + let remote = repo.find_remote("origin").unwrap(); + let connection = remote.connect(Fetch, progress::Discard).unwrap(); + let refs = connection.list_refs().unwrap(); + assert_eq!(refs.len(), 14, "it gets all remote refs, independently of the refspec."); + } + } +} diff --git a/git-repository/tests/remote/mod.rs b/git-repository/tests/remote/mod.rs new file mode 100644 index 00000000000..64aee6a0262 --- /dev/null +++ b/git-repository/tests/remote/mod.rs @@ -0,0 +1,14 @@ +use git_repository as git; +use git_testtools::scripted_fixture_repo_read_only; +use std::borrow::Cow; + +pub(crate) fn repo(name: &str) -> git::Repository { + let dir = scripted_fixture_repo_read_only("make_remote_repos.sh").unwrap(); + git::open_opts(dir.join(name), git::open::Options::isolated()).unwrap() +} + +pub(crate) fn cow_str(s: &str) -> Cow { + Cow::Borrowed(s) +} + +mod list_refs; diff --git a/git-repository/tests/repository/config.rs b/git-repository/tests/repository/config.rs index 1a3c1954c51..2149a089649 100644 --- a/git-repository/tests/repository/config.rs +++ b/git-repository/tests/repository/config.rs @@ -1,3 +1,5 @@ +use crate::Result; +use std::iter::FromIterator; use std::path::Path; use git_repository as git; @@ -5,11 +7,79 @@ use git_sec::{Access, Permission}; use git_testtools::Env; use serial_test::serial; -use crate::named_repo; +use crate::remote::cow_str; +use crate::{named_repo, remote}; + +#[test] +fn remote_and_branch_names() { + let repo = remote::repo("base"); + assert_eq!(repo.remote_names().len(), 0, "there are no remotes"); + assert_eq!(repo.branch_names().len(), 0, "there are no configured branches"); + assert_eq!(repo.remote_default_name(git::remote::Direction::Fetch), None); + assert_eq!(repo.remote_default_name(git::remote::Direction::Push), None); + + let repo = remote::repo("clone"); + assert_eq!( + Vec::from_iter(repo.remote_names().into_iter()), + vec!["myself", "origin"] + ); + assert_eq!( + repo.remote_default_name(git::remote::Direction::Fetch), + Some(cow_str("origin")) + ); + assert_eq!( + repo.remote_default_name(git::remote::Direction::Push), + Some(cow_str("origin")) + ); + assert_eq!(Vec::from_iter(repo.branch_names()), vec!["main"]); +} + +#[test] +fn remote_default_name() { + let repo = remote::repo("push-default"); + + assert_eq!( + repo.remote_default_name(git::remote::Direction::Push), + Some(cow_str("myself")), + "overridden via remote.pushDefault" + ); + + assert_eq!( + repo.remote_default_name(git::remote::Direction::Fetch), + None, + "none if name origin, and there are multiple" + ); +} + +#[test] +fn branch_remote() -> Result { + let repo = named_repo("make_remote_repo.sh")?; + + assert_eq!( + repo.branch_remote_ref("main") + .expect("Remote Merge ref exists") + .expect("Remote Merge ref is valid") + .shorten(), + "main" + ); + assert_eq!( + repo.branch_remote_name("main").expect("Remote name exists").as_ref(), + "remote_repo" + ); + + assert!(repo + .branch_remote_ref("broken") + .expect("Remote Merge ref exists") + .is_err()); + assert!(repo.branch_remote_ref("missing").is_none()); + assert!(repo.branch_remote_name("broken").is_none()); + + Ok(()) +} #[test] #[serial] -fn access_values() { +fn access_values_and_identity() { for trust in [git_sec::Trust::Full, git_sec::Trust::Reduced] { let repo = named_repo("make_config_repo.sh").unwrap(); let work_dir = repo.work_dir().expect("present").canonicalize().unwrap(); @@ -130,3 +200,77 @@ fn access_values() { } } } + +mod config_section_mut { + use crate::named_repo; + + #[test] + fn values_are_set_in_memory_only() { + let mut repo = named_repo("make_config_repo.sh").unwrap(); + let repo_clone = repo.clone(); + let key = "hallo.welt"; + let key_subsection = "hallo.unter.welt"; + assert_eq!(repo.config_snapshot().boolean(key), None, "no value there just yet"); + assert_eq!(repo.config_snapshot().string(key_subsection), None); + + { + let mut config = repo.config_snapshot_mut(); + config.set_raw_value("hallo", None, "welt", "true").unwrap(); + config.set_raw_value("hallo", Some("unter"), "welt", "value").unwrap(); + } + + assert_eq!( + repo.config_snapshot().boolean(key), + Some(true), + "value was set and applied" + ); + assert_eq!( + repo.config_snapshot().string(key_subsection).as_deref(), + Some("value".into()) + ); + + assert_eq!( + repo_clone.config_snapshot().boolean(key), + None, + "values are not written back automatically nor are they shared between clones" + ); + assert_eq!(repo_clone.config_snapshot().string(key_subsection), None); + } + + #[test] + fn apply_cli_overrides() -> crate::Result { + let mut repo = named_repo("make_config_repo.sh").unwrap(); + repo.config_snapshot_mut().apply_cli_overrides([ + "a.b=c", + "remote.origin.url = url", + "implicit.bool-true", + "implicit.bool-false = ", + ])?; + + let config = repo.config_snapshot(); + assert_eq!(config.string("a.b").expect("present").as_ref(), "c"); + assert_eq!(config.string("remote.origin.url").expect("present").as_ref(), "url"); + assert_eq!( + config.string("implicit.bool-true"), + None, + "no keysep is interpreted as 'not present' as we don't make up values" + ); + assert_eq!( + config.string("implicit.bool-false").expect("present").as_ref(), + "", + "empty values are fine" + ); + assert_eq!( + config.boolean("implicit.bool-false"), + Some(false), + "empty values are boolean true" + ); + assert_eq!( + config.boolean("implicit.bool-true"), + Some(true), + "values without key-sep are true" + ); + + Ok(()) + } +} diff --git a/git-repository/tests/repository/mod.rs b/git-repository/tests/repository/mod.rs index 7ef39b8f152..5c7168febe3 100644 --- a/git-repository/tests/repository/mod.rs +++ b/git-repository/tests/repository/mod.rs @@ -10,10 +10,11 @@ mod worktree; #[test] fn size_in_memory() { - let expected = [688, 696]; + let expected = [744, 760]; + let actual_size = std::mem::size_of::(); assert!( - expected.contains(&std::mem::size_of::()), - "size of Repository shouldn't change without us noticing, it's meant to be cloned: should have been within {:?}", - expected + expected.contains(&actual_size), + "size of Repository shouldn't change without us noticing, it's meant to be cloned: should have been within {:?}, was {}", + expected, actual_size ); } diff --git a/git-repository/tests/repository/remote.rs b/git-repository/tests/repository/remote.rs index 1d707f416ea..a0c8a8bb8c0 100644 --- a/git-repository/tests/repository/remote.rs +++ b/git-repository/tests/repository/remote.rs @@ -1,24 +1,271 @@ -use crate::{named_repo, Result}; - -#[test] -fn simple() -> Result { - let repo = named_repo("make_remote_repo.sh")?; - - assert_eq!( - repo.remote_ref("main") - .expect("Remote Merge ref exists") - .expect("Remote Merge ref is valid") - .shorten(), - "main" - ); - assert_eq!( - repo.branch_remote_name("main").expect("Remote name exists").as_ref(), - "remote_repo" - ); - - assert!(repo.remote_ref("broken").expect("Remote Merge ref exists").is_err()); - assert!(repo.remote_ref("missing").is_none()); - assert!(repo.branch_remote_name("broken").is_none()); - - Ok(()) +mod remote_at { + use crate::remote; + use git_repository::remote::Direction; + + #[test] + fn url_and_push_url() -> crate::Result { + let repo = remote::repo("base"); + let fetch_url = "https://github.com/byron/gitoxide"; + let remote = repo.remote_at(fetch_url)?; + + assert_eq!(remote.name(), None); + assert_eq!(remote.url(Direction::Fetch).unwrap().to_bstring(), fetch_url); + assert_eq!(remote.url(Direction::Push).unwrap().to_bstring(), fetch_url); + + let mut remote = remote.push_url("user@host.xz:./relative")?; + assert_eq!( + remote.url(Direction::Push).unwrap().to_bstring(), + "ssh://user@host.xz/relative" + ); + assert_eq!(remote.url(Direction::Fetch).unwrap().to_bstring(), fetch_url); + + for (spec, direction) in [ + ("refs/heads/push", Direction::Push), + ("refs/heads/fetch", Direction::Fetch), + ] { + assert_eq!( + remote.refspecs(direction), + &[], + "no specs are preset for newly created remotes" + ); + remote = remote.with_refspec(spec, direction)?; + assert_eq!(remote.refspecs(direction).len(), 1, "the new refspec was added"); + + remote = remote.with_refspec(spec, direction)?; + assert_eq!(remote.refspecs(direction).len(), 1, "duplicates are disallowed"); + } + + Ok(()) + } + + #[test] + fn url_rewrites_are_respected() -> crate::Result { + let repo = remote::repo("url-rewriting"); + let remote = repo.remote_at("https://github.com/foobar/gitoxide")?; + + assert_eq!(remote.name(), None, "anonymous remotes are unnamed"); + let rewritten_fetch_url = "https://github.com/byron/gitoxide"; + assert_eq!( + remote.url(Direction::Fetch).unwrap().to_bstring(), + rewritten_fetch_url, + "fetch was rewritten" + ); + assert_eq!( + remote.url(Direction::Push).unwrap().to_bstring(), + rewritten_fetch_url, + "push is the same as fetch was rewritten" + ); + + let remote = repo + .remote_at("https://github.com/foobar/gitoxide".to_owned())? + .push_url("file://dev/null".to_owned())?; + assert_eq!(remote.url(Direction::Fetch).unwrap().to_bstring(), rewritten_fetch_url); + assert_eq!( + remote.url(Direction::Push).unwrap().to_bstring(), + "ssh://dev/null", + "push-url rewrite rules are applied" + ); + Ok(()) + } + + #[test] + fn url_rewrites_can_be_skipped() -> crate::Result { + let repo = remote::repo("url-rewriting"); + let remote = repo.remote_at_without_url_rewrite("https://github.com/foobar/gitoxide")?; + + assert_eq!(remote.name(), None, "anonymous remotes are unnamed"); + let fetch_url = "https://github.com/foobar/gitoxide"; + assert_eq!( + remote.url(Direction::Fetch).unwrap().to_bstring(), + fetch_url, + "fetch was rewritten" + ); + assert_eq!( + remote.url(Direction::Push).unwrap().to_bstring(), + fetch_url, + "push is the same as fetch was rewritten" + ); + + let remote = repo + .remote_at_without_url_rewrite("https://github.com/foobar/gitoxide".to_owned())? + .push_url_without_url_rewrite("file://dev/null".to_owned())?; + assert_eq!(remote.url(Direction::Fetch).unwrap().to_bstring(), fetch_url); + assert_eq!( + remote.url(Direction::Push).unwrap().to_bstring(), + "file://dev/null", + "push-url rewrite rules are not applied" + ); + Ok(()) + } +} + +mod find_remote { + use crate::remote; + use git_object::bstr::BString; + use git_repository as git; + use git_repository::remote::Direction; + use git_repository::Repository; + use std::io::BufRead; + + #[test] + fn typical() { + let repo = remote::repo("clone"); + let mut count = 0; + let base_dir = base_dir(&repo); + let expected = [ + (".", "+refs/heads/*:refs/remotes/myself/*"), + (base_dir.as_str(), "+refs/heads/*:refs/remotes/origin/*"), + ]; + for (name, (url, refspec)) in repo.remote_names().into_iter().zip(expected) { + count += 1; + let remote = repo.find_remote(name).expect("no error"); + assert_eq!(remote.name(), Some(name)); + + let url = git::url::parse(url.into()).expect("valid"); + assert_eq!(remote.url(Direction::Fetch).unwrap(), &url); + + assert_eq!( + remote.refspecs(Direction::Fetch), + &[fetchspec(refspec)], + "default refspecs are set by git" + ); + assert_eq!( + remote.refspecs(Direction::Push), + &[], + "push-specs aren't configured by default" + ); + } + assert!(count > 0, "should have seen more than one commit"); + assert!(matches!( + repo.find_remote("unknown").unwrap_err(), + git::remote::find::existing::Error::NotFound { .. } + )); + } + + #[test] + fn push_url_and_push_specs() { + let repo = remote::repo("push-url"); + let remote = repo.find_remote("origin").expect("present"); + assert_eq!(remote.url(Direction::Push).unwrap().path, "."); + assert_eq!(remote.url(Direction::Fetch).unwrap().path, base_dir(&repo)); + assert_eq!(remote.refspecs(Direction::Push), &[pushspec("refs/tags/*:refs/tags/*")]) + } + + #[test] + fn many_fetchspecs() { + let repo = remote::repo("many-fetchspecs"); + let remote = repo.find_remote("origin").expect("present"); + assert_eq!( + remote.refspecs(Direction::Fetch), + &[ + fetchspec("HEAD"), + fetchspec("+refs/heads/*:refs/remotes/origin/*"), + fetchspec("refs/tags/*:refs/tags/*") + ] + ) + } + + #[test] + fn instead_of_url_rewriting() -> crate::Result { + let repo = remote::repo("url-rewriting"); + + let baseline = std::fs::read(repo.git_dir().join("baseline.git"))?; + let mut baseline = baseline.lines().filter_map(Result::ok); + let expected_fetch_url: BString = baseline.next().expect("fetch").into(); + let expected_push_url: BString = baseline.next().expect("push").into(); + + let remote = repo.find_remote("origin")?; + assert_eq!(remote.url(Direction::Fetch).unwrap().to_bstring(), expected_fetch_url,); + { + let actual_push_url = remote.url(Direction::Push).unwrap().to_bstring(); + assert_ne!( + actual_push_url, expected_push_url, + "here we actually resolve something that git doesn't probably because it's missing the host. Our parser is OK with it for some reason." + ); + assert_eq!(actual_push_url, "ssh://dev/null", "file:// gets replaced actually"); + } + + let mut remote = repo.try_find_remote_without_url_rewrite("origin").expect("exists")?; + assert_eq!( + remote.url(Direction::Fetch).unwrap().to_bstring(), + "https://github.com/foobar/gitoxide" + ); + assert_eq!(remote.url(Direction::Push).unwrap().to_bstring(), "file://dev/null"); + remote.rewrite_urls()?; + assert_eq!(remote.url(Direction::Push).unwrap().to_bstring(), "ssh://dev/null"); + Ok(()) + } + + #[test] + fn bad_url_rewriting_can_be_handled_much_like_git() -> crate::Result { + let repo = remote::repo("bad-url-rewriting"); + + let baseline = std::fs::read(repo.git_dir().join("baseline.git"))?; + let mut baseline = baseline.lines().filter_map(Result::ok); + let expected_fetch_url: BString = baseline.next().expect("fetch").into(); + let expected_push_url: BString = baseline.next().expect("push").into(); + assert_eq!( + expected_push_url, "file://dev/null", + "git leaves the failed one as is without any indication…" + ); + assert_eq!( + expected_fetch_url, "https://github.com/byron/gitoxide", + "…but is able to replace the fetch url successfully" + ); + + let expected_err_msg = "The rewritten push url \"foo://dev/null\" failed to parse"; + assert_eq!( + repo.find_remote("origin").unwrap_err().to_string(), + expected_err_msg, + "this fails by default as rewrites fail" + ); + + let mut remote = repo.try_find_remote_without_url_rewrite("origin").expect("exists")?; + for round in 1..=2 { + if round == 1 { + assert_eq!( + remote.url(Direction::Fetch).unwrap().to_bstring(), + "https://github.com/foobar/gitoxide", + "no rewrite happened" + ); + } else { + assert_eq!( + remote.url(Direction::Fetch).unwrap().to_bstring(), + "https://github.com/byron/gitoxide", + "it can rewrite a single url like git can" + ); + } + assert_eq!(remote.url(Direction::Push).unwrap().to_bstring(), "file://dev/null",); + assert_eq!( + remote.rewrite_urls().unwrap_err().to_string(), + expected_err_msg, + "rewriting fails, but it will rewrite what it can while reporting a single error." + ); + } + Ok(()) + } + + fn fetchspec(spec: &str) -> git_refspec::RefSpec { + git::refspec::parse(spec.into(), git::refspec::parse::Operation::Fetch) + .unwrap() + .to_owned() + } + + fn pushspec(spec: &str) -> git_refspec::RefSpec { + git::refspec::parse(spec.into(), git::refspec::parse::Operation::Push) + .unwrap() + .to_owned() + } + + fn base_dir(repo: &Repository) -> String { + git_path::to_unix_separators_on_windows(git::path::into_bstr( + git::path::realpath(repo.work_dir().unwrap()) + .unwrap() + .parent() + .unwrap() + .join("base"), + )) + .into_owned() + .to_string() + } } diff --git a/git-transport/Cargo.toml b/git-transport/Cargo.toml index 26b495296ea..3a2c16b564c 100644 --- a/git-transport/Cargo.toml +++ b/git-transport/Cargo.toml @@ -54,7 +54,6 @@ git-sec = { version = "^0.3.0", path = "../git-sec" } git-packetline = { version = "^0.12.6", path = "../git-packetline" } serde = { version = "1.0.114", optional = true, default-features = false, features = ["std", "derive"]} -quick-error = "2.0.0" bstr = { version = "0.2.13", default-features = false, features = ["std"] } # for async-client @@ -69,6 +68,10 @@ curl = { version = "0.4", optional = true, features = ["static-curl", "static-ss thiserror = "1.0.26" base64 = { version = "0.13.0", optional = true } +## If used in conjunction with `async-client`, the `connect()` method will be come available along with supporting the git protocol over TCP, +## where the TCP stream is created using this crate. +async-std = { version = "1.12.0", optional = true } + document-features = { version = "0.2.0", optional = true } [dev-dependencies] diff --git a/git-transport/src/client/async_io/connect.rs b/git-transport/src/client/async_io/connect.rs new file mode 100644 index 00000000000..2ebf347f6f5 --- /dev/null +++ b/git-transport/src/client/async_io/connect.rs @@ -0,0 +1,47 @@ +pub use crate::client::non_io_types::connect::Error; + +#[cfg(any(feature = "async-std"))] +pub(crate) mod function { + use crate::client::git; + use crate::client::non_io_types::connect::Error; + use std::convert::TryInto; + + /// A general purpose connector connecting to a repository identified by the given `url`. + /// + /// This includes connections to + /// [git daemons][crate::client::git::connect()] only at the moment. + /// + /// Use `desired_version` to set the desired protocol version to use when connecting, but note that the server may downgrade it. + pub async fn connect( + url: Url, + desired_version: crate::Protocol, + ) -> Result, Error> + where + Url: TryInto, + git_url::parse::Error: From, + { + let mut url = url.try_into().map_err(git_url::parse::Error::from)?; + Ok(match url.scheme { + git_url::Scheme::Git => { + if url.user().is_some() { + return Err(Error::UnsupportedUrlTokens { + url: url.to_bstring(), + scheme: url.scheme, + }); + } + let path = std::mem::take(&mut url.path); + Box::new( + git::Connection::new_tcp( + url.host().expect("host is present in url"), + url.port, + path, + desired_version, + ) + .await + .map_err(|e| Box::new(e) as Box)?, + ) + } + scheme => return Err(Error::UnsupportedScheme(scheme)), + }) + } +} diff --git a/git-transport/src/client/async_io/mod.rs b/git-transport/src/client/async_io/mod.rs index f6cf6165a07..2b12f2da802 100644 --- a/git-transport/src/client/async_io/mod.rs +++ b/git-transport/src/client/async_io/mod.rs @@ -8,6 +8,6 @@ mod traits; pub use traits::{SetServiceResponse, Transport, TransportV2Ext}; /// -pub mod connect { - pub use crate::client::non_io_types::connect::Error; -} +pub mod connect; +#[cfg(any(feature = "async-std"))] +pub use connect::function::connect; diff --git a/git-transport/src/client/blocking_io/connect.rs b/git-transport/src/client/blocking_io/connect.rs index ca3e9c6dcb2..13522d77476 100644 --- a/git-transport/src/client/blocking_io/connect.rs +++ b/git-transport/src/client/blocking_io/connect.rs @@ -1,62 +1,75 @@ pub use crate::client::non_io_types::connect::Error; -use crate::client::Transport; -/// A general purpose connector connecting to a repository identified by the given `url`. -/// -/// This includes connections to -/// [local repositories][crate::client::file::connect()], -/// [repositories over ssh][crate::client::ssh::connect()], -/// [git daemons][crate::client::git::connect()], -/// and if compiled in connections to [git repositories over https][crate::client::http::connect()]. -/// -/// Use `desired_version` to set the desired protocol version to use when connecting, but not that the server may downgrade it. -pub fn connect(url: &[u8], desired_version: crate::Protocol) -> Result, Error> { - let urlb = url; - let url = git_url::parse(urlb)?; - Ok(match url.scheme { - git_url::Scheme::Radicle => return Err(Error::UnsupportedScheme(url.scheme)), - git_url::Scheme::File => { - if url.user.is_some() || url.host.is_some() || url.port.is_some() { - return Err(Error::UnsupportedUrlTokens(urlb.into(), url.scheme)); - } - Box::new( - crate::client::blocking_io::file::connect(url.path, desired_version) - .map_err(|e| Box::new(e) as Box)?, - ) - } - git_url::Scheme::Ssh => Box::new( - crate::client::blocking_io::ssh::connect( - url.host.as_ref().expect("host is present in url"), - url.path, - desired_version, - url.user.as_deref(), - url.port, - ) - .map_err(|e| Box::new(e) as Box)?, - ), - git_url::Scheme::Git => { - if url.user.is_some() { - return Err(Error::UnsupportedUrlTokens(urlb.into(), url.scheme)); +pub(crate) mod function { + use crate::client::non_io_types::connect::Error; + use crate::client::Transport; + use std::convert::TryInto; + + /// A general purpose connector connecting to a repository identified by the given `url`. + /// + /// This includes connections to + /// [local repositories][crate::client::file::connect()], + /// [repositories over ssh][crate::client::ssh::connect()], + /// [git daemons][crate::client::git::connect()], + /// and if compiled in connections to [git repositories over https][crate::client::http::connect()]. + /// + /// Use `desired_version` to set the desired protocol version to use when connecting, but note that the server may downgrade it. + pub fn connect(url: Url, desired_version: crate::Protocol) -> Result, Error> + where + Url: TryInto, + git_url::parse::Error: From, + { + let mut url = url.try_into().map_err(git_url::parse::Error::from)?; + Ok(match url.scheme { + git_url::Scheme::Radicle => return Err(Error::UnsupportedScheme(url.scheme)), + git_url::Scheme::File => { + if url.user().is_some() || url.host().is_some() || url.port.is_some() { + return Err(Error::UnsupportedUrlTokens { + url: url.to_bstring(), + scheme: url.scheme, + }); + } + Box::new( + crate::client::blocking_io::file::connect(url.path, desired_version) + .map_err(|e| Box::new(e) as Box)?, + ) } - Box::new( - crate::client::git::connect( - url.host.as_ref().expect("host is present in url"), - url.path, + git_url::Scheme::Ssh => Box::new({ + let path = std::mem::take(&mut url.path); + crate::client::blocking_io::ssh::connect( + url.host().expect("host is present in url"), + path, desired_version, + url.user(), url.port, ) - .map_err(|e| Box::new(e) as Box)?, - ) - } - #[cfg(not(feature = "http-client-curl"))] - git_url::Scheme::Https | git_url::Scheme::Http => return Err(Error::CompiledWithoutHttp(url.scheme)), - #[cfg(feature = "http-client-curl")] - git_url::Scheme::Https | git_url::Scheme::Http => { - use bstr::ByteSlice; - Box::new( - crate::client::http::connect(urlb.to_str()?, desired_version) + .map_err(|e| Box::new(e) as Box)? + }), + git_url::Scheme::Git => { + if url.user().is_some() { + return Err(Error::UnsupportedUrlTokens { + url: url.to_bstring(), + scheme: url.scheme, + }); + } + Box::new({ + let path = std::mem::take(&mut url.path); + crate::client::git::connect( + url.host().expect("host is present in url"), + path, + desired_version, + url.port, + ) + .map_err(|e| Box::new(e) as Box)? + }) + } + #[cfg(not(feature = "http-client-curl"))] + git_url::Scheme::Https | git_url::Scheme::Http => return Err(Error::CompiledWithoutHttp(url.scheme)), + #[cfg(feature = "http-client-curl")] + git_url::Scheme::Https | git_url::Scheme::Http => Box::new( + crate::client::http::connect(&url.to_bstring().to_string(), desired_version) .map_err(|e| Box::new(e) as Box)?, - ) - } - }) + ), + }) + } } diff --git a/git-transport/src/client/blocking_io/file.rs b/git-transport/src/client/blocking_io/file.rs index a6861ba8092..0e23075acd2 100644 --- a/git-transport/src/client/blocking_io/file.rs +++ b/git-transport/src/client/blocking_io/file.rs @@ -70,13 +70,7 @@ impl SpawnProcessOnDemand { } fn new_local(path: BString, version: Protocol) -> SpawnProcessOnDemand { SpawnProcessOnDemand { - url: git_url::Url { - scheme: git_url::Scheme::File, - user: None, - host: None, - port: None, - path: path.clone(), - }, + url: git_url::Url::from_parts(git_url::Scheme::File, None, None, None, path.clone()).expect("valid url"), path, ssh_program: None, ssh_args: Vec::new(), @@ -101,7 +95,7 @@ impl client::TransportWithoutIO for SpawnProcessOnDemand { } fn to_url(&self) -> String { - self.url.to_string() + self.url.to_bstring().to_string() } fn connection_persists_across_multiple_requests(&self) -> bool { diff --git a/git-transport/src/client/blocking_io/http/curl/remote.rs b/git-transport/src/client/blocking_io/http/curl/remote.rs index 64fb0975db6..eac4d8414b4 100644 --- a/git-transport/src/client/blocking_io/http/curl/remote.rs +++ b/git-transport/src/client/blocking_io/http/curl/remote.rs @@ -181,6 +181,8 @@ pub fn new() -> ( impl From for http::Error { fn from(err: curl::Error) -> Self { - http::Error::Detail(err.to_string()) + http::Error::Detail { + description: err.to_string(), + } } } diff --git a/git-transport/src/client/blocking_io/http/mod.rs b/git-transport/src/client/blocking_io/http/mod.rs index 1c8d7053bc9..8c6b5269fdf 100644 --- a/git-transport/src/client/blocking_io/http/mod.rs +++ b/git-transport/src/client/blocking_io/http/mod.rs @@ -1,7 +1,7 @@ use std::{ borrow::Cow, convert::Infallible, - io::{self, BufRead, Read}, + io::{BufRead, Read}, }; use git_packetline::PacketLineRef; @@ -26,9 +26,9 @@ pub type Impl = curl::Curl; pub struct Transport { url: String, user_agent_header: &'static str, - desired_version: crate::Protocol, - supported_versions: [crate::Protocol; 1], - actual_version: crate::Protocol, + desired_version: Protocol, + supported_versions: [Protocol; 1], + actual_version: Protocol, http: H, service: Option, line_provider: Option>, @@ -37,7 +37,7 @@ pub struct Transport { impl Transport { /// Create a new instance to communicate to `url` using the given `desired_version` of the `git` protocol. - pub fn new(url: &str, desired_version: crate::Protocol) -> Self { + pub fn new(url: &str, desired_version: Protocol) -> Self { Transport { url: url.to_owned(), user_agent_header: concat!("User-Agent: git/oxide-", env!("CARGO_PKG_VERSION")), @@ -61,10 +61,12 @@ impl Transport { .iter() .any(|l| l == &wanted_content_type) { - return Err(client::Error::Http(Error::Detail(format!( - "Didn't find '{}' header to indicate 'smart' protocol, and 'dumb' protocol is not supported.", - wanted_content_type - )))); + return Err(client::Error::Http(Error::Detail { + description: format!( + "Didn't find '{}' header to indicate 'smart' protocol, and 'dumb' protocol is not supported.", + wanted_content_type + ), + })); } Ok(()) } @@ -88,11 +90,12 @@ impl Transport { } fn append_url(base: &str, suffix: &str) -> String { - if base.ends_with('/') { - format!("{}{}", base, suffix) - } else { - format!("{}/{}", base, suffix) + let mut buf = base.to_owned(); + if base.as_bytes().last() != Some(&b'/') { + buf.push('/'); } + buf.push_str(suffix); + buf } impl client::TransportWithoutIO for Transport { @@ -104,8 +107,8 @@ impl client::TransportWithoutIO for Transport { fn request( &mut self, write_mode: client::WriteMode, - on_into_read: client::MessageKind, - ) -> Result, client::Error> { + on_into_read: MessageKind, + ) -> Result, client::Error> { let service = self.service.expect("handshake() must have been called first"); let url = append_url(&self.url, service.as_str()); let static_headers = &[ @@ -146,7 +149,7 @@ impl client::TransportWithoutIO for Transport { } fn to_url(&self) -> String { - self.url.to_owned() + self.url.clone() } fn supported_protocol_versions(&self) -> &[Protocol] { @@ -164,7 +167,7 @@ impl client::Transport for Transport { service: Service, extra_parameters: &'a [(&'a str, Option<&'a str>)], ) -> Result, client::Error> { - let url = append_url(&self.url, &format!("info/refs?service={}", service.as_str())); + let url = append_url(self.url.as_ref(), &format!("info/refs?service={}", service.as_str())); let static_headers = [Cow::Borrowed(self.user_agent_header)]; let mut dynamic_headers = Vec::>::new(); if self.desired_version != Protocol::V1 || !extra_parameters.is_empty() { @@ -190,7 +193,9 @@ impl client::Transport for Transport { dynamic_headers.push(format!("Git-Protocol: {}", parameters).into()); } self.add_basic_auth_if_present(&mut dynamic_headers)?; - let GetResponse { headers, body } = self.http.get(&url, static_headers.iter().chain(&dynamic_headers))?; + let GetResponse { headers, body } = self + .http + .get(url.as_ref(), static_headers.iter().chain(&dynamic_headers))?; >::check_content_type(service, "advertisement", headers)?; let line_reader = self @@ -201,11 +206,13 @@ impl client::Transport for Transport { line_reader.as_read().read_to_string(&mut announced_service)?; let expected_service_announcement = format!("# service={}", service.as_str()); if announced_service.trim() != expected_service_announcement { - return Err(client::Error::Http(Error::Detail(format!( - "Expected to see {:?}, but got {:?}", - expected_service_announcement, - announced_service.trim() - )))); + return Err(client::Error::Http(Error::Detail { + description: format!( + "Expected to see {:?}, but got {:?}", + expected_service_announcement, + announced_service.trim() + ), + })); } let capabilities::recv::Outcome { @@ -230,24 +237,24 @@ struct HeadersThenBody { } impl HeadersThenBody { - fn handle_headers(&mut self) -> io::Result<()> { + fn handle_headers(&mut self) -> std::io::Result<()> { if let Some(headers) = self.headers.take() { >::check_content_type(self.service, "result", headers) - .map_err(|err| io::Error::new(io::ErrorKind::Other, err))? + .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))? } Ok(()) } } -impl io::Read for HeadersThenBody { - fn read(&mut self, buf: &mut [u8]) -> io::Result { +impl Read for HeadersThenBody { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { self.handle_headers()?; self.body.read(buf) } } -impl io::BufRead for HeadersThenBody { - fn fill_buf(&mut self) -> io::Result<&[u8]> { +impl BufRead for HeadersThenBody { + fn fill_buf(&mut self) -> std::io::Result<&[u8]> { self.handle_headers()?; self.body.fill_buf() } @@ -262,7 +269,7 @@ impl ExtendedBufRead for HeadersThenBody Option>> { + fn peek_data_line(&mut self) -> Option>> { if let Err(err) = self.handle_headers() { return Some(Err(err)); } @@ -279,6 +286,6 @@ impl ExtendedBufRead for HeadersThenBody Result, Infallible> { +pub fn connect(url: &str, desired_version: Protocol) -> Result, Infallible> { Ok(Transport::new(url, desired_version)) } diff --git a/git-transport/src/client/blocking_io/http/traits.rs b/git-transport/src/client/blocking_io/http/traits.rs index 194389ca6f2..be39502dab3 100644 --- a/git-transport/src/client/blocking_io/http/traits.rs +++ b/git-transport/src/client/blocking_io/http/traits.rs @@ -1,21 +1,11 @@ -use std::io; - -use quick_error::quick_error; - -quick_error! { - /// The error used by the [Http] trait. - #[derive(Debug)] - #[allow(missing_docs)] - pub enum Error { - Detail(description: String) { - display("{}", description) - } - PostBody(err: io::Error) { - display("An IO error occurred while uploading the body of a POST request") - from() - source(err) - } - } +/// The error used by the [Http] trait. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("{description}")] + Detail { description: String }, + #[error("An IO error occurred while uploading the body of a POST request")] + PostBody(#[from] std::io::Error), } /// The return value of [Http::get()]. @@ -51,11 +41,11 @@ impl From> for GetResponse { #[allow(clippy::type_complexity)] pub trait Http { /// A type providing headers line by line. - type Headers: io::BufRead + Unpin; + type Headers: std::io::BufRead + Unpin; /// A type providing the response. - type ResponseBody: io::BufRead; + type ResponseBody: std::io::BufRead; /// A type allowing to write the content to post. - type PostBody: io::Write; + type PostBody: std::io::Write; /// Initiate a `GET` request to `url` provided the given `headers`. /// diff --git a/git-transport/src/client/blocking_io/ssh.rs b/git-transport/src/client/blocking_io/ssh.rs index cfb414bf08d..739e0e9e092 100644 --- a/git-transport/src/client/blocking_io/ssh.rs +++ b/git-transport/src/client/blocking_io/ssh.rs @@ -1,19 +1,15 @@ use std::borrow::Cow; use bstr::BString; -use quick_error::quick_error; use crate::{client::blocking_io, Protocol}; -quick_error! { - /// The error used in [`connect()`]. - #[derive(Debug)] - #[allow(missing_docs)] - pub enum Error { - UnsupportedSshCommand(command: String) { - display("The ssh command '{}' is not currently supported", command) - } - } +/// The error used in [`connect()`]. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("The ssh command {0:?} is not currently supported")] + UnsupportedSshCommand(String), } /// Connect to `host` using the ssh program to obtain data from the repository at `path` on the remote. @@ -64,13 +60,14 @@ pub fn connect( }; let path = git_url::expand_path::for_shell(path); - let url = git_url::Url { - scheme: git_url::Scheme::Ssh, - user: user.map(Into::into), - host: Some(host.clone()), + let url = git_url::Url::from_parts( + git_url::Scheme::Ssh, + user.map(Into::into), + Some(host.clone()), port, - path: path.clone(), - }; + path.clone(), + ) + .expect("valid url"); Ok(match args_and_env { Some((args, envs)) => blocking_io::file::SpawnProcessOnDemand::new_ssh( url, @@ -103,7 +100,7 @@ mod tests { ("ssh://host.xy/~/repo", "~/repo"), ("ssh://host.xy/~username/repo", "~username/repo"), ] { - let url = git_url::parse(url.as_bytes()).expect("valid url"); + let url = git_url::parse((*url).into()).expect("valid url"); let cmd = connect("host", url.path, Protocol::V1, None, None).expect("parse success"); assert_eq!( cmd.path, diff --git a/git-transport/src/client/capabilities.rs b/git-transport/src/client/capabilities.rs index dc50a3f09c4..3bda61ade18 100644 --- a/git-transport/src/client/capabilities.rs +++ b/git-transport/src/client/capabilities.rs @@ -1,38 +1,25 @@ -use std::io; - use bstr::{BStr, BString, ByteSlice}; -use quick_error::quick_error; #[cfg(any(feature = "blocking-client", feature = "async-client"))] use crate::client; use crate::Protocol; -quick_error! { - /// The error used in [`Capabilities::from_bytes()`] and [`Capabilities::from_lines()`]. - #[derive(Debug)] - #[allow(missing_docs)] - pub enum Error { - MissingDelimitingNullByte { - display("Capabilities were missing entirely as there was no 0 byte") - } - NoCapabilities { - display("there was not a single capability behind the delimiter") - } - MissingVersionLine { - display("a version line was expected, but none was retrieved") - } - MalformattedVersionLine(actual: String) { - display("expected 'version X', got '{}'", actual) - } - UnsupportedVersion(wanted: Protocol, got: String) { - display("Got unsupported version '{}', expected '{}'", got, *wanted as usize) - } - Io(err: io::Error) { - display("An IO error occurred while reading V2 lines") - from() - source(err) - } - } +/// The error used in [`Capabilities::from_bytes()`] and [`Capabilities::from_lines()`]. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("Capabilities were missing entirely as there was no 0 byte")] + MissingDelimitingNullByte, + #[error("there was not a single capability behind the delimiter")] + NoCapabilities, + #[error("a version line was expected, but none was retrieved")] + MissingVersionLine, + #[error("expected 'version X', got {0:?}")] + MalformattedVersionLine(String), + #[error("Got unsupported version '{}', expected {actual:?}", *desired as u8)] + UnsupportedVersion { desired: Protocol, actual: String }, + #[error("An IO error occurred while reading V2 lines")] + Io(#[from] std::io::Error), } /// A structure to represent multiple [capabilities][Capability] or features supported by the server. @@ -113,7 +100,10 @@ impl Capabilities { return Err(Error::MalformattedVersionLine(version_line)); } if value != " 2" { - return Err(Error::UnsupportedVersion(Protocol::V2, value.to_owned())); + return Err(Error::UnsupportedVersion { + desired: Protocol::V2, + actual: value.to_owned(), + }); } Ok(Capabilities { value_sep: b'\n', @@ -181,7 +171,7 @@ pub mod recv { /// /// This is `Some` only when protocol v1 is used. The [`io::BufRead`] must be exhausted by /// the caller. - pub refs: Option>, + pub refs: Option>, /// The [`Protocol`] the remote advertised. pub protocol: Protocol, } diff --git a/git-transport/src/client/git/async_io.rs b/git-transport/src/client/git/async_io.rs index 0771b02c01f..717ec39aefc 100644 --- a/git-transport/src/client/git/async_io.rs +++ b/git-transport/src/client/git/async_io.rs @@ -29,14 +29,9 @@ where fn to_url(&self) -> String { self.custom_url.as_ref().map_or_else( || { - git_url::Url { - scheme: git_url::Scheme::File, - user: None, - host: None, - port: None, - path: self.path.clone(), - } - .to_string() + let mut possibly_lossy_url = self.path.to_string(); + possibly_lossy_url.insert_str(0, "file://"); + possibly_lossy_url }, |url| url.clone(), ) @@ -124,3 +119,37 @@ where } } } + +#[cfg(feature = "async-std")] +mod async_net { + use crate::client::git; + use crate::client::Error; + use async_std::net::TcpStream; + use std::time::Duration; + + impl git::Connection { + /// Create a new TCP connection using the `git` protocol of `desired_version`, and make a connection to `host` + /// at `port` for accessing the repository at `path` on the server side. + pub async fn new_tcp( + host: &str, + port: Option, + path: bstr::BString, + desired_version: crate::Protocol, + ) -> Result, Error> { + let read = async_std::io::timeout( + Duration::from_secs(5), + TcpStream::connect(&(host, port.unwrap_or(9418))), + ) + .await?; + let write = read.clone(); + Ok(git::Connection::new( + read, + write, + desired_version, + path, + None::<(String, _)>, + git::ConnectMode::Daemon, + )) + } + } +} diff --git a/git-transport/src/client/git/blocking_io.rs b/git-transport/src/client/git/blocking_io.rs index 544a2e618b9..240f00c9903 100644 --- a/git-transport/src/client/git/blocking_io.rs +++ b/git-transport/src/client/git/blocking_io.rs @@ -1,4 +1,4 @@ -use std::{io, io::Write}; +use std::io::Write; use bstr::BString; use git_packetline::PacketLineRef; @@ -10,8 +10,8 @@ use crate::{ impl client::TransportWithoutIO for git::Connection where - R: io::Read, - W: io::Write, + R: std::io::Read, + W: std::io::Write, { fn request( &mut self, @@ -29,14 +29,9 @@ where fn to_url(&self) -> String { self.custom_url.as_ref().map_or_else( || { - git_url::Url { - scheme: git_url::Scheme::File, - user: None, - host: None, - port: None, - path: self.path.clone(), - } - .to_string() + let mut possibly_lossy_url = self.path.to_string(); + possibly_lossy_url.insert_str(0, "file://"); + possibly_lossy_url }, |url| url.clone(), ) @@ -61,8 +56,8 @@ where impl client::Transport for git::Connection where - R: io::Read, - W: io::Write, + R: std::io::Read, + W: std::io::Write, { fn handshake<'a>( &mut self, @@ -96,8 +91,8 @@ where impl git::Connection where - R: io::Read, - W: io::Write, + R: std::io::Read, + W: std::io::Write, { /// Create a connection from the given `read` and `write`, asking for `desired_version` as preferred protocol /// and the transfer of the repository at `repository_path`. @@ -141,29 +136,19 @@ where /// pub mod connect { - use std::{ - io, - net::{TcpStream, ToSocketAddrs}, - }; + use std::net::{TcpStream, ToSocketAddrs}; use bstr::BString; - use quick_error::quick_error; use crate::client::git; - quick_error! { - /// The error used in [`connect()`]. - #[derive(Debug)] - #[allow(missing_docs)] - pub enum Error { - Io(err: io::Error){ - display("An IO error occurred when connecting to the server") - from() - source(err) - } - VirtualHostInvalid(host: String) { - display("Could not parse '{}' as virtual host with format [:port]", host) - } - } + /// The error used in [`connect()`]. + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error("An IO error occurred when connecting to the server")] + Io(#[from] std::io::Error), + #[error("Could not parse {host:?} as virtual host with format [:port]")] + VirtualHostInvalid { host: String }, } fn parse_host(input: String) -> Result<(String, Option), Error> { @@ -172,7 +157,7 @@ pub mod connect { (Some(host), None) => (host.to_owned(), None), (Some(host), Some(port)) => ( host.to_owned(), - Some(port.parse().map_err(|_| Error::VirtualHostInvalid(input))?), + Some(port.parse().map_err(|_| Error::VirtualHostInvalid { host: input })?), ), _ => unreachable!("we expect at least one token, the original string"), }) diff --git a/git-transport/src/client/mod.rs b/git-transport/src/client/mod.rs index bb50ca4e185..996c5ed0eb4 100644 --- a/git-transport/src/client/mod.rs +++ b/git-transport/src/client/mod.rs @@ -18,7 +18,7 @@ pub use blocking_io::{ }; #[cfg(feature = "blocking-client")] #[doc(inline)] -pub use connect::connect; +pub use connect::function::connect; /// pub mod capabilities; diff --git a/git-transport/src/client/non_io_types.rs b/git-transport/src/client/non_io_types.rs index 6ffabe43500..8d8400f4788 100644 --- a/git-transport/src/client/non_io_types.rs +++ b/git-transport/src/client/non_io_types.rs @@ -29,39 +29,28 @@ pub enum MessageKind { Text(&'static [u8]), } +#[cfg(any(feature = "blocking-client", feature = "async-client"))] pub(crate) mod connect { - use quick_error::quick_error; - quick_error! { - /// The error used in [`connect()`]. - #[derive(Debug)] - #[allow(missing_docs)] - pub enum Error { - Url(err: git_url::parse::Error) { - display("The URL could not be parsed") - from() - source(err) - } - PathConversion(err: bstr::Utf8Error) { - display("The git repository paths could not be converted to UTF8") - from() - source(err) - } - Connection(err: Box) { - display("connection failed") - from() - source(&**err) - } - UnsupportedUrlTokens(url: bstr::BString, scheme: git_url::Scheme) { - display("The url '{}' contains information that would not be used by the '{}' protocol", url, scheme) - } - UnsupportedScheme(scheme: git_url::Scheme) { - display("The '{}' protocol is currently unsupported", scheme) - } - #[cfg(not(feature = "http-client-curl"))] - CompiledWithoutHttp(scheme: git_url::Scheme) { - display("'{}' is not compiled in. Compile with the 'http-client-curl' cargo feature", scheme) - } - } + /// The error used in [`connect()`][crate::connect()]. + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error(transparent)] + Url(#[from] git_url::parse::Error), + #[error("The git repository path could not be converted to UTF8")] + PathConversion(#[from] bstr::Utf8Error), + #[error("connection failed")] + Connection(#[from] Box), + #[error("The url {url:?} contains information that would not be used by the {scheme} protocol")] + UnsupportedUrlTokens { + url: bstr::BString, + scheme: git_url::Scheme, + }, + #[error("The '{0}' protocol is currently unsupported")] + UnsupportedScheme(git_url::Scheme), + #[cfg(not(feature = "http-client-curl"))] + #[error("'{0}' is not compiled in. Compile with the 'http-client-curl' cargo feature")] + CompiledWithoutHttp(git_url::Scheme), } } diff --git a/git-transport/src/lib.rs b/git-transport/src/lib.rs index c4098860e64..5488ef66132 100644 --- a/git-transport/src/lib.rs +++ b/git-transport/src/lib.rs @@ -11,6 +11,12 @@ #![deny(missing_docs, rust_2018_idioms)] #![forbid(unsafe_code)] +#[cfg(feature = "async-trait")] +pub use async_trait; +#[cfg(feature = "futures-io")] +pub use futures_io; + +pub use bstr; pub use git_packetline as packetline; /// The version of the way client and server communicate. @@ -18,10 +24,19 @@ pub use git_packetline as packetline; #[cfg_attr(feature = "serde1", derive(serde::Serialize, serde::Deserialize))] #[allow(missing_docs)] pub enum Protocol { + /// Version 1 was the first one conceived, is stateful, and our implementation was seen to cause deadlocks. Prefer V2 V1 = 1, + /// A command-based and stateless protocol with clear semantics, and the one to use assuming the server isn't very old. + /// This is the default. V2 = 2, } +impl Default for Protocol { + fn default() -> Self { + Protocol::V2 + } +} + /// The kind of service to invoke on the client or the server side. #[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone, Copy)] #[cfg_attr(feature = "serde1", derive(serde::Serialize, serde::Deserialize))] @@ -46,7 +61,10 @@ impl Service { pub mod client; #[doc(inline)] -#[cfg(feature = "blocking-client")] +#[cfg(any( + feature = "blocking-client", + all(feature = "async-client", any(feature = "async-std")) +))] pub use client::connect; #[cfg(all(feature = "async-client", feature = "blocking-client"))] diff --git a/git-url/Cargo.toml b/git-url/Cargo.toml index 49582e8794d..5a140932681 100644 --- a/git-url/Cargo.toml +++ b/git-url/Cargo.toml @@ -16,10 +16,11 @@ doctest = false serde1 = ["serde", "bstr/serde1"] [dependencies] -serde = { version = "1.0.114", optional = true, default-features = false, features = ["std", "derive"]} git-features = { version = "^0.22.1", path = "../git-features" } git-path = { version = "^0.4.0", path = "../git-path" } -quick-error = "2.0.0" + +serde = { version = "1.0.114", optional = true, default-features = false, features = ["std", "derive"]} +thiserror = "1.0.32" url = "2.1.1" bstr = { version = "0.2.13", default-features = false, features = ["std"] } home = "0.5.3" diff --git a/git-url/src/expand_path.rs b/git-url/src/expand_path.rs index 59cab95b36d..b2e2f952946 100644 --- a/git-url/src/expand_path.rs +++ b/git-url/src/expand_path.rs @@ -2,7 +2,6 @@ use std::path::{Path, PathBuf}; use bstr::{BStr, BString, ByteSlice}; -use quick_error::quick_error; /// Whether a repository is resolving for the current user, or the given one. #[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)] @@ -23,17 +22,14 @@ impl From for Option { } } -quick_error! { - /// The error used by [`parse()`], [`with()`] and [`expand_path()`]. - #[derive(Debug)] - pub enum Error { - IllformedUtf8{path: BString} { - display("UTF8 conversion on non-unix system failed for path: {}", path) - } - MissingHome(user: Option) { - display("Home directory could not be obtained for {}", match user {Some(user) => format!("user '{}'", user), None => "current user".into()}) - } - } +/// The error used by [`parse()`], [`with()`] and [`expand_path()`]. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("UTF8 conversion on non-unix system failed for path: {path:?}")] + IllformedUtf8 { path: BString }, + #[error("Home directory could not be obtained for {}", match user {Some(user) => format!("user '{}'", user), None => "current user".into()})] + MissingHome { user: Option }, } fn path_segments(path: &BStr) -> Option> { @@ -109,7 +105,9 @@ pub fn with( let path = git_path::try_from_byte_slice(path).map_err(|_| Error::IllformedUtf8 { path: path.to_owned() })?; Ok(match user { Some(user) => home_for_user(user) - .ok_or_else(|| Error::MissingHome(user.to_owned().into()))? + .ok_or_else(|| Error::MissingHome { + user: user.to_owned().into(), + })? .join(make_relative(path)), None => path.into(), }) diff --git a/git-url/src/lib.rs b/git-url/src/lib.rs index 33dd8a84ba1..56a7cf0d2d8 100644 --- a/git-url/src/lib.rs +++ b/git-url/src/lib.rs @@ -5,15 +5,16 @@ cfg_attr(doc, doc = ::document_features::document_features!()) )] #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] -#![deny(rust_2018_idioms)] +#![deny(rust_2018_idioms, missing_docs)] #![forbid(unsafe_code)] +use std::path::PathBuf; use std::{ convert::TryFrom, - fmt::{self, Write}, + fmt::{self}, }; -use bstr::{BStr, ByteSlice}; +use bstr::{BStr, BString}; /// pub mod parse; @@ -28,26 +29,35 @@ pub use expand_path::expand_path; /// A scheme for use in a [`Url`] #[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone, Copy)] #[cfg_attr(feature = "serde1", derive(serde::Serialize, serde::Deserialize))] +#[allow(missing_docs)] pub enum Scheme { File, Git, Ssh, Http, Https, + // TODO: replace this with custom formats, maybe, get an idea how to do that. Radicle, } -impl fmt::Display for Scheme { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { +impl Scheme { + /// Return ourselves parseable name. + pub fn as_str(&self) -> &'static str { use Scheme::*; - f.write_str(match self { + match self { File => "file", Git => "git", Ssh => "ssh", Http => "http", Https => "https", Radicle => "rad", - }) + } + } +} + +impl fmt::Display for Scheme { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) } } @@ -61,9 +71,9 @@ pub struct Url { /// The URL scheme. pub scheme: Scheme, /// The user to impersonate on the remote. - pub user: Option, + user: Option, /// The host to which to connect. Localhost is implied if `None`. - pub host: Option, + host: Option, /// The port to use when connecting to a host. If `None`, standard ports depending on `scheme` will be used. pub port: Option, /// The path portion of the URL, usually the location of the git repository. @@ -82,31 +92,135 @@ impl Default for Url { } } -impl fmt::Display for Url { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.scheme.fmt(f)?; - f.write_str("://")?; +/// Instantiation +impl Url { + /// Create a new instance from the given parts, which will be validated by parsing them back. + pub fn from_parts( + scheme: Scheme, + user: Option, + host: Option, + port: Option, + path: BString, + ) -> Result { + parse( + Url { + scheme, + user, + host, + port, + path, + } + .to_bstring() + .as_ref(), + ) + } +} + +/// Modification +impl Url { + /// Set the given `user`, with `None` unsetting it. Returns the previous value. + pub fn set_user(&mut self, user: Option) -> Option { + let prev = self.user.take(); + self.user = user; + prev + } +} + +/// Access +impl Url { + /// Returns the user mentioned in the url, if present. + pub fn user(&self) -> Option<&str> { + self.user.as_deref() + } + /// Returns the host mentioned in the url, if present. + pub fn host(&self) -> Option<&str> { + self.host.as_deref() + } +} + +/// Serialization +impl Url { + /// Write this URL losslessly to `out`, ready to be parsed again. + pub fn write_to(&self, mut out: impl std::io::Write) -> std::io::Result<()> { + out.write_all(self.scheme.as_str().as_bytes())?; + out.write_all(b"://")?; match (&self.user, &self.host) { - (Some(user), Some(host)) => f.write_fmt(format_args!("{}@{}", user, host)), - (None, Some(host)) => f.write_str(host), - (None, None) => Ok(()), - _ => return Err(fmt::Error), - }?; + (Some(user), Some(host)) => { + out.write_all(user.as_bytes())?; + out.write_all(&[b'@'])?; + out.write_all(host.as_bytes())?; + } + (None, Some(host)) => { + out.write_all(host.as_bytes())?; + } + (None, None) => {} + (Some(_user), None) => unreachable!("BUG: should not be possible to have a user but no host"), + }; if let Some(port) = &self.port { - f.write_char(':')?; - f.write_fmt(format_args!("{}", port))?; + write!(&mut out, ":{}", port)?; } - f.write_str(self.path.to_str_lossy().as_ref()) + out.write_all(&self.path)?; + Ok(()) + } + + /// Transform ourselves into a binary string, losslessly, or fail if the URL is malformed due to host or user parts being incorrect. + pub fn to_bstring(&self) -> bstr::BString { + let mut buf = Vec::with_capacity( + (5 + 3) + + self.user.as_ref().map(|n| n.len()).unwrap_or_default() + + 1 + + self.host.as_ref().map(|h| h.len()).unwrap_or_default() + + self.port.map(|_| 5).unwrap_or_default() + + self.path.len(), + ); + self.write_to(&mut buf).expect("io cannot fail in memory"); + buf.into() } } impl Url { /// Parse a URL from `bytes` - pub fn from_bytes(bytes: &[u8]) -> Result { + pub fn from_bytes(bytes: &BStr) -> Result { parse(bytes) } } +impl TryFrom<&str> for Url { + type Error = parse::Error; + + fn try_from(value: &str) -> Result { + Self::from_bytes(value.into()) + } +} + +impl TryFrom for Url { + type Error = parse::Error; + + fn try_from(value: String) -> Result { + Self::from_bytes(value.as_str().into()) + } +} + +impl TryFrom for Url { + type Error = parse::Error; + + fn try_from(value: PathBuf) -> Result { + use std::convert::TryInto; + git_path::into_bstr(value).try_into() + } +} + +impl TryFrom<&std::ffi::OsStr> for Url { + type Error = parse::Error; + + fn try_from(value: &std::ffi::OsStr) -> Result { + use std::convert::TryInto; + git_path::os_str_into_bstr(value) + .expect("no illformed UTF-8 on Windows") + .try_into() + } +} + impl TryFrom<&BStr> for Url { type Error = parse::Error; diff --git a/git-url/src/parse.rs b/git-url/src/parse.rs index d357f07e254..5f0454adf23 100644 --- a/git-url/src/parse.rs +++ b/git-url/src/parse.rs @@ -1,32 +1,30 @@ use std::borrow::Cow; +use std::convert::Infallible; -use bstr::ByteSlice; -use quick_error::quick_error; +use bstr::{BStr, ByteSlice}; use crate::Scheme; +pub use bstr; -quick_error! { - /// The Error returned by [`parse()`] - #[derive(Debug)] - #[allow(missing_docs)] - pub enum Error { - Utf8(err: std::str::Utf8Error) { - display("Could not decode URL as UTF8") - from() - source(err) - } - Url(err: String) { - display("the URL could not be parsed: {}", err) - } - UnsupportedProtocol(protocol: String) { - display("Protocol '{}' is not supported", protocol) - } - EmptyPath { - display("Paths cannot be empty") - } - RelativeUrl(url: String) { - display("Relative URLs are not permitted: '{}'", url) - } +/// The Error returned by [`parse()`] +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("Could not decode URL as UTF8")] + Utf8(#[from] std::str::Utf8Error), + #[error(transparent)] + Url(#[from] url::ParseError), + #[error("Protocol {protocol:?} is not supported")] + UnsupportedProtocol { protocol: String }, + #[error("Paths cannot be empty")] + EmptyPath, + #[error("Relative URLs are not permitted: {url:?}")] + RelativeUrl { url: String }, +} + +impl From for Error { + fn from(_: Infallible) -> Self { + unreachable!("Cannot actually happen, but it seems there can't be a blanket impl for this") } } @@ -38,7 +36,7 @@ fn str_to_protocol(s: &str) -> Result { "http" => Scheme::Http, "https" => Scheme::Https, "rad" => Scheme::Radicle, - _ => return Err(Error::UnsupportedProtocol(s.into())), + _ => return Err(Error::UnsupportedProtocol { protocol: s.into() }), }) } @@ -94,17 +92,17 @@ fn to_owned_url(url: url::Url) -> Result { /// /// We cannot and should never have to deal with UTF-16 encoded windows strings, so bytes input is acceptable. /// For file-paths, we don't expect UTF8 encoding either. -pub fn parse(bytes: &[u8]) -> Result { - let guessed_protocol = guess_protocol(bytes); - if possibly_strip_file_protocol(bytes) != bytes || (has_no_explicit_protocol(bytes) && guessed_protocol == "file") { +pub fn parse(input: &BStr) -> Result { + let guessed_protocol = guess_protocol(input); + if possibly_strip_file_protocol(input) != input || (has_no_explicit_protocol(input) && guessed_protocol == "file") { return Ok(crate::Url { scheme: Scheme::File, - path: possibly_strip_file_protocol(bytes).into(), + path: possibly_strip_file_protocol(input).into(), ..Default::default() }); } - let url_str = std::str::from_utf8(bytes)?; + let url_str = std::str::from_utf8(input)?; let mut url = match url::Url::parse(url_str) { Ok(url) => url, Err(::url::ParseError::RelativeUrlWithoutBase) => { @@ -114,22 +112,20 @@ pub fn parse(bytes: &[u8]) -> Result { "{}://{}", guessed_protocol, sanitize_for_protocol(guessed_protocol, url_str) - )) - .map_err(|err| Error::Url(err.to_string()))? + ))? } - Err(err) => return Err(Error::Url(err.to_string())), + Err(err) => return Err(err.into()), }; // SCP like URLs without user parse as 'something' with the scheme being the 'host'. Hosts always have dots. if url.scheme().find('.').is_some() { // try again with prefixed protocol - url = url::Url::parse(&format!("ssh://{}", sanitize_for_protocol("ssh", url_str))) - .map_err(|err| Error::Url(err.to_string()))?; + url = url::Url::parse(&format!("ssh://{}", sanitize_for_protocol("ssh", url_str)))?; } if url.scheme() != "rad" && url.path().is_empty() { return Err(Error::EmptyPath); } if url.cannot_be_a_base() { - return Err(Error::RelativeUrl(url.into())); + return Err(Error::RelativeUrl { url: url.into() }); } to_owned_url(url) diff --git a/git-url/tests/parse/file.rs b/git-url/tests/parse/file.rs index 1e9500878a7..f697e4a0712 100644 --- a/git-url/tests/parse/file.rs +++ b/git-url/tests/parse/file.rs @@ -1,3 +1,4 @@ +use bstr::ByteSlice; use git_url::Scheme; use crate::parse::{assert_url_and, assert_url_roundtrip, url}; @@ -12,14 +13,14 @@ fn file_path_with_protocol() -> crate::Result { #[test] fn file_path_without_protocol() -> crate::Result { - let url = assert_url_and("/path/to/git", url(Scheme::File, None, None, None, b"/path/to/git"))?.to_string(); + let url = assert_url_and("/path/to/git", url(Scheme::File, None, None, None, b"/path/to/git"))?.to_bstring(); assert_eq!(url, "file:///path/to/git"); Ok(()) } #[test] fn no_username_expansion_for_file_paths_without_protocol() -> crate::Result { - let url = assert_url_and("~/path/to/git", url(Scheme::File, None, None, None, b"~/path/to/git"))?.to_string(); + let url = assert_url_and("~/path/to/git", url(Scheme::File, None, None, None, b"~/path/to/git"))?.to_bstring(); assert_eq!(url, "file://~/path/to/git"); Ok(()) } @@ -33,13 +34,15 @@ fn no_username_expansion_for_file_paths_with_protocol() -> crate::Result { #[test] fn non_utf8_file_path_without_protocol() -> crate::Result { - let parsed = git_url::parse(b"/path/to\xff/git")?; + let parsed = git_url::parse(b"/path/to\xff/git".as_bstr())?; assert_eq!(parsed, url(Scheme::File, None, None, None, b"/path/to\xff/git",)); + let url_lossless = parsed.to_bstring(); assert_eq!( - parsed.to_string(), + url_lossless.to_string(), "file:///path/to�/git", - "non-unicode is made unicode safe" + "non-unicode is made unicode safe after conversion" ); + assert_eq!(url_lossless, &b"file:///path/to\xff/git"[..], "otherwise it's lossless"); Ok(()) } @@ -49,9 +52,9 @@ fn relative_file_path_without_protocol() -> crate::Result { "../../path/to/git", url(Scheme::File, None, None, None, b"../../path/to/git"), )? - .to_string(); + .to_bstring(); assert_eq!(parsed, "file://../../path/to/git"); - let url = assert_url_and("path/to/git", url(Scheme::File, None, None, None, b"path/to/git"))?.to_string(); + let url = assert_url_and("path/to/git", url(Scheme::File, None, None, None, b"path/to/git"))?.to_bstring(); assert_eq!(url, "file://path/to/git"); Ok(()) } @@ -62,7 +65,7 @@ fn interior_relative_file_path_without_protocol() -> crate::Result { "/abs/path/../../path/to/git", url(Scheme::File, None, None, None, b"/abs/path/../../path/to/git"), )? - .to_string(); + .to_bstring(); assert_eq!(url, "file:///abs/path/../../path/to/git"); Ok(()) } @@ -74,7 +77,8 @@ mod windows { #[test] fn file_path_without_protocol() -> crate::Result { - let url = assert_url_and("x:/path/to/git", url(Scheme::File, None, None, None, b"x:/path/to/git"))?.to_string(); + let url = + assert_url_and("x:/path/to/git", url(Scheme::File, None, None, None, b"x:/path/to/git"))?.to_bstring(); assert_eq!(url, "file://x:/path/to/git"); Ok(()) } @@ -85,7 +89,7 @@ mod windows { "x:\\path\\to\\git", url(Scheme::File, None, None, None, b"x:\\path\\to\\git"), )? - .to_string(); + .to_bstring(); assert_eq!(url, "file://x:\\path\\to\\git"); Ok(()) } diff --git a/git-url/tests/parse/invalid.rs b/git-url/tests/parse/invalid.rs index 62bcbb4c3a3..ef9fb4b375a 100644 --- a/git-url/tests/parse/invalid.rs +++ b/git-url/tests/parse/invalid.rs @@ -2,7 +2,7 @@ use crate::parse::assert_failure; #[test] fn unknown_protocol() { - assert_failure("foo://host.xz/path/to/repo.git/", "Protocol 'foo' is not supported") + assert_failure("foo://host.xz/path/to/repo.git/", "Protocol \"foo\" is not supported") } #[test] diff --git a/git-url/tests/parse/mod.rs b/git-url/tests/parse/mod.rs index 646b8920cc9..47e01de55c0 100644 --- a/git-url/tests/parse/mod.rs +++ b/git-url/tests/parse/mod.rs @@ -1,17 +1,17 @@ use git_url::Scheme; fn assert_url_and(url: &str, expected: git_url::Url) -> Result { - assert_eq!(git_url::parse(url.as_bytes())?, expected); + assert_eq!(git_url::parse(url.into())?, expected); Ok(expected) } fn assert_url_roundtrip(url: &str, expected: git_url::Url) -> crate::Result { - assert_eq!(assert_url_and(url, expected)?.to_string(), url); + assert_eq!(assert_url_and(url, expected)?.to_bstring(), url); Ok(()) } fn assert_failure(url: &str, expected_err: &str) { - assert_eq!(git_url::parse(url.as_bytes()).unwrap_err().to_string(), expected_err); + assert_eq!(git_url::parse(url.into()).unwrap_err().to_string(), expected_err); } fn url( @@ -21,13 +21,14 @@ fn url( port: impl Into>, path: &'static [u8], ) -> git_url::Url { - git_url::Url { - scheme: protocol, - user: user.into().map(Into::into), - host: host.into().map(Into::into), - port: port.into(), - path: path.into(), - } + git_url::Url::from_parts( + protocol, + user.into().map(Into::into), + host.into().map(Into::into), + port.into(), + path.into(), + ) + .expect("valid") } mod file; diff --git a/git-url/tests/parse/ssh.rs b/git-url/tests/parse/ssh.rs index 0b708255e92..349069f398a 100644 --- a/git-url/tests/parse/ssh.rs +++ b/git-url/tests/parse/ssh.rs @@ -53,7 +53,7 @@ fn scp_like_without_user() -> crate::Result { "host.xz:path/to/git", url(Scheme::Ssh, None, "host.xz", None, b"/path/to/git"), )? - .to_string(); + .to_bstring(); assert_eq!(url, "ssh://host.xz/path/to/git"); Ok(()) } @@ -64,7 +64,7 @@ fn scp_like_without_user_and_username_expansion_without_username() -> crate::Res "host.xz:~/to/git", url(Scheme::Ssh, None, "host.xz", None, b"/~/to/git"), )? - .to_string(); + .to_bstring(); assert_eq!(url, "ssh://host.xz/~/to/git"); Ok(()) } @@ -75,7 +75,7 @@ fn scp_like_without_user_and_username_expansion_with_username() -> crate::Result "host.xz:~byron/to/git", url(Scheme::Ssh, None, "host.xz", None, b"/~byron/to/git"), )? - .to_string(); + .to_bstring(); assert_eq!(url, "ssh://host.xz/~byron/to/git"); Ok(()) } @@ -86,7 +86,7 @@ fn scp_like_with_user_and_relative_path_turns_into_absolute_path() -> crate::Res "user@host.xz:./relative", url(Scheme::Ssh, "user", "host.xz", None, b"/relative"), )? - .to_string(); + .to_bstring(); assert_eq!(url, "ssh://user@host.xz/relative"); Ok(()) } diff --git a/gitoxide-core/Cargo.toml b/gitoxide-core/Cargo.toml index 66594cd688b..7d755b9a4e2 100644 --- a/gitoxide-core/Cargo.toml +++ b/gitoxide-core/Cargo.toml @@ -27,7 +27,7 @@ estimate-hours = ["itertools", "rayon", "fs-err"] blocking-client = ["git-repository/blocking-network-client"] ## The client to connect to git servers will be async, while supporting only the 'git' transport itself. ## It's the most limited and can be seen as example on how to use custom transports for custom servers. -async-client = ["git-repository/async-network-client", "async-trait", "futures-io", "async-net", "async-io", "futures-lite", "blocking"] +async-client = ["git-repository/async-network-client-async-std", "git-transport-configuration-only/async-std", "async-trait", "futures-io", "async-net", "async-io", "futures-lite", "blocking"] #! ### Other ## Data structures implement `serde::Serialize` and `serde::Deserialize`. @@ -38,6 +38,7 @@ serde1 = ["git-commitgraph/serde1", "git-repository/serde1", "serde_json", "serd # deselect everything else (like "performance") as this should be controllable by the parent application. git-repository = { version = "^0.21.1", path = "../git-repository", default-features = false, features = ["local", "unstable"]} # TODO: eventually 'unstable' shouldn't be needed anymore git-pack-for-configuration-only = { package = "git-pack", version = "^0.21.1", path = "../git-pack", default-features = false, features = ["pack-cache-lru-dynamic", "pack-cache-lru-static"] } +git-transport-configuration-only = { package = "git-transport", version = "^0.19.0", path = "../git-transport", default-features = false } git-commitgraph = { version = "^0.8.1", path = "../git-commitgraph" } git-config = { version = "^0.6.1", path = "../git-config" } git-features = { version = "^0.22.1", path = "../git-features" } diff --git a/gitoxide-core/src/lib.rs b/gitoxide-core/src/lib.rs index 158e19e5287..c146f582dad 100644 --- a/gitoxide-core/src/lib.rs +++ b/gitoxide-core/src/lib.rs @@ -51,8 +51,6 @@ pub mod mailmap; #[cfg(feature = "organize")] pub mod organize; pub mod pack; -#[cfg(any(feature = "async-client", feature = "blocking-client"))] -pub mod remote; pub mod repository; #[cfg(all(feature = "async-client", feature = "blocking-client"))] diff --git a/gitoxide-core/src/net.rs b/gitoxide-core/src/net.rs index dd9a612ac01..8177060fed4 100644 --- a/gitoxide-core/src/net.rs +++ b/gitoxide-core/src/net.rs @@ -41,72 +41,6 @@ impl Default for Protocol { Protocol::V2 } } -#[cfg(feature = "async-client")] -mod async_io { - use std::{io, time::Duration}; - use async_net::TcpStream; - use futures_lite::FutureExt; - use git_repository::{ - objs::bstr::BString, - protocol::{ - transport, - transport::{ - client, - client::{connect::Error, git}, - }, - }, - }; - - async fn git_connect( - host: &str, - path: BString, - desired_version: transport::Protocol, - port: Option, - ) -> Result, Error> { - let read = TcpStream::connect(&(host, port.unwrap_or(9418))) - .or(async { - async_io::Timer::after(Duration::from_secs(5)).await; - Err(io::ErrorKind::TimedOut.into()) - }) - .await - .map_err(|e| Box::new(e) as Box)?; - let write = read.clone(); - Ok(git::Connection::new( - read, - write, - desired_version, - path, - None::<(String, _)>, - git::ConnectMode::Daemon, - )) - } - - pub async fn connect( - url: &[u8], - desired_version: transport::Protocol, - ) -> Result { - let urlb = url; - let url = git_repository::url::parse(urlb)?; - Ok(match url.scheme { - git_repository::url::Scheme::Git => { - if url.user.is_some() { - return Err(Error::UnsupportedUrlTokens(urlb.into(), url.scheme)); - } - git_connect( - url.host.as_ref().expect("host is present in url"), - url.path, - desired_version, - url.port, - ) - .await? - } - scheme => return Err(Error::UnsupportedScheme(scheme)), - }) - } -} -#[cfg(feature = "blocking-client")] +#[cfg(any(feature = "async-client", feature = "blocking-client"))] pub use git_repository::protocol::transport::connect; - -#[cfg(feature = "async-client")] -pub use self::async_io::connect; diff --git a/gitoxide-core/src/organize.rs b/gitoxide-core/src/organize.rs index 5c97759e765..48a6c8e4e04 100644 --- a/gitoxide-core/src/organize.rs +++ b/gitoxide-core/src/organize.rs @@ -168,16 +168,15 @@ fn handle( progress.info(format!( "Skipping repository at {:?} whose remote does not have a path: {:?}", git_workdir.display(), - url.to_string() + url.to_bstring() )); return Ok(()); } let destination = canonicalized_destination .join( - url.host - .as_ref() - .ok_or_else(|| anyhow::Error::msg(format!("Remote URLs must have host names: {}", url)))?, + url.host() + .ok_or_else(|| anyhow::Error::msg(format!("Remote URLs must have host names: {}", url.to_bstring())))?, ) .join(to_relative({ let mut path = git_url::expand_path(None, url.path.as_bstr())?; diff --git a/gitoxide-core/src/pack/receive.rs b/gitoxide-core/src/pack/receive.rs index 88cf827d1bc..30455eef927 100644 --- a/gitoxide-core/src/pack/receive.rs +++ b/gitoxide-core/src/pack/receive.rs @@ -18,7 +18,7 @@ use git_repository::{ Progress, }; -use crate::{remote::refs::JsonRef, OutputFormat}; +use crate::OutputFormat; pub const PROGRESS_RANGE: std::ops::RangeInclusive = 1..=3; @@ -151,7 +151,7 @@ mod blocking_io { progress: P, ctx: Context, ) -> anyhow::Result<()> { - let transport = net::connect(url.as_bytes(), protocol.unwrap_or_default().into())?; + let transport = net::connect(url, protocol.unwrap_or_default().into())?; let delegate = CloneDelegate { ctx, directory, @@ -219,7 +219,7 @@ mod async_io { progress: P, ctx: Context, ) -> anyhow::Result<()> { - let transport = net::connect(url.as_bytes(), protocol.unwrap_or_default().into()).await?; + let transport = net::connect(url.to_string(), protocol.unwrap_or_default().into()).await?; let mut delegate = CloneDelegate { ctx, directory, @@ -272,7 +272,7 @@ pub struct JsonOutcome { pub index_path: Option, pub data_path: Option, - pub refs: Vec, + pub refs: Vec, } impl JsonOutcome { @@ -298,7 +298,7 @@ fn print(out: &mut impl io::Write, res: pack::bundle::write::Outcome, refs: &[Re print_hash_and_path(out, "index", res.index.index_hash, res.index_path)?; print_hash_and_path(out, "pack", res.index.data_hash, res.data_path)?; writeln!(out)?; - crate::remote::refs::print(out, refs)?; + crate::repository::remote::refs::print(out, refs)?; Ok(()) } diff --git a/gitoxide-core/src/remote.rs b/gitoxide-core/src/remote.rs deleted file mode 100644 index 499c973e864..00000000000 --- a/gitoxide-core/src/remote.rs +++ /dev/null @@ -1,229 +0,0 @@ -pub mod refs { - use git_repository::{ - protocol, - protocol::{ - fetch::{Action, Arguments, Ref, Response}, - transport, - }, - }; - - use crate::OutputFormat; - - pub const PROGRESS_RANGE: std::ops::RangeInclusive = 1..=2; - - use std::io; - - #[derive(Default)] - struct LsRemotes { - refs: Vec, - } - - impl protocol::fetch::DelegateBlocking for LsRemotes { - fn prepare_fetch( - &mut self, - _version: transport::Protocol, - _server: &transport::client::Capabilities, - _features: &mut Vec<(&str, Option<&str>)>, - refs: &[Ref], - ) -> io::Result { - self.refs = refs.into(); - Ok(Action::Cancel) - } - - fn negotiate( - &mut self, - _refs: &[Ref], - _arguments: &mut Arguments, - _previous_response: Option<&Response>, - ) -> io::Result { - unreachable!("not to be called due to Action::Close in `prepare_fetch`") - } - } - - #[cfg(feature = "async-client")] - mod async_io { - use std::io; - - use async_trait::async_trait; - use futures_io::AsyncBufRead; - use git_repository::{ - protocol, - protocol::fetch::{Ref, Response}, - Progress, - }; - - use super::{Context, LsRemotes}; - use crate::{net, remote::refs::print, OutputFormat}; - - #[async_trait(?Send)] - impl protocol::fetch::Delegate for LsRemotes { - async fn receive_pack( - &mut self, - input: impl AsyncBufRead + Unpin + 'async_trait, - progress: impl Progress, - refs: &[Ref], - previous_response: &Response, - ) -> io::Result<()> { - unreachable!("not called for ls-refs") - } - } - - pub async fn list( - protocol: Option, - url: &str, - progress: impl Progress, - ctx: Context, - ) -> anyhow::Result<()> { - let url = url.to_owned(); - let transport = net::connect(url.as_bytes(), protocol.unwrap_or_default().into()).await?; - blocking::unblock( - // `blocking` really needs a way to unblock futures, which is what it does internally anyway. - // Both fetch() needs unblocking as it executes blocking code within the future, and the other - // block does blocking IO because it's primarily a blocking codebase. - move || { - futures_lite::future::block_on(async move { - let mut delegate = LsRemotes::default(); - protocol::fetch( - transport, - &mut delegate, - protocol::credentials::helper, - progress, - protocol::FetchConnection::TerminateOnSuccessfulCompletion, - ) - .await?; - - match ctx.format { - OutputFormat::Human => drop(print(ctx.out, &delegate.refs)), - #[cfg(feature = "serde1")] - OutputFormat::Json => serde_json::to_writer_pretty( - ctx.out, - &delegate.refs.into_iter().map(super::JsonRef::from).collect::>(), - )?, - } - Ok(()) - }) - }, - ) - .await - } - } - #[cfg(feature = "async-client")] - pub use self::async_io::list; - - #[cfg(feature = "blocking-client")] - mod blocking_io { - use std::io; - - use git_repository::{ - protocol, - protocol::fetch::{Ref, Response}, - Progress, - }; - - #[cfg(feature = "serde1")] - use super::JsonRef; - use super::{print, Context, LsRemotes}; - use crate::{net, OutputFormat}; - - impl protocol::fetch::Delegate for LsRemotes { - fn receive_pack( - &mut self, - _input: impl io::BufRead, - _progress: impl Progress, - _refs: &[Ref], - _previous_response: &Response, - ) -> io::Result<()> { - unreachable!("not called for ls-refs") - } - } - - pub fn list( - protocol: Option, - url: &str, - progress: impl Progress, - ctx: Context, - ) -> anyhow::Result<()> { - let transport = net::connect(url.as_bytes(), protocol.unwrap_or_default().into())?; - let mut delegate = LsRemotes::default(); - protocol::fetch( - transport, - &mut delegate, - protocol::credentials::helper, - progress, - protocol::FetchConnection::TerminateOnSuccessfulCompletion, - )?; - - match ctx.format { - OutputFormat::Human => drop(print(ctx.out, &delegate.refs)), - #[cfg(feature = "serde1")] - OutputFormat::Json => serde_json::to_writer_pretty( - ctx.out, - &delegate.refs.into_iter().map(JsonRef::from).collect::>(), - )?, - }; - Ok(()) - } - } - #[cfg(feature = "blocking-client")] - pub use blocking_io::list; - - pub struct Context { - pub thread_limit: Option, - pub format: OutputFormat, - pub out: W, - } - - #[cfg_attr(feature = "serde1", derive(serde::Serialize, serde::Deserialize))] - pub enum JsonRef { - Peeled { - path: String, - tag: String, - object: String, - }, - Direct { - path: String, - object: String, - }, - Symbolic { - path: String, - target: String, - object: String, - }, - } - - impl From for JsonRef { - fn from(value: Ref) -> Self { - match value { - Ref::Direct { path, object } => JsonRef::Direct { - path: path.to_string(), - object: object.to_string(), - }, - Ref::Symbolic { path, target, object } => JsonRef::Symbolic { - path: path.to_string(), - target: target.to_string(), - object: object.to_string(), - }, - Ref::Peeled { path, tag, object } => JsonRef::Peeled { - path: path.to_string(), - tag: tag.to_string(), - object: object.to_string(), - }, - } - } - } - - pub(crate) fn print(mut out: impl io::Write, refs: &[Ref]) -> io::Result<()> { - for r in refs { - match r { - Ref::Direct { path, object } => writeln!(&mut out, "{} {}", object.to_hex(), path), - Ref::Peeled { path, object, tag } => { - writeln!(&mut out, "{} {} tag:{}", object.to_hex(), path, tag) - } - Ref::Symbolic { path, target, object } => { - writeln!(&mut out, "{} {} symref-target:{}", object.to_hex(), path, target) - } - }?; - } - Ok(()) - } -} diff --git a/gitoxide-core/src/repository/mod.rs b/gitoxide-core/src/repository/mod.rs index 71ce148d319..eda9d2f564c 100644 --- a/gitoxide-core/src/repository/mod.rs +++ b/gitoxide-core/src/repository/mod.rs @@ -14,18 +14,12 @@ pub fn init(directory: Option) -> Result = 1..=2; + + pub struct Context { + pub format: OutputFormat, + pub name: Option, + pub url: Option, + } + + pub(crate) use super::print; + } + + #[git::protocol::maybe_async::maybe_async] + pub async fn refs_fn( + repo: git::Repository, + mut progress: impl git::Progress, + out: impl std::io::Write, + refs::Context { format, name, url }: refs::Context, + ) -> anyhow::Result<()> { + use anyhow::Context; + let remote = match (name, url) { + (Some(name), None) => repo.find_remote(&name)?, + (None, None) => repo + .head()? + .into_remote(git::remote::Direction::Fetch) + .context("Cannot find a remote for unborn branch")??, + (None, Some(url)) => repo.remote_at(url)?, + (Some(_), Some(_)) => bail!("Must not set both the remote name and the url - they are mutually exclusive"), + }; + progress.info(format!( + "Connecting to {:?}", + remote + .url(git::remote::Direction::Fetch) + .context("Remote didn't have a URL to connect to")? + .to_bstring() + )); + let refs = remote + .connect(git::remote::Direction::Fetch, progress) + .await? + .list_refs() + .await?; + + match format { + OutputFormat::Human => drop(print(out, &refs)), + #[cfg(feature = "serde1")] + OutputFormat::Json => { + serde_json::to_writer_pretty(out, &refs.into_iter().map(JsonRef::from).collect::>())? + } + }; + Ok(()) + } + + #[cfg_attr(feature = "serde1", derive(serde::Serialize, serde::Deserialize))] + pub enum JsonRef { + Peeled { + path: String, + tag: String, + object: String, + }, + Direct { + path: String, + object: String, + }, + Symbolic { + path: String, + target: String, + object: String, + }, + } + + impl From for JsonRef { + fn from(value: fetch::Ref) -> Self { + match value { + fetch::Ref::Direct { path, object } => JsonRef::Direct { + path: path.to_string(), + object: object.to_string(), + }, + fetch::Ref::Symbolic { path, target, object } => JsonRef::Symbolic { + path: path.to_string(), + target: target.to_string(), + object: object.to_string(), + }, + fetch::Ref::Peeled { path, tag, object } => JsonRef::Peeled { + path: path.to_string(), + tag: tag.to_string(), + object: object.to_string(), + }, + } + } + } + + pub(crate) fn print(mut out: impl std::io::Write, refs: &[fetch::Ref]) -> std::io::Result<()> { + for r in refs { + match r { + fetch::Ref::Direct { path, object } => writeln!(&mut out, "{} {}", object.to_hex(), path), + fetch::Ref::Peeled { path, object, tag } => { + writeln!(&mut out, "{} {} tag:{}", object.to_hex(), path, tag) + } + fetch::Ref::Symbolic { path, target, object } => { + writeln!(&mut out, "{} {} symref-target:{}", object.to_hex(), path, target) + } + }?; + } + Ok(()) + } +} +#[cfg(any(feature = "blocking-client", feature = "async-client"))] +pub use net::{refs, refs_fn as refs, JsonRef}; diff --git a/src/plumbing/main.rs b/src/plumbing/main.rs index a8706a1792c..b3d51c10f4c 100644 --- a/src/plumbing/main.rs +++ b/src/plumbing/main.rs @@ -7,12 +7,13 @@ use std::{ }, }; -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Parser; use git_repository::bstr::io::BufReadExt; use gitoxide_core as core; use gitoxide_core::pack::verify; +use crate::plumbing::options::remote; use crate::{ plumbing::options::{commit, config, exclude, free, mailmap, odb, revision, tree, Args, Subcommands}, shared::pretty::prepare_and_run, @@ -29,7 +30,10 @@ pub mod async_util { verbose: bool, name: &str, range: impl Into>, - ) -> (Option, Option) { + ) -> ( + Option, + git_features::progress::DoOrDiscard, + ) { use crate::shared::{self, STANDARD_RANGE}; shared::init_env_logger(); @@ -37,9 +41,9 @@ pub mod async_util { let progress = shared::progress_tree(); let sub_progress = progress.add_child(name); let ui_handle = shared::setup_line_renderer_range(&progress, range.into().unwrap_or(STANDARD_RANGE)); - (Some(ui_handle), Some(sub_progress)) + (Some(ui_handle), Some(sub_progress).into()) } else { - (None, None) + (None, None.into()) } } } @@ -51,20 +55,27 @@ pub fn main() -> Result<()> { let format = args.format; let cmd = args.cmd; let object_hash = args.object_hash; + let config = args.config; use git_repository as git; let repository = args.repository; enum Mode { Strict, Lenient, } - let repository = move |mode: Mode| { + let repository = move |mode: Mode| -> Result { let mut mapping: git::sec::trust::Mapping = Default::default(); let toggle = matches!(mode, Mode::Strict); mapping.full = mapping.full.strict_config(toggle); mapping.reduced = mapping.reduced.strict_config(toggle); - git::ThreadSafeRepository::discover_opts(repository, Default::default(), mapping) + let mut repo = git::ThreadSafeRepository::discover_opts(repository, Default::default(), mapping) .map(git::Repository::from) - .map(|r| r.apply_environment()) + .map(|r| r.apply_environment())?; + if !config.is_empty() { + repo.config_snapshot_mut() + .apply_cli_overrides(config) + .context("Unable to parse command-line configuration")?; + } + Ok(repo) }; let progress; @@ -87,6 +98,44 @@ pub fn main() -> Result<()> { })?; match cmd { + #[cfg_attr(feature = "small", allow(unused_variables))] + Subcommands::Remote(remote::Platform { name, url, cmd }) => match cmd { + #[cfg(any(feature = "gitoxide-core-async-client", feature = "gitoxide-core-blocking-client"))] + remote::Subcommands::Refs => { + #[cfg(feature = "gitoxide-core-blocking-client")] + { + prepare_and_run( + "remote-refs", + verbose, + progress, + progress_keep_open, + core::repository::remote::refs::PROGRESS_RANGE, + move |progress, out, _err| { + core::repository::remote::refs( + repository(Mode::Lenient)?, + progress, + out, + core::repository::remote::refs::Context { name, url, format }, + ) + }, + ) + } + #[cfg(feature = "gitoxide-core-async-client")] + { + let (_handle, progress) = async_util::prepare( + verbose, + "remote-refs", + Some(core::repository::remote::refs::PROGRESS_RANGE), + ); + futures_lite::future::block_on(core::repository::remote::refs( + repository(Mode::Lenient)?, + progress, + std::io::stdout(), + core::repository::remote::refs::Context { name, url, format }, + )) + } + } + }, Subcommands::Config(config::Platform { filter }) => prepare_and_run( "config-list", verbose, @@ -97,45 +146,6 @@ pub fn main() -> Result<()> { ) .map(|_| ()), Subcommands::Free(subcommands) => match subcommands { - #[cfg(any(feature = "gitoxide-core-async-client", feature = "gitoxide-core-blocking-client"))] - free::Subcommands::Remote(subcommands) => match subcommands { - #[cfg(feature = "gitoxide-core-async-client")] - free::remote::Subcommands::RefList { protocol, url } => { - let (_handle, progress) = - async_util::prepare(verbose, "remote-ref-list", Some(core::remote::refs::PROGRESS_RANGE)); - let fut = core::remote::refs::list( - protocol, - &url, - git_features::progress::DoOrDiscard::from(progress), - core::remote::refs::Context { - thread_limit, - format, - out: std::io::stdout(), - }, - ); - return futures_lite::future::block_on(fut); - } - #[cfg(feature = "gitoxide-core-blocking-client")] - free::remote::Subcommands::RefList { protocol, url } => prepare_and_run( - "remote-ref-list", - verbose, - progress, - progress_keep_open, - core::remote::refs::PROGRESS_RANGE, - move |progress, out, _err| { - core::remote::refs::list( - protocol, - &url, - progress, - core::remote::refs::Context { - thread_limit, - format, - out, - }, - ) - }, - ), - }, free::Subcommands::CommitGraph(subcommands) => match subcommands { free::commitgraph::Subcommands::Verify { path, statistics } => prepare_and_run( "commitgraph-verify", @@ -298,7 +308,7 @@ pub fn main() -> Result<()> { directory, refs_directory, refs.into_iter().map(|s| s.into()).collect(), - git_features::progress::DoOrDiscard::from(progress), + progress, core::pack::receive::Context { thread_limit, format, diff --git a/src/plumbing/options.rs b/src/plumbing/options.rs index c6e8d006cd4..921e175a4b4 100644 --- a/src/plumbing/options.rs +++ b/src/plumbing/options.rs @@ -1,3 +1,5 @@ +use git_repository as git; +use git_repository::bstr::BString; use std::path::PathBuf; use gitoxide_core as core; @@ -11,6 +13,13 @@ pub struct Args { #[clap(short = 'r', long, default_value = ".")] pub repository: PathBuf, + /// Add these values to the configuration in the form of `key=value` or `key`. + /// + /// For example, if `key` is `core.abbrev`, set configuration like `[core] abbrev = key`, + /// or `remote.origin.url = foo` to set `[remote "origin"] url = foo`. + #[clap(long, short = 'c', parse(try_from_os_str = git::env::os_str_to_bstring))] + pub config: Vec, + #[clap(long, short = 't')] /// The amount of threads to use for some operations. /// @@ -72,6 +81,8 @@ pub enum Subcommands { /// Interact with the mailmap. #[clap(subcommand)] Mailmap(mailmap::Subcommands), + /// Interact with the remote hosts. + Remote(remote::Platform), /// Interact with the exclude files like .gitignore. #[clap(subcommand)] Exclude(exclude::Subcommands), @@ -94,6 +105,35 @@ pub mod config { } } +pub mod remote { + use git_repository as git; + + #[derive(Debug, clap::Parser)] + pub struct Platform { + /// The name of the remote to connect to. + /// + /// If unset, the current branch will determine the remote. + #[clap(long, short = 'n')] + pub name: Option, + + /// Connect directly to the given URL, forgoing any configuration from the repository. + #[clap(long, short = 'u', conflicts_with("name"), parse(try_from_os_str = std::convert::TryFrom::try_from))] + pub url: Option, + + /// Subcommands + #[clap(subcommand)] + pub cmd: Subcommands, + } + + #[derive(Debug, clap::Subcommand)] + #[clap(visible_alias = "remotes")] + pub enum Subcommands { + /// Print all references available on the remote + #[cfg(any(feature = "gitoxide-core-async-client", feature = "gitoxide-core-blocking-client"))] + Refs, + } +} + pub mod mailmap { #[derive(Debug, clap::Subcommand)] pub enum Subcommands { @@ -211,10 +251,6 @@ pub mod free { #[derive(Debug, clap::Subcommand)] #[clap(visible_alias = "no-repo")] pub enum Subcommands { - /// Subcommands for interacting with git remote server. - #[clap(subcommand)] - #[cfg(any(feature = "gitoxide-core-async-client", feature = "gitoxide-core-blocking-client"))] - Remote(remote::Subcommands), /// Subcommands for interacting with commit-graphs #[clap(subcommand)] CommitGraph(commitgraph::Subcommands), @@ -230,30 +266,6 @@ pub mod free { Index(index::Platform), } - /// - #[cfg(any(feature = "gitoxide-core-async-client", feature = "gitoxide-core-blocking-client"))] - pub mod remote { - use gitoxide_core as core; - - #[derive(Debug, clap::Subcommand)] - pub enum Subcommands { - /// List remote references from a remote identified by a url. - /// - /// This is the plumbing equivalent of `git ls-remote`. - /// Supported URLs are documented here: - RefList { - /// The protocol version to use. Valid values are 1 and 2 - #[clap(long, short = 'p')] - protocol: Option, - - /// the URLs or path from which to receive references - /// - /// See here for a list of supported URLs: - url: String, - }, - } - } - /// pub mod commitgraph { use std::path::PathBuf; diff --git a/tests/journey/gix.sh b/tests/journey/gix.sh index 4b1b902e149..cded2d91421 100644 --- a/tests/journey/gix.sh +++ b/tests/journey/gix.sh @@ -68,6 +68,89 @@ title "gix (with repository)" fi ) ) + + title "gix remote" + (when "running 'remote'" + snapshot="$snapshot/remote" + title "gix remote refs" + (with "the 'refs' subcommand" + snapshot="$snapshot/refs" + (small-repo-in-sandbox + if [[ "$kind" != "small" ]]; then + + if [[ "$kind" != "async" ]]; then + (with "file:// protocol" + (with "version 1" + it "generates the correct output" && { + WITH_SNAPSHOT="$snapshot/file-v-any" \ + expect_run $SUCCESSFULLY "$exe_plumbing" -c protocol.version=1 remote -u .git refs + } + ) + (with "version 2" + it "generates the correct output" && { + WITH_SNAPSHOT="$snapshot/file-v-any" \ + expect_run $SUCCESSFULLY "$exe_plumbing" -c protocol.version=2 remote -u "$PWD" refs + } + ) + if test "$kind" = "max"; then + (with "--format json" + it "generates the correct output in JSON format" && { + WITH_SNAPSHOT="$snapshot/file-v-any-json" \ + expect_run $SUCCESSFULLY "$exe_plumbing" --format json remote -u . refs + } + ) + fi + ) + fi + + (with "git:// protocol" + launch-git-daemon + (with "version 1" + it "generates the correct output" && { + WITH_SNAPSHOT="$snapshot/file-v-any" \ + expect_run $SUCCESSFULLY "$exe_plumbing" --config protocol.version=1 remote --url git://localhost/ refs + } + ) + (with "version 2" + it "generates the correct output" && { + WITH_SNAPSHOT="$snapshot/file-v-any" \ + expect_run $SUCCESSFULLY "$exe_plumbing" -c protocol.version=2 remote -u git://localhost/ refs + } + ) + ) + if [[ "$kind" == "small" ]]; then + (with "https:// protocol (in small builds)" + it "fails as http is not compiled in" && { + WITH_SNAPSHOT="$snapshot/fail-http-in-small" \ + expect_run $WITH_FAILURE "$exe_plumbing" -c protocol.version=1 remote -u https://github.com/byron/gitoxide refs + } + ) + fi + (on_ci + if [[ "$kind" = "max" ]]; then + (with "https:// protocol" + (with "version 1" + it "generates the correct output" && { + expect_run $SUCCESSFULLY "$exe_plumbing" -c protocol.version=1 remote -u https://github.com/byron/gitoxide refs + } + ) + (with "version 2" + it "generates the correct output" && { + expect_run $SUCCESSFULLY "$exe_plumbing" -c protocol.version=2 remote -u https://github.com/byron/gitoxide refs + } + ) + ) + fi + ) + else + it "fails as the CLI doesn't include networking in 'small' mode" && { + WITH_SNAPSHOT="$snapshot/remote ref-list-no-networking-in-small-failure" \ + expect_run 2 "$exe_plumbing" -c protocol.version=1 remote -u .git refs + } + fi + ) + ) + ) ) (with "gix free" @@ -527,91 +610,6 @@ title "gix (with repository)" ) ) - title "gix free remote" - (when "running 'remote'" - snapshot="$snapshot/remote" - title "gix remote ref-list" - (with "the 'ref-list' subcommand" - snapshot="$snapshot/ref-list" - (small-repo-in-sandbox - if [[ "$kind" != "small" ]]; then - - if [[ "$kind" != "async" ]]; then - (with "file:// protocol" - (with "version 1" - it "generates the correct output" && { - WITH_SNAPSHOT="$snapshot/file-v-any" \ - expect_run $SUCCESSFULLY "$exe_plumbing" free remote ref-list -p 1 .git - } - ) - (with "version 2" - it "generates the correct output" && { - WITH_SNAPSHOT="$snapshot/file-v-any" \ - expect_run $SUCCESSFULLY "$exe_plumbing" free remote ref-list --protocol 2 "$PWD/.git" - } - ) - if test "$kind" = "max"; then - (with "--format json" - it "generates the correct output in JSON format" && { - WITH_SNAPSHOT="$snapshot/file-v-any-json" \ - expect_run $SUCCESSFULLY "$exe_plumbing" --format json free remote ref-list .git - } - ) - fi - ) - fi - - (with "git:// protocol" - launch-git-daemon - (with "version 1" - it "generates the correct output" && { - WITH_SNAPSHOT="$snapshot/file-v-any" \ - expect_run $SUCCESSFULLY "$exe_plumbing" free remote ref-list -p 1 git://localhost/ - } - ) - (with "version 2" - it "generates the correct output" && { - WITH_SNAPSHOT="$snapshot/file-v-any" \ - expect_run $SUCCESSFULLY "$exe_plumbing" free remote ref-list -p 2 git://localhost/ - } - ) - ) - if [[ "$kind" == "small" ]]; then - (with "https:// protocol (in small builds)" - it "fails as http is not compiled in" && { - WITH_SNAPSHOT="$snapshot/fail-http-in-small" \ - expect_run $WITH_FAILURE "$exe_plumbing" free remote ref-list -p 1 https://github.com/byron/gitoxide - } - ) - fi - (on_ci - if [[ "$kind" = "max" ]]; then - (with "https:// protocol" - (with "version 1" - it "generates the correct output" && { - expect_run $SUCCESSFULLY "$exe_plumbing" free remote ref-list -p 1 https://github.com/byron/gitoxide - } - ) - (with "version 2" - it "generates the correct output" && { - expect_run $SUCCESSFULLY "$exe_plumbing" free remote ref-list -p 2 https://github.com/byron/gitoxide - } - ) - ) - fi - ) - else - it "fails as the CLI doesn't include networking in 'small' mode" && { - WITH_SNAPSHOT="$snapshot/remote ref-list-no-networking-in-small-failure" \ - expect_run 2 "$exe_plumbing" free remote ref-list -p 1 .git - } - fi - ) - ) - ) - - - title "gix free commit-graph" (when "running 'commit-graph'" snapshot="$snapshot/commit-graph" diff --git a/tests/snapshots/plumbing/no-repo/pack/receive/file-v-any-no-output-non-existing-single-ref b/tests/snapshots/plumbing/no-repo/pack/receive/file-v-any-no-output-non-existing-single-ref index 6bed155babf..62d4a6f5c56 100644 --- a/tests/snapshots/plumbing/no-repo/pack/receive/file-v-any-no-output-non-existing-single-ref +++ b/tests/snapshots/plumbing/no-repo/pack/receive/file-v-any-no-output-non-existing-single-ref @@ -1,5 +1 @@ -Error: The server response could not be parsed - -Caused by: - 0: Upload pack reported an error - 1: unknown ref refs/heads/does-not-exist \ No newline at end of file +Error: unknown ref refs/heads/does-not-exist \ No newline at end of file diff --git a/tests/snapshots/plumbing/no-repo/remote/ref-list/remote ref-list-no-networking-in-small-failure b/tests/snapshots/plumbing/no-repo/remote/ref-list/remote ref-list-no-networking-in-small-failure deleted file mode 100644 index 885fe4f7406..00000000000 --- a/tests/snapshots/plumbing/no-repo/remote/ref-list/remote ref-list-no-networking-in-small-failure +++ /dev/null @@ -1,6 +0,0 @@ -error: Found argument 'remote' which wasn't expected, or isn't valid in this context - -USAGE: - gix free - -For more information try --help \ No newline at end of file diff --git a/tests/snapshots/plumbing/no-repo/remote/ref-list/file-v-any b/tests/snapshots/plumbing/repository/remote/refs/file-v-any similarity index 100% rename from tests/snapshots/plumbing/no-repo/remote/ref-list/file-v-any rename to tests/snapshots/plumbing/repository/remote/refs/file-v-any diff --git a/tests/snapshots/plumbing/no-repo/remote/ref-list/file-v-any-json b/tests/snapshots/plumbing/repository/remote/refs/file-v-any-json similarity index 100% rename from tests/snapshots/plumbing/no-repo/remote/ref-list/file-v-any-json rename to tests/snapshots/plumbing/repository/remote/refs/file-v-any-json diff --git a/tests/snapshots/plumbing/repository/remote/refs/remote ref-list-no-networking-in-small-failure b/tests/snapshots/plumbing/repository/remote/refs/remote ref-list-no-networking-in-small-failure new file mode 100644 index 00000000000..7279eeb4f14 --- /dev/null +++ b/tests/snapshots/plumbing/repository/remote/refs/remote ref-list-no-networking-in-small-failure @@ -0,0 +1,6 @@ +error: Found argument 'refs' which wasn't expected, or isn't valid in this context + +USAGE: + gix remote [OPTIONS] + +For more information try --help \ No newline at end of file