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