OXYGENHUB

Coverage / crates/oxy-core/src/repo/subrepos.rs 79.9%

255 of 319 lines run, 26 of 60 functions · run #350 on proposal/125 · run missed

1
//! Subrepositories in the working copy (spec §7.8): reading pins from
2
//! nested repositories, moving them to pins, and the `oxy submodule`
3
//! commands.
4
5
use super::*;
6
use crate::subrepo::{Module, Modules, Pin};
7
use std::process::Command;
8
9
/// What syncing a nested repository to its pin did.
10
#[derive(Clone, Debug, PartialEq, Eq)]
11
pub enum SubrepoSync {
12
    Updated,
13
    UpToDate,
14
    Missing,
15
    Dirty,
16
    /// The working copy was brought to the pin, but the nested repository's
17
    /// bookmark has changes the pin lacks, so it was left where it is: a
18
    /// bookmark is never moved backwards (spec §7.8).
19
    KeptBookmark {
20
        bookmark: String,
21
        newer: usize,
22
    },
23
    Failed(String),
24
}
25
26
impl SubrepoSync {
27
    /// A note for a checkout report, when there is something to say.
289×
    pub fn note(&self, path: &str) -> Option<String> {
299×
        match self {
304×
            SubrepoSync::Updated | SubrepoSync::UpToDate => None,
313×
            SubrepoSync::Missing => Some(format!(
323×
                "{path}: nested repository not present; run oxy submodule update --init"
333×
            )),
341×
            SubrepoSync::Dirty => Some(format!(
351×
                "{path}: nested repository has uncommitted changes; left as is"
361×
            )),
371×
            SubrepoSync::KeptBookmark { bookmark, newer } => Some(format!(
38
                "{path}: checked out at the pin; its bookmark {bookmark} keeps {newer} newer change{} (oxy bookmark switch {bookmark} there to return to them)",
391×
                if *newer == 1 { "" } else { "s" }
40
            )),
410×
            SubrepoSync::Failed(e) => Some(format!("{path}: {e}")),
42
        }
439×
    }
44
}
45
46
/// One subrepo as `oxy submodule status` shows it.
47
#[derive(Clone, Debug)]
48
pub struct SubrepoInfo {
49
    pub path: String,
50
    pub pin: Pin,
51
    pub url: Option<String>,
52
    /// The nested repository's own pin now, if present.
53
    pub actual: Option<Pin>,
54
    pub dirty: bool,
55
    /// Whether the working copy's base records it. An added subrepo
56
    /// (registered in `.oxymodules`, not committed yet) has `pin` set to
57
    /// what the next commit records.
58
    pub committed: bool,
59
}
60
612×
fn git_out(dir: &Path, args: &[&str]) -> Result<String> {
622×
    let out = Command::new("git")
632×
        .arg("-C")
642×
        .arg(dir)
652×
        .args(args)
662×
        .output()
672×
        .map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
682×
    if !out.status.success() {
690×
        return fail(format!(
70
            "git {} in {}: {}",
710×
            args.join(" "),
720×
            dir.display(),
730×
            String::from_utf8_lossy(&out.stderr).trim()
74
        ));
752×
    }
762×
    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
772×
}
78
79
/// The pin a nested repository at `dir` is at: the base of its working
80
/// copy for an Oxygen repository (which is its bookmark's heads unless a
81
/// parent checked out an older pin), `HEAD` for a Git one.
8266×
pub(crate) fn read_pin(dir: &Path, bookmark_hint: Option<&str>) -> Result<Pin> {
8366×
    if dir.join(OXY_DIR).exists() {
8462×
        let nested = Repo::open(dir)?;
85
        return Ok(Pin::Oxy {
8662×
            repo: nested.id()?,
8762×
            heads: nested.working_copy()?.1.deps,
8862×
            bookmark: nested.config.bookmark.clone(),
89
        });
904×
    }
914×
    if dir.join(".git").exists() {
922×
        let sha = git_out(dir, &["rev-parse", "HEAD"])?;
932×
        return Ok(Pin::Git {
942×
            sha,
952×
            branch: bookmark_hint.unwrap_or("HEAD").to_string(),
962×
        });
972×
    }
982×
    fail(format!("{} is not a repository", dir.display()))
9966×
}
100
101
/// Whether a nested repository has uncommitted work.
1028×
fn nested_dirty(dir: &Path) -> Result<bool> {
1038×
    if dir.join(OXY_DIR).exists() {
1048×
        let nested = Repo::open(dir)?;
1058×
        let snap = nested.snapshot("oxy submodule")?;
1068×
        return Ok(!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty());
1070×
    }
1080×
    Ok(!git_out(dir, &["status", "--porcelain"])?.is_empty())
1098×
}
110
111
/// Whether `url` is an Oxygen repository (else it is taken to be Git),
112
/// probed with `options`, the credentials a clone of it would use; with
113
/// the probe's error when it is not, to report if Git cannot clone it
114
/// either.
1159×
fn probe_oxy_url(url: &str, options: &RemoteOptions) -> std::result::Result<(), String> {
1169×
    if url.starts_with("http://") || url.starts_with("https://") {
1170×
        return crate::protocol::open_remote(url, options)
1180×
            .and_then(|r| r.repo_id())
1190×
            .map(|_| ())
1200×
            .map_err(|e| e.to_string());
1219×
    }
1229×
    let p = Path::new(url.strip_prefix("file://").unwrap_or(url));
1239×
    if p.join(OXY_DIR).exists() {
1249×
        Ok(())
125
    } else {
1260×
        Err(format!("{} has no {OXY_DIR} directory", p.display()))
127
    }
1289×
}
129
130
impl Repo {
1311467×
    pub(crate) fn modules_on_disk(&self) -> Modules {
1321467×
        fs::read_to_string(self.root.join(".oxymodules"))
1331467×
            .map(|t| Modules::parse(&t))
1341467×
            .unwrap_or_default()
1351467×
    }
136
13710×
    fn write_modules(&self, m: &Modules) -> Result<()> {
13810×
        if m.entries.is_empty() {
1391×
            let _ = fs::remove_file(self.root.join(".oxymodules"));
1401×
            return Ok(());
1419×
        }
1429×
        write_atomic(&self.root.join(".oxymodules"), m.to_text().as_bytes())
14310×
    }
144
145
    /// Bring the nested repository at `path` to `pin`, cloning it first when
146
    /// `init` and it is absent.
14714×
    pub(crate) fn sync_subrepo(&self, path: &str, pin: &Pin, init: bool) -> Result<SubrepoSync> {
14814×
        let dir = self.root.join(path);
14914×
        let module = self.modules_on_disk().entries.get(path).cloned();
15014×
        let present = dir.join(OXY_DIR).exists() || dir.join(".git").exists();
15114×
        let cloned = !present;
15214×
        if !present {
1536×
            if !init {
1544×
                return Ok(SubrepoSync::Missing);
1552×
            }
1562×
            let Some(m) = &module else {
1570×
                return Ok(SubrepoSync::Failed(
1580×
                    "no .oxymodules entry to clone from".into(),
1590×
                ));
160
            };
1612×
            if let Some(parent) = dir.parent() {
1622×
                fs::create_dir_all(parent)?;
1630×
            }
1642×
            let _ = fs::remove_dir(&dir);
1652×
            let r = match pin {
1661×
                Pin::Oxy { .. } => self.clone_as_subrepo(&m.url, &dir).map(|_| ()),
1671×
                Pin::Git { .. } => Command::new("git")
1681×
                    .args(["clone", "-q", &m.url])
1691×
                    .arg(&dir)
1701×
                    .status()
1711×
                    .map_err(|e| RepoError::Msg(e.to_string()))
1721×
                    .and_then(|s| {
1731×
                        if s.success() {
1741×
                            Ok(())
175
                        } else {
1760×
                            fail(format!("git clone {} failed", m.url))
177
                        }
1781×
                    }),
179
            };
1802×
            if let Err(e) = r {
1810×
                return Ok(SubrepoSync::Failed(format!("cloning {}: {e}", m.url)));
1822×
            }
1838×
        }
18410×
        let actual = read_pin(&dir, module.as_ref().and_then(|m| m.bookmark.as_deref()))?;
18510×
        let same = match (pin, &actual) {
1869×
            (Pin::Oxy { heads: a, .. }, Pin::Oxy { heads: b, .. }) => {
1879×
                let (mut a, mut b) = (a.clone(), b.clone());
1889×
                a.sort();
1899×
                b.sort();
1909×
                a == b
191
            }
1921×
            (Pin::Git { sha: a, .. }, Pin::Git { sha: b, .. }) => a == b,
1930×
            _ => false,
194
        };
19510×
        if same {
1967×
            return Ok(if cloned {
1972×
                SubrepoSync::Updated
198
            } else {
1995×
                SubrepoSync::UpToDate
200
            });
2013×
        }
2023×
        if nested_dirty(&dir)? {
2031×
            return Ok(SubrepoSync::Dirty);
2042×
        }
2052×
        match pin {
2062×
            Pin::Oxy { heads, .. } => {
2072×
                let nested = Repo::open(&dir)?;
2082×
                if nested.load_state(heads).is_err() {
209
                    // The pinned changes are not here yet: fetch from origin.
2100×
                    if let Err(e) = nested.fetch("origin") {
2110×
                        return Ok(SubrepoSync::Failed(format!(
2120×
                            "fetching the pinned changes: {e}"
2130×
                        )));
2140×
                    }
2152×
                }
2162×
                match nested.checkout(heads, false, "oxy submodule update") {
217
                    Ok(_) => {
218
                        // Its bookmark follows only forward: work it has
219
                        // beyond the pin stays on it (metalgear, oxygen#38).
2202×
                        let bookmark = nested.config.bookmark.clone();
2212×
                        let at = nested.bookmark_heads().unwrap_or_default();
2222×
                        let newer = nested.state_gap(&at, heads).map(|g| g.lacks).unwrap_or(0);
2232×
                        if newer == 0 {
2241×
                            let _ = nested.bookmark_set(&bookmark, heads);
2251×
                            Ok(SubrepoSync::Updated)
226
                        } else {
2271×
                            Ok(SubrepoSync::KeptBookmark { bookmark, newer })
228
                        }
229
                    }
2300×
                    Err(e) => Ok(SubrepoSync::Failed(e.to_string())),
231
                }
232
            }
2330×
            Pin::Git { sha, .. } => {
2340×
                if git_out(&dir, &["cat-file", "-e", &format!("{sha}^{{commit}}")]).is_err() {
2350×
                    if let Err(e) = git_out(&dir, &["fetch", "-q", "origin"]) {
2360×
                        return Ok(SubrepoSync::Failed(e.to_string()));
2370×
                    }
2380×
                }
2390×
                match git_out(&dir, &["checkout", "-q", "--detach", sha]) {
2400×
                    Ok(_) => Ok(SubrepoSync::Updated),
2410×
                    Err(e) => Ok(SubrepoSync::Failed(e.to_string())),
242
                }
243
            }
244
        }
24514×
    }
246
247
    /// Every subrepo of the working copy's base state.
24829×
    pub fn subrepos(&self) -> Result<Vec<SubrepoInfo>> {
24929×
        let (_, wc) = self.working_copy()?;
25029×
        let snap = self.load_state(&wc.deps)?.materialize();
25129×
        let modules = self.modules_on_disk();
25229×
        let mut out = Vec::new();
25349×
        for (path, f) in &snap.files {
25449×
            if f.kind != NodeKind::Subrepo {
25542×
                continue;
2567×
            }
2577×
            let FileContent::Opaque { blobs } = &f.content else {
2580×
                continue;
259
            };
2607×
            let Some(b) = blobs.first() else { continue };
2617×
            let pin = Pin::decode(&self.read_blob(b)?)?;
2627×
            let dir = self.root.join(path);
2637×
            let present = dir.join(OXY_DIR).exists() || dir.join(".git").exists();
2647×
            let actual = if present {
2653×
                read_pin(
2663×
                    &dir,
2673×
                    modules
2683×
                        .entries
2693×
                        .get(path)
2703×
                        .and_then(|m| m.bookmark.as_deref()),
271
                )
2723×
                .ok()
273
            } else {
2744×
                None
275
            };
2767×
            let dirty = present && nested_dirty(&dir).unwrap_or(false);
2777×
            out.push(SubrepoInfo {
2787×
                path: path.clone(),
2797×
                pin,
2807×
                url: modules.entries.get(path).map(|m| m.url.clone()),
2817×
                actual,
2827×
                dirty,
283
                committed: true,
284
            });
285
        }
286
        // Registered but not committed yet: pinned at what they are now.
28729×
        for (path, m) in &modules.entries {
28811×
            if snap.files.contains_key(path) {
2897×
                continue;
2904×
            }
2914×
            let dir = self.root.join(path);
2924×
            let Ok(pin) = read_pin(&dir, m.bookmark.as_deref()) else {
2932×
                continue;
294
            };
2952×
            out.push(SubrepoInfo {
2962×
                path: path.clone(),
2972×
                pin: pin.clone(),
2982×
                url: Some(m.url.clone()),
2992×
                actual: Some(pin),
3002×
                dirty: nested_dirty(&dir).unwrap_or(false),
3012×
                committed: false,
3022×
            });
303
        }
30429×
        out.sort_by(|a, b| a.path.cmp(&b.path));
30529×
        Ok(out)
30629×
    }
307
308
    /// `.oxymodules` entries with no subrepo behind them: never added, so
309
    /// there is no pin to clone or check out (oxygen#72).
3103×
    pub fn unpinned_modules(&self) -> Result<Vec<(String, Module)>> {
3113×
        let pinned: Vec<String> = self.subrepos()?.into_iter().map(|s| s.path).collect();
3123×
        Ok(self
3133×
            .modules_on_disk()
3143×
            .entries
3153×
            .into_iter()
3163×
            .filter(|(path, _)| !pinned.contains(path))
3173×
            .collect())
3183×
    }
319
320
    /// Clone `url` at `path`, register it in `.oxymodules`, and return the
321
    /// pin the next snapshot will record.
3229×
    pub fn submodule_add(&self, url: &str, path: &str, bookmark: Option<&str>) -> Result<Pin> {
3239×
        let path = path.trim_end_matches('/').to_string();
3249×
        if path.is_empty()
3259×
            || path.starts_with('/')
32617×
            || path.split('/').any(|c| c == ".." || c == ".")
327
        {
3280×
            return fail(format!("{path:?} is not a valid subrepo path"));
3299×
        }
3309×
        let dir = self.root.join(&path);
3319×
        if dir.exists() && fs::read_dir(&dir)?.next().is_some() {
3320×
            return fail(format!("{path} exists and is not empty"));
3339×
        }
3349×
        if let Some(parent) = dir.parent() {
3359×
            fs::create_dir_all(parent)?;
3360×
        }
3379×
        let probe = probe_oxy_url(url, &self.credentials_for(url));
3389×
        let git = probe.is_err();
3399×
        if git {
3400×
            let mut cmd = Command::new("git");
3410×
            cmd.args(["clone", "-q"]);
3420×
            if let Some(b) = bookmark {
3430×
                cmd.args(["--branch", b]);
3440×
            }
3450×
            let status = cmd
3460×
                .arg(url)
3470×
                .arg(&dir)
3480×
                .status()
3490×
                .map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
3500×
            if !status.success() {
3510×
                let oxy_error = probe.err().unwrap_or_default();
3520×
                return fail(format!(
353
                    "git clone {url} failed; as an Oxygen repository: {oxy_error}"
354
                ));
3550×
            }
356
        } else {
3579×
            let _ = fs::remove_dir(&dir);
3589×
            let mut nested = self.clone_as_subrepo(url, &dir)?;
3599×
            if let Some(b) = bookmark {
3600×
                nested.bookmark_switch(b, true)?;
3619×
            }
362
        }
3639×
        let mut modules = self.modules_on_disk();
3649×
        modules.entries.insert(
3659×
            path.clone(),
3669×
            Module {
3679×
                url: url.to_string(),
3689×
                bookmark: bookmark.map(str::to_string),
3699×
                git,
3709×
            },
371
        );
3729×
        self.write_modules(&modules)?;
3739×
        read_pin(&dir, bookmark)
3749×
    }
375
376
    /// Bring nested repositories to their pins (`paths` empty = all).
3775×
    pub fn submodule_update(
3785×
        &self,
3795×
        init: bool,
3805×
        paths: &[String],
3815×
    ) -> Result<Vec<(String, SubrepoSync)>> {
3825×
        let mut out = Vec::new();
3835×
        for s in self.subrepos()? {
3844×
            if !paths.is_empty() && !paths.iter().any(|p| p.trim_end_matches('/') == s.path) {
3850×
                continue;
3864×
            }
3874×
            let r = self.sync_subrepo(&s.path, &s.pin, init)?;
3884×
            out.push((s.path, r));
389
        }
3905×
        Ok(out)
3915×
    }
392
393
    /// Drop the `.oxymodules` entry; the node goes with the next snapshot
394
    /// only if the directory is removed, since a registered or tracked
395
    /// nested repository keeps contributing its pin. Here the directory is
396
    /// left in place and the node is removed explicitly from the working
397
    /// copy's next change by unregistering and moving the directory aside.
3981×
    pub fn submodule_remove(&self, path: &str) -> Result<()> {
3991×
        let path = path.trim_end_matches('/').to_string();
4001×
        let mut modules = self.modules_on_disk();
4011×
        let known = modules.entries.remove(&path).is_some()
4020×
            || self.subrepos()?.iter().any(|s| s.path == path);
4031×
        if !known {
4040×
            return fail(format!("{path} is not a subrepo"));
4051×
        }
4061×
        self.write_modules(&modules)?;
407
        // The directory stays; the walker ignores an unregistered nested
408
        // repository unless tracked, so mark it as removed for the next
409
        // snapshot by recording an explicit removal.
4101×
        let dir = self.oxy.join("subrepos-removed");
4111×
        fs::create_dir_all(&dir)?;
4121×
        write_atomic(&dir.join(path.replace('/', "%2F")), b"")?;
4131×
        Ok(())
4141×
    }
415
416
    /// Paths whose subrepo node was removed with `oxy submodule remove` and
417
    /// whose directory is still on disk.
4181390×
    pub(crate) fn removed_subrepos(&self) -> BTreeSet<String> {
4191390×
        let mut out = BTreeSet::new();
4201390×
        if let Ok(rd) = fs::read_dir(self.oxy.join("subrepos-removed")) {
4213×
            for e in rd.flatten() {
4223×
                out.insert(e.file_name().to_string_lossy().replace("%2F", "/"));
4233×
            }
4241387×
        }
4251390×
        out
4261390×
    }
427
4281×
    pub(crate) fn clear_removed_subrepo(&self, path: &str) {
4291×
        let _ = fs::remove_file(
4301×
            self.oxy
4311×
                .join("subrepos-removed")
4321×
                .join(path.replace('/', "%2F")),
4331×
        );
4341×
    }
435
}