//! Subrepositories (spec ยง7.8): the pin stored as a `subrepo` node's //! content, and the `.oxymodules` file mapping paths to remotes. //! //! Pin encoding: `{0: 1, 1: kind, 2: locator, 3: pin, 4: bookmark}`; kind 0 //! is an Oxygen repository (locator = repository id, pin = sorted heads), //! kind 1 a Git repository (locator = null, pin = commit sha as text). use crate::cbor::{self, Value}; use crate::hash::ChangeHash; use crate::objects::{ array, bytes, check_version, field, oref, text, uint, ObjectError, FORMAT_VERSION, }; use std::collections::BTreeMap; #[derive(Clone, Debug, PartialEq, Eq)] pub enum Pin { Oxy { repo: [u8; 16], heads: Vec, bookmark: String, }, Git { sha: String, branch: String, }, } impl Pin { pub fn encode(&self) -> Vec { let v = match self { Pin::Oxy { repo, heads, bookmark, } => { let mut heads = heads.clone(); heads.sort(); heads.dedup(); Value::map([ (0, Value::Uint(FORMAT_VERSION)), (1, Value::Uint(0)), (2, Value::Bytes(repo.to_vec())), ( 3, Value::Array(heads.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()), ), (4, Value::Text(bookmark.clone())), ]) } Pin::Git { sha, branch } => Value::map([ (0, Value::Uint(FORMAT_VERSION)), (1, Value::Uint(1)), (2, Value::Null), (3, Value::Text(sha.clone())), (4, Value::Text(branch.clone())), ]), }; cbor::encode(&v) } pub fn decode(b: &[u8]) -> Result { let v = cbor::decode(b)?; let m = v .as_map() .ok_or_else(|| ObjectError::Schema("pin: expected map".into()))?; check_version(m)?; let bookmark = text(field(m, 4, "bookmark")?, "bookmark")?.to_owned(); match uint(field(m, 1, "kind")?, "kind")? { 0 => { let repo: [u8; 16] = bytes(field(m, 2, "repo")?, "repo")? .try_into() .map_err(|_| ObjectError::Schema("pin: repo id must be 16 bytes".into()))?; let heads = array(field(m, 3, "heads")?, "heads")? .iter() .map(|h| oref(h, "head")) .collect::, _>>()?; Ok(Pin::Oxy { repo, heads, bookmark, }) } 1 => Ok(Pin::Git { sha: text(field(m, 3, "sha")?, "sha")?.to_owned(), branch: bookmark, }), k => Err(ObjectError::Schema(format!("pin: unknown kind {k}"))), } } /// A one-line description for listings. pub fn summary(&self) -> String { match self { Pin::Oxy { heads, bookmark, .. } => format!( "oxy {bookmark} {}", heads .iter() .map(|h| h.short(12)) .collect::>() .join(" ") ), Pin::Git { sha, branch } => format!("git {branch} {}", &sha[..sha.len().min(12)]), } } pub fn is_git(&self) -> bool { matches!(self, Pin::Git { .. }) } } /// One `.oxymodules` entry. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Module { pub url: String, /// Oxygen bookmark or Git branch to track. pub bookmark: Option, pub git: bool, } /// `.oxymodules`: `[path]` sections with `url`, and `bookmark` (Oxygen) or /// `branch` (Git) keys. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Modules { pub entries: BTreeMap, } impl Modules { pub fn parse(text: &str) -> Modules { let mut out = Modules::default(); let mut current: Option = None; for line in text.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } if let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { let path = inner .trim() .trim_matches('"') .trim_end_matches('/') .to_string(); out.entries.entry(path.clone()).or_default(); current = Some(path); continue; } let (Some(path), Some((k, v))) = (¤t, line.split_once('=')) else { continue; }; let v = v.trim().trim_matches('"').to_string(); let m = out.entries.get_mut(path).unwrap(); match k.trim() { "url" => m.url = v, "bookmark" => { m.bookmark = Some(v); m.git = false; } "branch" => { m.bookmark = Some(v); m.git = true; } "kind" => m.git = v == "git", _ => {} } } out } pub fn to_text(&self) -> String { let mut s = String::new(); for (path, m) in &self.entries { s.push_str(&format!("[{path}]\nurl = {}\n", m.url)); if m.git { s.push_str("kind = git\n"); } if let Some(b) = &m.bookmark { s.push_str(&format!( "{} = {b}\n", if m.git { "branch" } else { "bookmark" } )); } s.push('\n'); } s } /// From Git's `.gitmodules` (`[submodule "name"]` with `path`, `url`, `branch`). pub fn from_gitmodules(text: &str) -> Modules { let mut out = Modules::default(); let mut path: Option = None; let mut url = String::new(); let mut branch: Option = None; let flush = |out: &mut Modules, path: &Option, url: &str, branch: &Option| { if let Some(p) = path { out.entries.insert( p.clone(), Module { url: url.to_string(), bookmark: branch.clone(), git: true, }, ); } }; for line in text.lines() { let line = line.trim(); if line.starts_with("[submodule") { flush(&mut out, &path, &url, &branch); path = None; url.clear(); branch = None; continue; } if let Some((k, v)) = line.split_once('=') { let v = v.trim().to_string(); match k.trim() { "path" => path = Some(v.trim_end_matches('/').to_string()), "url" => url = v, "branch" => branch = Some(v), _ => {} } } } flush(&mut out, &path, &url, &branch); out } /// Git's `.gitmodules` form of the Git-kind entries. pub fn to_gitmodules(&self) -> String { let mut s = String::new(); for (path, m) in &self.entries { if !m.git { continue; } s.push_str(&format!( "[submodule \"{path}\"]\n\tpath = {path}\n\turl = {}\n", m.url )); if let Some(b) = &m.bookmark { s.push_str(&format!("\tbranch = {b}\n")); } } s } } #[cfg(test)] mod tests { use super::*; use crate::hash::ObjectRef; #[test] fn pins_round_trip() { let o = Pin::Oxy { repo: [7; 16], heads: vec![ObjectRef([2; 32]), ObjectRef([1; 32])], bookmark: "main".into(), }; let back = Pin::decode(&o.encode()).unwrap(); assert_eq!( back, Pin::Oxy { repo: [7; 16], heads: vec![ObjectRef([1; 32]), ObjectRef([2; 32])], bookmark: "main".into() } ); let g = Pin::Git { sha: "0cb4742f64cf204e5fafd0963749d84afc561892".into(), branch: "main".into(), }; assert_eq!(Pin::decode(&g.encode()).unwrap(), g); assert!(g.is_git() && !o.is_git()); } #[test] fn modules_files() { let text = "[libs/sub]\nurl = ../sub\nbookmark = main\n\n[vendor/x]\nurl = https://example.org/x.git\nkind = git\nbranch = trunk\n"; let m = Modules::parse(text); assert_eq!( m.entries["libs/sub"], Module { url: "../sub".into(), bookmark: Some("main".into()), git: false } ); assert_eq!( m.entries["vendor/x"], Module { url: "https://example.org/x.git".into(), bookmark: Some("trunk".into()), git: true } ); assert_eq!(Modules::parse(&m.to_text()), m); let gm = "[submodule \"rstar-api\"]\n\tpath = rstar-api\n\turl = https://github.com/x/rstar-api.git\n[submodule \"c\"]\n\tpath = cstar-api\n\turl = https://github.com/x/cstar-api.git\n\tbranch = dev\n"; let g = Modules::from_gitmodules(gm); assert_eq!(g.entries.len(), 2); assert!(g.entries["rstar-api"].git && g.entries["rstar-api"].bookmark.is_none()); assert_eq!(g.entries["cstar-api"].bookmark.as_deref(), Some("dev")); assert!( g.to_gitmodules().contains("[submodule \"cstar-api\"]") && g.to_gitmodules().contains("branch = dev") ); } }