OXYGENHUB
# c792033af344 oxygen / crates / oxy-core / src / repo / subrepos.rs
11.3 KiB · Rust · last changed Subrepositories: subrepo node kind with Oxygen and Git pins, .oxymodules, nested repositories skipped by the walker and moved by checkout, oxy submodule add|update|status|remove, Git gitlink import and export (spec 0.8 section 7.8) 1 month ago source blame history raw
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
    Failed(String),
17
}
18
19
/// One subrepo as `oxy submodule status` shows it.
20
#[derive(Clone, Debug)]
21
pub struct SubrepoInfo {
22
    pub path: String,
23
    pub pin: Pin,
24
    pub url: Option<String>,
25
    /// The nested repository's own pin now, if present.
26
    pub actual: Option<Pin>,
27
    pub dirty: bool,
28
}
29
30
fn git_out(dir: &Path, args: &[&str]) -> Result<String> {
31
    let out = Command::new("git").arg("-C").arg(dir).args(args).output().map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
32
    if !out.status.success() {
33
        return fail(format!("git {} in {}: {}", args.join(" "), dir.display(), String::from_utf8_lossy(&out.stderr).trim()));
34
    }
35
    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
36
}
37
38
/// The pin a nested repository at `dir` is at: its current bookmark's
39
/// heads for an Oxygen repository, `HEAD` for a Git one.
40
pub(crate) fn read_pin(dir: &Path, bookmark_hint: Option<&str>) -> Result<Pin> {
41
    if dir.join(OXY_DIR).exists() {
42
        let nested = Repo::open(dir)?;
43
        return Ok(Pin::Oxy { repo: nested.id()?, heads: nested.bookmark_heads()?, bookmark: nested.config.bookmark.clone() });
44
    }
45
    if dir.join(".git").exists() {
46
        let sha = git_out(dir, &["rev-parse", "HEAD"])?;
47
        return Ok(Pin::Git { sha, branch: bookmark_hint.unwrap_or("HEAD").to_string() });
48
    }
49
    fail(format!("{} is not a repository", dir.display()))
50
}
51
52
/// Whether a nested repository has uncommitted work.
53
fn nested_dirty(dir: &Path) -> Result<bool> {
54
    if dir.join(OXY_DIR).exists() {
55
        let nested = Repo::open(dir)?;
56
        let snap = nested.snapshot("oxy submodule")?;
57
        return Ok(!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty());
58
    }
59
    Ok(!git_out(dir, &["status", "--porcelain"])?.is_empty())
60
}
61
62
/// Whether `url` names an Oxygen repository (else it is taken to be Git).
63
fn is_oxy_url(url: &str) -> bool {
64
    if url.starts_with("http://") || url.starts_with("https://") {
65
        return crate::protocol::open_remote(url, &RemoteOptions::default()).and_then(|r| r.repo_id()).is_ok();
66
    }
67
    let p = Path::new(url.strip_prefix("file://").unwrap_or(url));
68
    p.join(OXY_DIR).exists()
69
}
70
71
impl Repo {
72
    pub(crate) fn modules_on_disk(&self) -> Modules {
73
        fs::read_to_string(self.root.join(".oxymodules")).map(|t| Modules::parse(&t)).unwrap_or_default()
74
    }
75
76
    fn write_modules(&self, m: &Modules) -> Result<()> {
77
        if m.entries.is_empty() {
78
            let _ = fs::remove_file(self.root.join(".oxymodules"));
79
            return Ok(());
80
        }
81
        write_atomic(&self.root.join(".oxymodules"), m.to_text().as_bytes())
82
    }
83
84
    /// Bring the nested repository at `path` to `pin`, cloning it first when
85
    /// `init` and it is absent.
86
    pub(crate) fn sync_subrepo(&self, path: &str, pin: &Pin, init: bool) -> Result<SubrepoSync> {
87
        let dir = self.root.join(path);
88
        let module = self.modules_on_disk().entries.get(path).cloned();
89
        let present = dir.join(OXY_DIR).exists() || dir.join(".git").exists();
90
        let cloned = !present;
91
        if !present {
92
            if !init {
93
                return Ok(SubrepoSync::Missing);
94
            }
95
            let Some(m) = &module else { return Ok(SubrepoSync::Failed("no .oxymodules entry to clone from".into())) };
96
            if let Some(parent) = dir.parent() {
97
                fs::create_dir_all(parent)?;
98
            }
99
            let _ = fs::remove_dir(&dir);
100
            let r = match pin {
101
                Pin::Oxy { .. } => Repo::clone(&m.url, &dir).map(|_| ()),
102
                Pin::Git { .. } => Command::new("git")
103
                    .args(["clone", "-q", &m.url])
104
                    .arg(&dir)
105
                    .status()
106
                    .map_err(|e| RepoError::Msg(e.to_string()))
107
                    .and_then(|s| if s.success() { Ok(()) } else { fail(format!("git clone {} failed", m.url)) }),
108
            };
109
            if let Err(e) = r {
110
                return Ok(SubrepoSync::Failed(format!("cloning {}: {e}", m.url)));
111
            }
112
        }
113
        let actual = read_pin(&dir, module.as_ref().and_then(|m| m.bookmark.as_deref()))?;
114
        let same = match (pin, &actual) {
115
            (Pin::Oxy { heads: a, .. }, Pin::Oxy { heads: b, .. }) => {
116
                let (mut a, mut b) = (a.clone(), b.clone());
117
                a.sort();
118
                b.sort();
119
                a == b
120
            }
121
            (Pin::Git { sha: a, .. }, Pin::Git { sha: b, .. }) => a == b,
122
            _ => false,
123
        };
124
        if same {
125
            return Ok(if cloned { SubrepoSync::Updated } else { SubrepoSync::UpToDate });
126
        }
127
        if nested_dirty(&dir)? {
128
            return Ok(SubrepoSync::Dirty);
129
        }
130
        match pin {
131
            Pin::Oxy { heads, .. } => {
132
                let nested = Repo::open(&dir)?;
133
                if nested.load_state(heads).is_err() {
134
                    // The pinned changes are not here yet: fetch from origin.
135
                    if let Err(e) = nested.fetch("origin") {
136
                        return Ok(SubrepoSync::Failed(format!("fetching the pinned changes: {e}")));
137
                    }
138
                }
139
                match nested.checkout(heads, false, "oxy submodule update") {
140
                    Ok(_) => {
141
                        let _ = nested.bookmark_set(&nested.config.bookmark, heads);
142
                        Ok(SubrepoSync::Updated)
143
                    }
144
                    Err(e) => Ok(SubrepoSync::Failed(e.to_string())),
145
                }
146
            }
147
            Pin::Git { sha, .. } => {
148
                if git_out(&dir, &["cat-file", "-e", &format!("{sha}^{{commit}}")]).is_err() {
149
                    if let Err(e) = git_out(&dir, &["fetch", "-q", "origin"]) {
150
                        return Ok(SubrepoSync::Failed(e.to_string()));
151
                    }
152
                }
153
                match git_out(&dir, &["checkout", "-q", "--detach", sha]) {
154
                    Ok(_) => Ok(SubrepoSync::Updated),
155
                    Err(e) => Ok(SubrepoSync::Failed(e.to_string())),
156
                }
157
            }
158
        }
159
    }
160
161
    /// Every subrepo of the working copy's base state.
162
    pub fn subrepos(&self) -> Result<Vec<SubrepoInfo>> {
163
        let (_, wc) = self.working_copy()?;
164
        let snap = self.load_state(&wc.deps)?.materialize();
165
        let modules = self.modules_on_disk();
166
        let mut out = Vec::new();
167
        for (path, f) in &snap.files {
168
            if f.kind != NodeKind::Subrepo {
169
                continue;
170
            }
171
            let FileContent::Opaque { blobs } = &f.content else { continue };
172
            let Some(b) = blobs.first() else { continue };
173
            let pin = Pin::decode(&self.read_blob(b)?)?;
174
            let dir = self.root.join(path);
175
            let present = dir.join(OXY_DIR).exists() || dir.join(".git").exists();
176
            let actual = if present { read_pin(&dir, modules.entries.get(path).and_then(|m| m.bookmark.as_deref())).ok() } else { None };
177
            let dirty = present && nested_dirty(&dir).unwrap_or(false);
178
            out.push(SubrepoInfo { path: path.clone(), pin, url: modules.entries.get(path).map(|m| m.url.clone()), actual, dirty });
179
        }
180
        Ok(out)
181
    }
182
183
    /// Clone `url` at `path`, register it in `.oxymodules`, and return the
184
    /// pin the next snapshot will record.
185
    pub fn submodule_add(&self, url: &str, path: &str, bookmark: Option<&str>) -> Result<Pin> {
186
        let path = path.trim_end_matches('/').to_string();
187
        if path.is_empty() || path.starts_with('/') || path.split('/').any(|c| c == ".." || c == ".") {
188
            return fail(format!("{path:?} is not a valid subrepo path"));
189
        }
190
        let dir = self.root.join(&path);
191
        if dir.exists() && fs::read_dir(&dir)?.next().is_some() {
192
            return fail(format!("{path} exists and is not empty"));
193
        }
194
        if let Some(parent) = dir.parent() {
195
            fs::create_dir_all(parent)?;
196
        }
197
        let git = !is_oxy_url(url);
198
        if git {
199
            let mut cmd = Command::new("git");
200
            cmd.args(["clone", "-q"]);
201
            if let Some(b) = bookmark {
202
                cmd.args(["--branch", b]);
203
            }
204
            let status = cmd.arg(url).arg(&dir).status().map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
205
            if !status.success() {
206
                return fail(format!("git clone {url} failed"));
207
            }
208
        } else {
209
            let _ = fs::remove_dir(&dir);
210
            let mut nested = Repo::clone(url, &dir)?;
211
            if let Some(b) = bookmark {
212
                nested.bookmark_switch(b, true)?;
213
            }
214
        }
215
        let mut modules = self.modules_on_disk();
216
        modules.entries.insert(path.clone(), Module { url: url.to_string(), bookmark: bookmark.map(str::to_string), git });
217
        self.write_modules(&modules)?;
218
        read_pin(&dir, bookmark)
219
    }
220
221
    /// Bring nested repositories to their pins (`paths` empty = all).
222
    pub fn submodule_update(&self, init: bool, paths: &[String]) -> Result<Vec<(String, SubrepoSync)>> {
223
        let mut out = Vec::new();
224
        for s in self.subrepos()? {
225
            if !paths.is_empty() && !paths.iter().any(|p| p.trim_end_matches('/') == s.path) {
226
                continue;
227
            }
228
            let r = self.sync_subrepo(&s.path, &s.pin, init)?;
229
            out.push((s.path, r));
230
        }
231
        Ok(out)
232
    }
233
234
    /// Drop the `.oxymodules` entry; the node goes with the next snapshot
235
    /// only if the directory is removed, since a registered or tracked
236
    /// nested repository keeps contributing its pin. Here the directory is
237
    /// left in place and the node is removed explicitly from the working
238
    /// copy's next change by unregistering and moving the directory aside.
239
    pub fn submodule_remove(&self, path: &str) -> Result<()> {
240
        let path = path.trim_end_matches('/').to_string();
241
        let mut modules = self.modules_on_disk();
242
        let known = modules.entries.remove(&path).is_some() || self.subrepos()?.iter().any(|s| s.path == path);
243
        if !known {
244
            return fail(format!("{path} is not a subrepo"));
245
        }
246
        self.write_modules(&modules)?;
247
        // The directory stays; the walker ignores an unregistered nested
248
        // repository unless tracked, so mark it as removed for the next
249
        // snapshot by recording an explicit removal.
250
        let dir = self.oxy.join("subrepos-removed");
251
        fs::create_dir_all(&dir)?;
252
        write_atomic(&dir.join(path.replace('/', "%2F")), b"")?;
253
        Ok(())
254
    }
255
256
    /// Paths whose subrepo node was removed with `oxy submodule remove` and
257
    /// whose directory is still on disk.
258
    pub(crate) fn removed_subrepos(&self) -> BTreeSet<String> {
259
        let mut out = BTreeSet::new();
260
        if let Ok(rd) = fs::read_dir(self.oxy.join("subrepos-removed")) {
261
            for e in rd.flatten() {
262
                out.insert(e.file_name().to_string_lossy().replace("%2F", "/"));
263
            }
264
        }
265
        out
266
    }
267
268
    pub(crate) fn clear_removed_subrepo(&self, path: &str) {
269
        let _ = fs::remove_file(self.oxy.join("subrepos-removed").join(path.replace('/', "%2F")));
270
    }
271
}