//! Subrepositories in the working copy (spec §7.8): reading pins from //! nested repositories, moving them to pins, and the `oxy submodule` //! commands. use super::*; use crate::subrepo::{Module, Modules, Pin}; use std::process::Command; /// What syncing a nested repository to its pin did. #[derive(Clone, Debug, PartialEq, Eq)] pub enum SubrepoSync { Updated, UpToDate, Missing, Dirty, /// The working copy was brought to the pin, but the nested repository's /// bookmark has changes the pin lacks, so it was left where it is: a /// bookmark is never moved backwards (spec §7.8). KeptBookmark { bookmark: String, newer: usize, }, Failed(String), } impl SubrepoSync { /// A note for a checkout report, when there is something to say. pub fn note(&self, path: &str) -> Option { match self { SubrepoSync::Updated | SubrepoSync::UpToDate => None, SubrepoSync::Missing => Some(format!( "{path}: nested repository not present; run oxy submodule update --init" )), SubrepoSync::Dirty => Some(format!( "{path}: nested repository has uncommitted changes; left as is" )), SubrepoSync::KeptBookmark { bookmark, newer } => Some(format!( "{path}: checked out at the pin; its bookmark {bookmark} keeps {newer} newer change{} (oxy bookmark switch {bookmark} there to return to them)", if *newer == 1 { "" } else { "s" } )), SubrepoSync::Failed(e) => Some(format!("{path}: {e}")), } } } /// One subrepo as `oxy submodule status` shows it. #[derive(Clone, Debug)] pub struct SubrepoInfo { pub path: String, pub pin: Pin, pub url: Option, /// The nested repository's own pin now, if present. pub actual: Option, pub dirty: bool, /// Whether the working copy's base records it. An added subrepo /// (registered in `.oxymodules`, not committed yet) has `pin` set to /// what the next commit records. pub committed: bool, } fn git_out(dir: &Path, args: &[&str]) -> Result { let out = Command::new("git") .arg("-C") .arg(dir) .args(args) .output() .map_err(|e| RepoError::Msg(format!("running git: {e}")))?; if !out.status.success() { return fail(format!( "git {} in {}: {}", args.join(" "), dir.display(), String::from_utf8_lossy(&out.stderr).trim() )); } Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) } /// The pin a nested repository at `dir` is at: the base of its working /// copy for an Oxygen repository (which is its bookmark's heads unless a /// parent checked out an older pin), `HEAD` for a Git one. pub(crate) fn read_pin(dir: &Path, bookmark_hint: Option<&str>) -> Result { if dir.join(OXY_DIR).exists() { let nested = Repo::open(dir)?; return Ok(Pin::Oxy { repo: nested.id()?, heads: nested.working_copy()?.1.deps, bookmark: nested.config.bookmark.clone(), }); } if dir.join(".git").exists() { let sha = git_out(dir, &["rev-parse", "HEAD"])?; return Ok(Pin::Git { sha, branch: bookmark_hint.unwrap_or("HEAD").to_string(), }); } fail(format!("{} is not a repository", dir.display())) } /// Whether a nested repository has uncommitted work. fn nested_dirty(dir: &Path) -> Result { if dir.join(OXY_DIR).exists() { let nested = Repo::open(dir)?; let snap = nested.snapshot("oxy submodule")?; return Ok(!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()); } Ok(!git_out(dir, &["status", "--porcelain"])?.is_empty()) } /// Whether `url` is an Oxygen repository (else it is taken to be Git), /// probed with `options`, the credentials a clone of it would use; with /// the probe's error when it is not, to report if Git cannot clone it /// either. fn probe_oxy_url(url: &str, options: &RemoteOptions) -> std::result::Result<(), String> { if url.starts_with("http://") || url.starts_with("https://") { return crate::protocol::open_remote(url, options) .and_then(|r| r.repo_id()) .map(|_| ()) .map_err(|e| e.to_string()); } let p = Path::new(url.strip_prefix("file://").unwrap_or(url)); if p.join(OXY_DIR).exists() { Ok(()) } else { Err(format!("{} has no {OXY_DIR} directory", p.display())) } } impl Repo { pub(crate) fn modules_on_disk(&self) -> Modules { fs::read_to_string(self.root.join(".oxymodules")) .map(|t| Modules::parse(&t)) .unwrap_or_default() } fn write_modules(&self, m: &Modules) -> Result<()> { if m.entries.is_empty() { let _ = fs::remove_file(self.root.join(".oxymodules")); return Ok(()); } write_atomic(&self.root.join(".oxymodules"), m.to_text().as_bytes()) } /// Bring the nested repository at `path` to `pin`, cloning it first when /// `init` and it is absent. pub(crate) fn sync_subrepo(&self, path: &str, pin: &Pin, init: bool) -> Result { let dir = self.root.join(path); let module = self.modules_on_disk().entries.get(path).cloned(); let present = dir.join(OXY_DIR).exists() || dir.join(".git").exists(); let cloned = !present; if !present { if !init { return Ok(SubrepoSync::Missing); } let Some(m) = &module else { return Ok(SubrepoSync::Failed( "no .oxymodules entry to clone from".into(), )); }; if let Some(parent) = dir.parent() { fs::create_dir_all(parent)?; } let _ = fs::remove_dir(&dir); let r = match pin { Pin::Oxy { .. } => self.clone_as_subrepo(&m.url, &dir).map(|_| ()), Pin::Git { .. } => Command::new("git") .args(["clone", "-q", &m.url]) .arg(&dir) .status() .map_err(|e| RepoError::Msg(e.to_string())) .and_then(|s| { if s.success() { Ok(()) } else { fail(format!("git clone {} failed", m.url)) } }), }; if let Err(e) = r { return Ok(SubrepoSync::Failed(format!("cloning {}: {e}", m.url))); } } let actual = read_pin(&dir, module.as_ref().and_then(|m| m.bookmark.as_deref()))?; let same = match (pin, &actual) { (Pin::Oxy { heads: a, .. }, Pin::Oxy { heads: b, .. }) => { let (mut a, mut b) = (a.clone(), b.clone()); a.sort(); b.sort(); a == b } (Pin::Git { sha: a, .. }, Pin::Git { sha: b, .. }) => a == b, _ => false, }; if same { return Ok(if cloned { SubrepoSync::Updated } else { SubrepoSync::UpToDate }); } if nested_dirty(&dir)? { return Ok(SubrepoSync::Dirty); } match pin { Pin::Oxy { heads, .. } => { let nested = Repo::open(&dir)?; if nested.load_state(heads).is_err() { // The pinned changes are not here yet: fetch from origin. if let Err(e) = nested.fetch("origin") { return Ok(SubrepoSync::Failed(format!( "fetching the pinned changes: {e}" ))); } } match nested.checkout(heads, false, "oxy submodule update") { Ok(_) => { // Its bookmark follows only forward: work it has // beyond the pin stays on it (metalgear, oxygen#38). let bookmark = nested.config.bookmark.clone(); let at = nested.bookmark_heads().unwrap_or_default(); let newer = nested.state_gap(&at, heads).map(|g| g.lacks).unwrap_or(0); if newer == 0 { let _ = nested.bookmark_set(&bookmark, heads); Ok(SubrepoSync::Updated) } else { Ok(SubrepoSync::KeptBookmark { bookmark, newer }) } } Err(e) => Ok(SubrepoSync::Failed(e.to_string())), } } Pin::Git { sha, .. } => { if git_out(&dir, &["cat-file", "-e", &format!("{sha}^{{commit}}")]).is_err() { if let Err(e) = git_out(&dir, &["fetch", "-q", "origin"]) { return Ok(SubrepoSync::Failed(e.to_string())); } } match git_out(&dir, &["checkout", "-q", "--detach", sha]) { Ok(_) => Ok(SubrepoSync::Updated), Err(e) => Ok(SubrepoSync::Failed(e.to_string())), } } } } /// Every subrepo of the working copy's base state. pub fn subrepos(&self) -> Result> { let (_, wc) = self.working_copy()?; let snap = self.load_state(&wc.deps)?.materialize(); let modules = self.modules_on_disk(); let mut out = Vec::new(); for (path, f) in &snap.files { if f.kind != NodeKind::Subrepo { continue; } let FileContent::Opaque { blobs } = &f.content else { continue; }; let Some(b) = blobs.first() else { continue }; let pin = Pin::decode(&self.read_blob(b)?)?; let dir = self.root.join(path); let present = dir.join(OXY_DIR).exists() || dir.join(".git").exists(); let actual = if present { read_pin( &dir, modules .entries .get(path) .and_then(|m| m.bookmark.as_deref()), ) .ok() } else { None }; let dirty = present && nested_dirty(&dir).unwrap_or(false); out.push(SubrepoInfo { path: path.clone(), pin, url: modules.entries.get(path).map(|m| m.url.clone()), actual, dirty, committed: true, }); } // Registered but not committed yet: pinned at what they are now. for (path, m) in &modules.entries { if snap.files.contains_key(path) { continue; } let dir = self.root.join(path); let Ok(pin) = read_pin(&dir, m.bookmark.as_deref()) else { continue; }; out.push(SubrepoInfo { path: path.clone(), pin: pin.clone(), url: Some(m.url.clone()), actual: Some(pin), dirty: nested_dirty(&dir).unwrap_or(false), committed: false, }); } out.sort_by(|a, b| a.path.cmp(&b.path)); Ok(out) } /// `.oxymodules` entries with no subrepo behind them: never added, so /// there is no pin to clone or check out (oxygen#72). pub fn unpinned_modules(&self) -> Result> { let pinned: Vec = self.subrepos()?.into_iter().map(|s| s.path).collect(); Ok(self .modules_on_disk() .entries .into_iter() .filter(|(path, _)| !pinned.contains(path)) .collect()) } /// Clone `url` at `path`, register it in `.oxymodules`, and return the /// pin the next snapshot will record. pub fn submodule_add(&self, url: &str, path: &str, bookmark: Option<&str>) -> Result { let path = path.trim_end_matches('/').to_string(); if path.is_empty() || path.starts_with('/') || path.split('/').any(|c| c == ".." || c == ".") { return fail(format!("{path:?} is not a valid subrepo path")); } let dir = self.root.join(&path); if dir.exists() && fs::read_dir(&dir)?.next().is_some() { return fail(format!("{path} exists and is not empty")); } if let Some(parent) = dir.parent() { fs::create_dir_all(parent)?; } let probe = probe_oxy_url(url, &self.credentials_for(url)); let git = probe.is_err(); if git { let mut cmd = Command::new("git"); cmd.args(["clone", "-q"]); if let Some(b) = bookmark { cmd.args(["--branch", b]); } let status = cmd .arg(url) .arg(&dir) .status() .map_err(|e| RepoError::Msg(format!("running git: {e}")))?; if !status.success() { let oxy_error = probe.err().unwrap_or_default(); return fail(format!( "git clone {url} failed; as an Oxygen repository: {oxy_error}" )); } } else { let _ = fs::remove_dir(&dir); let mut nested = self.clone_as_subrepo(url, &dir)?; if let Some(b) = bookmark { nested.bookmark_switch(b, true)?; } } let mut modules = self.modules_on_disk(); modules.entries.insert( path.clone(), Module { url: url.to_string(), bookmark: bookmark.map(str::to_string), git, }, ); self.write_modules(&modules)?; read_pin(&dir, bookmark) } /// Bring nested repositories to their pins (`paths` empty = all). pub fn submodule_update( &self, init: bool, paths: &[String], ) -> Result> { let mut out = Vec::new(); for s in self.subrepos()? { if !paths.is_empty() && !paths.iter().any(|p| p.trim_end_matches('/') == s.path) { continue; } let r = self.sync_subrepo(&s.path, &s.pin, init)?; out.push((s.path, r)); } Ok(out) } /// Drop the `.oxymodules` entry; the node goes with the next snapshot /// only if the directory is removed, since a registered or tracked /// nested repository keeps contributing its pin. Here the directory is /// left in place and the node is removed explicitly from the working /// copy's next change by unregistering and moving the directory aside. pub fn submodule_remove(&self, path: &str) -> Result<()> { let path = path.trim_end_matches('/').to_string(); let mut modules = self.modules_on_disk(); let known = modules.entries.remove(&path).is_some() || self.subrepos()?.iter().any(|s| s.path == path); if !known { return fail(format!("{path} is not a subrepo")); } self.write_modules(&modules)?; // The directory stays; the walker ignores an unregistered nested // repository unless tracked, so mark it as removed for the next // snapshot by recording an explicit removal. let dir = self.oxy.join("subrepos-removed"); fs::create_dir_all(&dir)?; write_atomic(&dir.join(path.replace('/', "%2F")), b"")?; Ok(()) } /// Paths whose subrepo node was removed with `oxy submodule remove` and /// whose directory is still on disk. pub(crate) fn removed_subrepos(&self) -> BTreeSet { let mut out = BTreeSet::new(); if let Ok(rd) = fs::read_dir(self.oxy.join("subrepos-removed")) { for e in rd.flatten() { out.insert(e.file_name().to_string_lossy().replace("%2F", "/")); } } out } pub(crate) fn clear_removed_subrepo(&self, path: &str) { let _ = fs::remove_file( self.oxy .join("subrepos-removed") .join(path.replace('/', "%2F")), ); } }