1pub mod canonical;
2pub mod raw;
3
4use std::io;
5use std::path::Path;
6use std::process::Command;
7use std::str::FromStr;
8
9pub use radicle_oid::{str::ParseOidError, Oid};
10
11pub extern crate radicle_git_ref_format as fmt;
12
13use crate::crypto::PublicKey;
14use crate::node::Alias;
15use crate::rad;
16use crate::storage::RemoteId;
17
18pub use crate::storage::git::transport::local::Url;
19
20use raw::ErrorExt as _;
21
22pub type BranchName = crate::git::fmt::RefString;
23
24pub const PROTOCOL_PORT: u16 = 9418;
26pub const VERSION_REQUIRED: Version = Version {
28 major: 2,
29 minor: 31,
30 patch: 0,
31};
32
33#[derive(PartialEq, Eq, Debug, PartialOrd, Ord)]
35pub struct Version {
36 pub major: u8,
37 pub minor: u8,
38 pub patch: u8,
39}
40
41impl std::fmt::Display for Version {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
44 }
45}
46
47#[derive(Default, Clone, Copy)]
49pub struct Verbosity(i8);
50
51impl Verbosity {
52 pub fn into_flag(&self) -> Option<String> {
57 const FLAG_PREFIX: &str = "-";
58 const FLAG_QUIET: &str = "q";
59 const FLAG_VERBOSE: &str = "v";
60
61 let repetitions = self.0.unsigned_abs() as usize;
62
63 if repetitions == 0 {
64 return None;
65 }
66
67 let flag = if self.0 > 0 { FLAG_VERBOSE } else { FLAG_QUIET };
68
69 Some(FLAG_PREFIX.to_string() + &flag.repeat(repetitions))
70 }
71
72 fn clamp(self, min: i8, max: i8) -> Self {
75 Self(self.0.clamp(min, max))
76 }
77
78 pub fn clamp_one(self) -> Self {
81 self.clamp(-1, 1)
82 }
83}
84
85impl From<i8> for Verbosity {
86 fn from(v: i8) -> Self {
87 Self(v)
88 }
89}
90
91#[derive(thiserror::Error, Debug)]
92pub enum VersionError {
93 #[error("malformed git version string")]
94 Malformed,
95 #[error("malformed git version string: {0}")]
96 ParseInt(#[from] std::num::ParseIntError),
97 #[error("malformed git version string: {0}")]
98 Utf8(#[from] std::string::FromUtf8Error),
99 #[error("error retrieving git version: {0}")]
100 Io(#[from] io::Error),
101 #[error("error retrieving git version: {0}")]
102 Other(String),
103}
104
105impl std::str::FromStr for Version {
106 type Err = VersionError;
107
108 fn from_str(input: &str) -> Result<Self, Self::Err> {
109 let rest = input
110 .strip_prefix("git version ")
111 .ok_or(VersionError::Malformed)?;
112 let rest = rest.split(' ').next().ok_or(VersionError::Malformed)?;
113 let rest = rest.trim_end();
114
115 let mut parts = rest.split('.');
116 let major = parts.next().ok_or(VersionError::Malformed)?.parse()?;
117 let minor = parts.next().ok_or(VersionError::Malformed)?.parse()?;
118
119 let patch = match parts.next() {
120 None => 0,
121 Some(patch) => patch.parse()?,
122 };
123
124 Ok(Self {
125 major,
126 minor,
127 patch,
128 })
129 }
130}
131
132pub fn version() -> Result<Version, VersionError> {
134 let output = Command::new("git").arg("version").output()?;
135
136 if output.status.success() {
137 let output = String::from_utf8(output.stdout)?;
138 let version = output.parse()?;
139
140 return Ok(version);
141 }
142 Err(VersionError::Other(
143 String::from_utf8_lossy(&output.stderr).to_string(),
144 ))
145}
146
147#[derive(thiserror::Error, Debug)]
148pub enum RefError {
149 #[error("ref name is not valid UTF-8")]
150 InvalidName,
151 #[error("unexpected unqualified ref: {0}")]
152 Unqualified(fmt::RefString),
153 #[error("invalid ref format: {0}")]
154 Format(#[from] fmt::Error),
155 #[error("reference has no target")]
156 NoTarget,
157 #[error("expected ref to begin with 'refs/namespaces' but found '{0}'")]
158 MissingNamespace(fmt::RefString),
159 #[error("ref name contains invalid namespace identifier '{name}'")]
160 InvalidNamespace {
161 name: fmt::RefString,
162 #[source]
163 err: Box<dyn std::error::Error + Send + Sync + 'static>,
164 },
165 #[error(transparent)]
166 Other(#[from] raw::Error),
167}
168
169#[derive(thiserror::Error, Debug)]
170pub enum ListRefsError {
171 #[error("git error: {0}")]
172 Git(#[from] raw::Error),
173 #[error("invalid ref: {0}")]
174 InvalidRef(#[from] RefError),
175}
176
177pub mod refs {
178 use std::sync::LazyLock;
179
180 use radicle_cob as cob;
181
182 use super::fmt::*;
183 use super::*;
184
185 pub fn qualified_from<'a>(r: &'a raw::Reference) -> Result<(Qualified<'a>, Oid), RefError> {
187 let name = r.name().ok_or(RefError::InvalidName)?;
188 let refstr = RefStr::try_from_str(name)?;
189 let target = r.resolve()?.target().ok_or(RefError::NoTarget)?;
190 let qualified = Qualified::from_refstr(refstr)
191 .ok_or_else(|| RefError::Unqualified(refstr.to_owned()))?;
192
193 Ok((qualified, target.into()))
194 }
195
196 pub fn branch<'a>(branch: &RefStr) -> Qualified<'a> {
201 Qualified::from(lit::refs_heads(branch))
202 }
203
204 pub fn patch<'a>(object_id: &cob::ObjectId) -> Qualified<'a> {
209 Qualified::from_components(
210 component!("heads"),
211 component!("patches"),
212 Some(object_id.into()),
213 )
214 }
215
216 pub mod storage {
217 use super::*;
218
219 pub static IDENTITY_BRANCH: LazyLock<Qualified> =
224 LazyLock::new(|| Qualified::from_components(component!("rad"), component!("id"), None));
225
226 pub static IDENTITY_ROOT: LazyLock<Qualified> = LazyLock::new(|| {
231 Qualified::from_components(component!("rad"), component!("root"), None)
232 });
233
234 pub static SIGREFS_BRANCH: LazyLock<Qualified> = LazyLock::new(|| {
239 Qualified::from_components(component!("rad"), component!("sigrefs"), None)
240 });
241
242 pub static SIGREFS_PARENT: LazyLock<Qualified> = LazyLock::new(|| {
247 Qualified::from_components(component!("rad"), component!("sigrefs-parent"), None)
248 });
249
250 #[derive(Clone, Copy, Debug)]
252 pub enum Special {
253 Id,
255 SignedRefs,
257 }
258
259 impl From<Special> for Qualified<'_> {
260 fn from(s: Special) -> Self {
261 match s {
262 Special::Id => (*IDENTITY_BRANCH).clone(),
263 Special::SignedRefs => (*SIGREFS_BRANCH).clone(),
264 }
265 }
266 }
267
268 impl Special {
269 pub fn namespaced<'a>(&self, remote: &PublicKey) -> Namespaced<'a> {
270 Qualified::from(*self).with_namespace(Component::from(remote))
271 }
272
273 pub fn from_qualified(refname: &Qualified) -> Option<Self> {
274 if refname == &*IDENTITY_BRANCH {
275 Some(Special::Id)
276 } else if refname == &*SIGREFS_BRANCH {
277 Some(Special::SignedRefs)
278 } else {
279 None
280 }
281 }
282 }
283
284 pub fn branch_of<'a>(remote: &RemoteId, branch: &RefStr) -> Namespaced<'a> {
289 Qualified::from(lit::refs_heads(branch)).with_namespace(remote.into())
290 }
291
292 pub fn id(remote: &RemoteId) -> Namespaced<'_> {
297 IDENTITY_BRANCH.with_namespace(remote.into())
298 }
299
300 pub fn id_root(remote: &RemoteId) -> Namespaced<'_> {
305 IDENTITY_ROOT.with_namespace(remote.into())
306 }
307
308 pub fn sigrefs(remote: &RemoteId) -> Namespaced<'_> {
314 SIGREFS_BRANCH.with_namespace(remote.into())
315 }
316
317 pub fn cob<'a>(
322 remote: &RemoteId,
323 typename: &cob::TypeName,
324 object_id: &cob::ObjectId,
325 ) -> Namespaced<'a> {
326 Qualified::from_components(
327 component!("cobs"),
328 Component::from(typename),
329 Some(object_id.into()),
330 )
331 .with_namespace(remote.into())
332 }
333
334 pub fn cobs(typename: &cob::TypeName, object_id: &cob::ObjectId) -> refspec::PatternString {
339 pattern!("refs/namespaces/*")
340 .join(refname!("refs/cobs"))
341 .join(Component::from(typename))
342 .join(Component::from(object_id))
343 }
344
345 pub mod draft {
349 use super::*;
350
351 pub fn review<'a>(remote: &RemoteId, patch: &cob::ObjectId) -> Namespaced<'a> {
357 Qualified::from_components(
358 component!("drafts"),
359 component!("reviews"),
360 Some(Component::from(patch)),
361 )
362 .with_namespace(remote.into())
363 }
364
365 pub fn cob<'a>(
371 remote: &RemoteId,
372 typename: &cob::TypeName,
373 object_id: &cob::ObjectId,
374 ) -> Namespaced<'a> {
375 Qualified::from_components(
376 component!("drafts"),
377 component!("cobs"),
378 [Component::from(typename), object_id.into()],
379 )
380 .with_namespace(remote.into())
381 }
382
383 pub fn cobs(
388 typename: &cob::TypeName,
389 object_id: &cob::ObjectId,
390 ) -> refspec::PatternString {
391 pattern!("refs/namespaces/*")
392 .join(refname!("refs/drafts/cobs"))
393 .join(Component::from(typename))
394 .join(Component::from(object_id))
395 }
396 }
397
398 pub mod staging {
400 use super::*;
401
402 pub fn patch<'a>(remote: &RemoteId, oid: impl Into<Oid>) -> Namespaced<'a> {
409 #[allow(clippy::unwrap_used)]
411 let oid = RefString::try_from(oid.into().to_string()).unwrap();
412 #[allow(clippy::unwrap_used)]
413 let oid = Component::from_refstr(oid).unwrap();
414
415 Qualified::from_components(component!("tmp"), component!("heads"), Some(oid))
416 .with_namespace(remote.into())
417 }
418 }
419 }
420
421 pub mod workdir {
422 use super::*;
423
424 pub fn branch(branch: &RefStr) -> RefString {
426 refname!("refs/heads").join(branch)
427 }
428
429 pub fn note(name: &RefStr) -> RefString {
431 refname!("refs/notes").join(name)
432 }
433
434 pub fn remote_branch(remote: &RefStr, branch: &RefStr) -> RefString {
436 refname!("refs/remotes").and(remote).and(branch)
437 }
438
439 pub fn tag(name: &RefStr) -> RefString {
441 refname!("refs/tags").join(name)
442 }
443
444 pub fn patch_upstream<'a>(patch_id: &cob::ObjectId) -> Qualified<'a> {
449 Qualified::from_components(
450 component!("remotes"),
451 crate::rad::REMOTE_COMPONENT.clone(),
452 [component!("patches"), patch_id.into()],
453 )
454 }
455 }
456}
457
458pub fn parse_ref_namespaced<T>(s: &str) -> Result<(T, fmt::Qualified<'_>), RefError>
474where
475 T: FromStr,
476 T::Err: std::error::Error + Send + Sync + 'static,
477{
478 match parse_ref::<T>(s) {
479 Ok((None, refname)) => Err(RefError::MissingNamespace(refname.to_ref_string())),
480 Ok((Some(t), r)) => Ok((t, r)),
481 Err(err) => Err(err),
482 }
483}
484
485pub fn parse_ref<T>(s: &str) -> Result<(Option<T>, fmt::Qualified<'_>), RefError>
503where
504 T: FromStr,
505 T::Err: std::error::Error + Send + Sync + 'static,
506{
507 let input = fmt::RefStr::try_from_str(s)?;
508 match input.to_namespaced() {
509 None => {
510 let refname = fmt::Qualified::from_refstr(input)
511 .ok_or_else(|| RefError::Unqualified(input.to_owned()))?;
512
513 Ok((None, refname))
514 }
515 Some(ns) => {
516 let id = ns
517 .namespace()
518 .as_str()
519 .parse()
520 .map_err(|err| RefError::InvalidNamespace {
521 name: input.to_owned(),
522 err: Box::new(err),
523 })?;
524 let rest = ns.strip_namespace();
525
526 Ok((Some(id), rest))
527 }
528 }
529}
530
531pub fn initial_commit<'a>(
533 repo: &'a raw::Repository,
534 sig: &raw::Signature,
535) -> Result<raw::Commit<'a>, raw::Error> {
536 let tree_id = repo.index()?.write_tree()?;
537 let tree = repo.find_tree(tree_id)?;
538 let oid = repo.commit(None, sig, sig, "Initial commit", &tree, &[])?;
539 let commit = repo.find_commit(oid)?;
540
541 Ok(commit)
542}
543
544pub fn commit<'a>(
546 repo: &'a raw::Repository,
547 parent: &'a raw::Commit,
548 target: &fmt::RefStr,
549 message: &str,
550 sig: &raw::Signature,
551 tree: &raw::Tree,
552) -> Result<raw::Commit<'a>, raw::Error> {
553 let oid = repo.commit(Some(target.as_str()), sig, sig, message, tree, &[parent])?;
554 let commit = repo.find_commit(oid)?;
555
556 Ok(commit)
557}
558
559pub fn empty_commit<'a>(
561 repo: &'a raw::Repository,
562 parent: &'a raw::Commit,
563 target: &fmt::RefStr,
564 message: &str,
565 sig: &raw::Signature,
566) -> Result<raw::Commit<'a>, raw::Error> {
567 let tree = parent.tree()?;
568 let oid = repo.commit(Some(target.as_str()), sig, sig, message, &tree, &[parent])?;
569 let commit = repo.find_commit(oid)?;
570
571 Ok(commit)
572}
573
574pub fn head(repo: &raw::Repository) -> Result<raw::Commit<'_>, raw::Error> {
576 let head = repo.head()?.peel_to_commit()?;
577
578 Ok(head)
579}
580
581pub fn write_tree<'r>(
583 path: &Path,
584 bytes: &[u8],
585 repo: &'r raw::Repository,
586) -> Result<raw::Tree<'r>, raw::Error> {
587 let blob_id = repo.blob(bytes)?;
588 let mut builder = repo.treebuilder(None)?;
589 builder.insert(path, blob_id, 0o100_644)?;
590
591 let tree_id = builder.write()?;
592 let tree = repo.find_tree(tree_id)?;
593
594 Ok(tree)
595}
596
597pub fn configure_repository(repo: &raw::Repository) -> Result<(), raw::Error> {
601 let mut cfg = repo.config()?;
602 cfg.set_str("push.default", "upstream")?;
603
604 Ok(())
605}
606
607pub fn configure_remote<'r>(
628 repo: &'r raw::Repository,
629 name: &str,
630 fetch: &Url,
631 push: &Url,
632) -> Result<raw::Remote<'r>, raw::Error> {
633 let fetchspec = format!("+refs/heads/*:refs/remotes/{name}/*");
634 let remote = repo.remote_with_fetch(name, fetch.to_string().as_str(), &fetchspec)?;
635
636 let tags = format!("+refs/tags/*:refs/remotes/{name}/tags/*");
640 repo.remote_add_fetch(name, &tags)?;
641
642 if name != (*rad::REMOTE_NAME).as_str() {
643 let mut config = repo.config()?;
644 config.set_bool(&format!("remote.{name}.pruneTags"), false)?;
645 config.set_str(&format!("remote.{name}.tagOpt"), "--no-tags")?;
646 }
647
648 if push != fetch {
649 repo.remote_set_pushurl(name, Some(push.to_string().as_str()))?;
650 }
651 Ok(remote)
652}
653
654pub fn fetch(repo: &raw::Repository, remote: &str) -> Result<(), raw::Error> {
656 repo.find_remote(remote)?.fetch::<&str>(
657 &[],
658 Some(
659 raw::FetchOptions::new()
660 .update_fetchhead(false)
661 .prune(raw::FetchPrune::On)
662 .download_tags(raw::AutotagOption::None),
663 ),
664 None,
665 )
666}
667
668pub fn push<'a>(
670 repo: &raw::Repository,
671 remote: &str,
672 refspecs: impl IntoIterator<Item = (&'a fmt::Qualified<'a>, &'a fmt::Qualified<'a>)>,
673) -> Result<(), raw::Error> {
674 let refspecs = refspecs
675 .into_iter()
676 .map(|(src, dst)| format!("{src}:{dst}"));
677
678 repo.find_remote(remote)?
679 .push(refspecs.collect::<Vec<_>>().as_slice(), None)?;
680
681 Ok(())
682}
683
684pub fn set_upstream(
695 repo: &raw::Repository,
696 remote: impl AsRef<str>,
697 branch: impl AsRef<str>,
698 merge: impl AsRef<str>,
699) -> Result<(), raw::Error> {
700 let remote = remote.as_ref();
701 let branch = branch.as_ref();
702 let merge = merge.as_ref();
703
704 let mut config = repo.config()?;
705 let branch_remote = format!("branch.{branch}.remote");
706 let branch_merge = format!("branch.{branch}.merge");
707
708 config.remove_multivar(&branch_remote, ".*").or_else(|e| {
709 if e.is_not_found() {
710 Ok(())
711 } else {
712 Err(e)
713 }
714 })?;
715 config.remove_multivar(&branch_merge, ".*").or_else(|e| {
716 if e.is_not_found() {
717 Ok(())
718 } else {
719 Err(e)
720 }
721 })?;
722 config.set_multivar(&branch_remote, ".*", remote)?;
723 config.set_multivar(&branch_merge, ".*", merge)?;
724
725 Ok(())
726}
727
728pub fn init_default_branch(repo: &raw::Repository) -> Result<Option<String>, raw::Error> {
729 let config = repo.config().and_then(|mut c| c.snapshot())?;
730 let default_branch = config.get_str("init.defaultbranch")?;
731 let branch = repo.find_branch(default_branch, raw::BranchType::Local)?;
732 Ok(branch.into_reference().shorthand().map(ToOwned::to_owned))
733}
734
735pub fn head_refname(repo: &raw::Repository) -> Result<Option<String>, raw::Error> {
736 let head = repo.head()?;
737 match head.shorthand() {
738 Some("HEAD") => Ok(None),
739 Some(refname) => Ok(Some(refname.to_owned())),
740 None => Ok(None),
741 }
742}
743
744pub fn run<S>(
749 working: Option<&std::path::Path>,
750 args: impl IntoIterator<Item = S>,
751) -> io::Result<std::process::Output>
752where
753 S: AsRef<std::ffi::OsStr>,
754{
755 let mut cmd = Command::new("git");
756
757 if let Some(working) = working {
758 cmd.arg("-C").arg(dunce::canonicalize(working)?);
759 }
760
761 cmd.args(args).output()
762}
763
764pub mod process {
766 use std::io;
767 use std::path::Path;
768
769 use crate::storage::ReadRepository;
770
771 use super::{run, Oid, Verbosity};
772
773 pub fn fetch_pack<R>(
778 working: Option<&Path>,
779 storage: &R,
780 oids: impl IntoIterator<Item = Oid>,
781 verbosity: Verbosity,
782 ) -> io::Result<std::process::Output>
783 where
784 R: ReadRepository,
785 {
786 let mut args = vec!["fetch-pack".to_string()];
787 args.extend(verbosity.clamp_one().into_flag());
788 args.push(dunce::canonicalize(storage.path())?.display().to_string());
789 args.extend(oids.into_iter().map(|oid| oid.to_string()));
790 run(working, args)
791 }
792}
793
794pub mod url {
796 use std::path::PathBuf;
797
798 use crate::prelude::RepoId;
799
800 pub struct File {
802 pub path: PathBuf,
803 }
804
805 impl File {
806 pub fn new(path: impl Into<PathBuf>) -> Self {
808 Self { path: path.into() }
809 }
810
811 pub fn rid(mut self, rid: RepoId) -> Self {
813 self.path.push(rid.canonical());
814 self
815 }
816 }
817
818 impl std::fmt::Display for File {
819 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
820 write!(f, "file://{}", self.path.display())
821 }
822 }
823}
824
825pub mod env {
827 pub const GIT_DEFAULT_CONFIG: [(&str, &str); 2] = [
829 ("GIT_CONFIG_GLOBAL", "/dev/null"),
830 ("GIT_CONFIG_NOSYSTEM", "1"),
831 ];
832}
833
834#[derive(Debug, Clone)]
837pub struct UserInfo {
838 pub alias: Alias,
840 pub key: PublicKey,
842}
843
844impl UserInfo {
845 pub fn name(&self) -> Alias {
847 self.alias.clone()
848 }
849
850 pub fn email(&self) -> String {
853 format!("{}@{}", self.alias, self.key)
854 }
855}
856
857#[cfg(test)]
858mod test {
859 use super::*;
860 use std::str::FromStr;
861
862 #[test]
863 fn test_version_ord() {
864 assert!(
865 Version {
866 major: 2,
867 minor: 34,
868 patch: 1
869 } > Version {
870 major: 2,
871 minor: 34,
872 patch: 0
873 }
874 );
875 assert!(
876 Version {
877 major: 2,
878 minor: 24,
879 patch: 12
880 } < Version {
881 major: 2,
882 minor: 34,
883 patch: 0
884 }
885 );
886 }
887
888 #[test]
889 fn test_version_from_str() {
890 assert_eq!(
891 Version::from_str("git version 2.34.1\n").ok(),
892 Some(Version {
893 major: 2,
894 minor: 34,
895 patch: 1
896 })
897 );
898
899 assert_eq!(
900 Version::from_str("git version 2.34.1 (macOS)").ok(),
901 Some(Version {
902 major: 2,
903 minor: 34,
904 patch: 1
905 })
906 );
907
908 assert_eq!(
909 Version::from_str("git version 2.34").ok(),
910 Some(Version {
911 major: 2,
912 minor: 34,
913 patch: 0
914 })
915 );
916
917 assert!(Version::from_str("2.34").is_err());
918 }
919}