OXYGENHUB
# 0955469ac0e1 oxygen / crates / oxy-core / src / repo / subrepos.rs
13.2 KiB · Rust · last changed submodule add probes an URL with the environment's credentials (OXY_TOKEN, OXY_CA) as a clone does, and reports the Oxygen error when Git cannot clone it either 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")
32
        .arg("-C")
33
        .arg(dir)
34
        .args(args)
35
        .output()
36
        .map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
37
    if !out.status.success() {
38
        return fail(format!(
39
            "git {} in {}: {}",
40
            args.join(" "),
41
            dir.display(),
42
            String::from_utf8_lossy(&out.stderr).trim()
43
        ));
44
    }
45
    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
46
}
47
48
/// The pin a nested repository at `dir` is at: its current bookmark's
49
/// heads for an Oxygen repository, `HEAD` for a Git one.
50
pub(crate) fn read_pin(dir: &Path, bookmark_hint: Option<&str>) -> Result<Pin> {
51
    if dir.join(OXY_DIR).exists() {
52
        let nested = Repo::open(dir)?;
53
        return Ok(Pin::Oxy {
54
            repo: nested.id()?,
55
            heads: nested.bookmark_heads()?,
56
            bookmark: nested.config.bookmark.clone(),
57
        });
58
    }
59
    if dir.join(".git").exists() {
60
        let sha = git_out(dir, &["rev-parse", "HEAD"])?;
61
        return Ok(Pin::Git {
62
            sha,
63
            branch: bookmark_hint.unwrap_or("HEAD").to_string(),
64
        });
65
    }
66
    fail(format!("{} is not a repository", dir.display()))
67
}
68
69
/// Whether a nested repository has uncommitted work.
70
fn nested_dirty(dir: &Path) -> Result<bool> {
71
    if dir.join(OXY_DIR).exists() {
72
        let nested = Repo::open(dir)?;
73
        let snap = nested.snapshot("oxy submodule")?;
74
        return Ok(!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty());
75
    }
76
    Ok(!git_out(dir, &["status", "--porcelain"])?.is_empty())
77
}
78
79
/// Whether `url` names an Oxygen repository (else it is taken to be Git).
80
/// Whether `url` is an Oxygen repository, probed with the environment's
81
/// credentials (`OXY_TOKEN`, `OXY_CA`) as a clone would use them; with the
82
/// probe's error when it is not, to report if Git cannot clone it either.
83
fn probe_oxy_url(url: &str) -> std::result::Result<(), String> {
84
    if url.starts_with("http://") || url.starts_with("https://") {
85
        let options = RemoteOptions {
86
            token: std::env::var("OXY_TOKEN").ok(),
87
            ca: std::env::var("OXY_CA").ok().map(std::path::PathBuf::from),
88
            repo_id: None,
89
        };
90
        return crate::protocol::open_remote(url, &options)
91
            .and_then(|r| r.repo_id())
92
            .map(|_| ())
93
            .map_err(|e| e.to_string());
94
    }
95
    let p = Path::new(url.strip_prefix("file://").unwrap_or(url));
96
    if p.join(OXY_DIR).exists() {
97
        Ok(())
98
    } else {
99
        Err(format!("{} has no {OXY_DIR} directory", p.display()))
100
    }
101
}
102
103
impl Repo {
104
    pub(crate) fn modules_on_disk(&self) -> Modules {
105
        fs::read_to_string(self.root.join(".oxymodules"))
106
            .map(|t| Modules::parse(&t))
107
            .unwrap_or_default()
108
    }
109
110
    fn write_modules(&self, m: &Modules) -> Result<()> {
111
        if m.entries.is_empty() {
112
            let _ = fs::remove_file(self.root.join(".oxymodules"));
113
            return Ok(());
114
        }
115
        write_atomic(&self.root.join(".oxymodules"), m.to_text().as_bytes())
116
    }
117
118
    /// Bring the nested repository at `path` to `pin`, cloning it first when
119
    /// `init` and it is absent.
120
    pub(crate) fn sync_subrepo(&self, path: &str, pin: &Pin, init: bool) -> Result<SubrepoSync> {
121
        let dir = self.root.join(path);
122
        let module = self.modules_on_disk().entries.get(path).cloned();
123
        let present = dir.join(OXY_DIR).exists() || dir.join(".git").exists();
124
        let cloned = !present;
125
        if !present {
126
            if !init {
127
                return Ok(SubrepoSync::Missing);
128
            }
129
            let Some(m) = &module else {
130
                return Ok(SubrepoSync::Failed(
131
                    "no .oxymodules entry to clone from".into(),
132
                ));
133
            };
134
            if let Some(parent) = dir.parent() {
135
                fs::create_dir_all(parent)?;
136
            }
137
            let _ = fs::remove_dir(&dir);
138
            let r = match pin {
139
                Pin::Oxy { .. } => Repo::clone(&m.url, &dir).map(|_| ()),
140
                Pin::Git { .. } => Command::new("git")
141
                    .args(["clone", "-q", &m.url])
142
                    .arg(&dir)
143
                    .status()
144
                    .map_err(|e| RepoError::Msg(e.to_string()))
145
                    .and_then(|s| {
146
                        if s.success() {
147
                            Ok(())
148
                        } else {
149
                            fail(format!("git clone {} failed", m.url))
150
                        }
151
                    }),
152
            };
153
            if let Err(e) = r {
154
                return Ok(SubrepoSync::Failed(format!("cloning {}: {e}", m.url)));
155
            }
156
        }
157
        let actual = read_pin(&dir, module.as_ref().and_then(|m| m.bookmark.as_deref()))?;
158
        let same = match (pin, &actual) {
159
            (Pin::Oxy { heads: a, .. }, Pin::Oxy { heads: b, .. }) => {
160
                let (mut a, mut b) = (a.clone(), b.clone());
161
                a.sort();
162
                b.sort();
163
                a == b
164
            }
165
            (Pin::Git { sha: a, .. }, Pin::Git { sha: b, .. }) => a == b,
166
            _ => false,
167
        };
168
        if same {
169
            return Ok(if cloned {
170
                SubrepoSync::Updated
171
            } else {
172
                SubrepoSync::UpToDate
173
            });
174
        }
175
        if nested_dirty(&dir)? {
176
            return Ok(SubrepoSync::Dirty);
177
        }
178
        match pin {
179
            Pin::Oxy { heads, .. } => {
180
                let nested = Repo::open(&dir)?;
181
                if nested.load_state(heads).is_err() {
182
                    // The pinned changes are not here yet: fetch from origin.
183
                    if let Err(e) = nested.fetch("origin") {
184
                        return Ok(SubrepoSync::Failed(format!(
185
                            "fetching the pinned changes: {e}"
186
                        )));
187
                    }
188
                }
189
                match nested.checkout(heads, false, "oxy submodule update") {
190
                    Ok(_) => {
191
                        let _ = nested.bookmark_set(&nested.config.bookmark, heads);
192
                        Ok(SubrepoSync::Updated)
193
                    }
194
                    Err(e) => Ok(SubrepoSync::Failed(e.to_string())),
195
                }
196
            }
197
            Pin::Git { sha, .. } => {
198
                if git_out(&dir, &["cat-file", "-e", &format!("{sha}^{{commit}}")]).is_err() {
199
                    if let Err(e) = git_out(&dir, &["fetch", "-q", "origin"]) {
200
                        return Ok(SubrepoSync::Failed(e.to_string()));
201
                    }
202
                }
203
                match git_out(&dir, &["checkout", "-q", "--detach", sha]) {
204
                    Ok(_) => Ok(SubrepoSync::Updated),
205
                    Err(e) => Ok(SubrepoSync::Failed(e.to_string())),
206
                }
207
            }
208
        }
209
    }
210
211
    /// Every subrepo of the working copy's base state.
212
    pub fn subrepos(&self) -> Result<Vec<SubrepoInfo>> {
213
        let (_, wc) = self.working_copy()?;
214
        let snap = self.load_state(&wc.deps)?.materialize();
215
        let modules = self.modules_on_disk();
216
        let mut out = Vec::new();
217
        for (path, f) in &snap.files {
218
            if f.kind != NodeKind::Subrepo {
219
                continue;
220
            }
221
            let FileContent::Opaque { blobs } = &f.content else {
222
                continue;
223
            };
224
            let Some(b) = blobs.first() else { continue };
225
            let pin = Pin::decode(&self.read_blob(b)?)?;
226
            let dir = self.root.join(path);
227
            let present = dir.join(OXY_DIR).exists() || dir.join(".git").exists();
228
            let actual = if present {
229
                read_pin(
230
                    &dir,
231
                    modules
232
                        .entries
233
                        .get(path)
234
                        .and_then(|m| m.bookmark.as_deref()),
235
                )
236
                .ok()
237
            } else {
238
                None
239
            };
240
            let dirty = present && nested_dirty(&dir).unwrap_or(false);
241
            out.push(SubrepoInfo {
242
                path: path.clone(),
243
                pin,
244
                url: modules.entries.get(path).map(|m| m.url.clone()),
245
                actual,
246
                dirty,
247
            });
248
        }
249
        Ok(out)
250
    }
251
252
    /// Clone `url` at `path`, register it in `.oxymodules`, and return the
253
    /// pin the next snapshot will record.
254
    pub fn submodule_add(&self, url: &str, path: &str, bookmark: Option<&str>) -> Result<Pin> {
255
        let path = path.trim_end_matches('/').to_string();
256
        if path.is_empty()
257
            || path.starts_with('/')
258
            || path.split('/').any(|c| c == ".." || c == ".")
259
        {
260
            return fail(format!("{path:?} is not a valid subrepo path"));
261
        }
262
        let dir = self.root.join(&path);
263
        if dir.exists() && fs::read_dir(&dir)?.next().is_some() {
264
            return fail(format!("{path} exists and is not empty"));
265
        }
266
        if let Some(parent) = dir.parent() {
267
            fs::create_dir_all(parent)?;
268
        }
269
        let probe = probe_oxy_url(url);
270
        let git = probe.is_err();
271
        if git {
272
            let mut cmd = Command::new("git");
273
            cmd.args(["clone", "-q"]);
274
            if let Some(b) = bookmark {
275
                cmd.args(["--branch", b]);
276
            }
277
            let status = cmd
278
                .arg(url)
279
                .arg(&dir)
280
                .status()
281
                .map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
282
            if !status.success() {
283
                let oxy_error = probe.err().unwrap_or_default();
284
                return fail(format!(
285
                    "git clone {url} failed; as an Oxygen repository: {oxy_error}"
286
                ));
287
            }
288
        } else {
289
            let _ = fs::remove_dir(&dir);
290
            let mut nested = Repo::clone(url, &dir)?;
291
            if let Some(b) = bookmark {
292
                nested.bookmark_switch(b, true)?;
293
            }
294
        }
295
        let mut modules = self.modules_on_disk();
296
        modules.entries.insert(
297
            path.clone(),
298
            Module {
299
                url: url.to_string(),
300
                bookmark: bookmark.map(str::to_string),
301
                git,
302
            },
303
        );
304
        self.write_modules(&modules)?;
305
        read_pin(&dir, bookmark)
306
    }
307
308
    /// Bring nested repositories to their pins (`paths` empty = all).
309
    pub fn submodule_update(
310
        &self,
311
        init: bool,
312
        paths: &[String],
313
    ) -> Result<Vec<(String, SubrepoSync)>> {
314
        let mut out = Vec::new();
315
        for s in self.subrepos()? {
316
            if !paths.is_empty() && !paths.iter().any(|p| p.trim_end_matches('/') == s.path) {
317
                continue;
318
            }
319
            let r = self.sync_subrepo(&s.path, &s.pin, init)?;
320
            out.push((s.path, r));
321
        }
322
        Ok(out)
323
    }
324
325
    /// Drop the `.oxymodules` entry; the node goes with the next snapshot
326
    /// only if the directory is removed, since a registered or tracked
327
    /// nested repository keeps contributing its pin. Here the directory is
328
    /// left in place and the node is removed explicitly from the working
329
    /// copy's next change by unregistering and moving the directory aside.
330
    pub fn submodule_remove(&self, path: &str) -> Result<()> {
331
        let path = path.trim_end_matches('/').to_string();
332
        let mut modules = self.modules_on_disk();
333
        let known = modules.entries.remove(&path).is_some()
334
            || self.subrepos()?.iter().any(|s| s.path == path);
335
        if !known {
336
            return fail(format!("{path} is not a subrepo"));
337
        }
338
        self.write_modules(&modules)?;
339
        // The directory stays; the walker ignores an unregistered nested
340
        // repository unless tracked, so mark it as removed for the next
341
        // snapshot by recording an explicit removal.
342
        let dir = self.oxy.join("subrepos-removed");
343
        fs::create_dir_all(&dir)?;
344
        write_atomic(&dir.join(path.replace('/', "%2F")), b"")?;
345
        Ok(())
346
    }
347
348
    /// Paths whose subrepo node was removed with `oxy submodule remove` and
349
    /// whose directory is still on disk.
350
    pub(crate) fn removed_subrepos(&self) -> BTreeSet<String> {
351
        let mut out = BTreeSet::new();
352
        if let Ok(rd) = fs::read_dir(self.oxy.join("subrepos-removed")) {
353
            for e in rd.flatten() {
354
                out.insert(e.file_name().to_string_lossy().replace("%2F", "/"));
355
            }
356
        }
357
        out
358
    }
359
360
    pub(crate) fn clear_removed_subrepo(&self, path: &str) {
361
        let _ = fs::remove_file(
362
            self.oxy
363
                .join("subrepos-removed")
364
                .join(path.replace('/', "%2F")),
365
        );
366
    }
367
}