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