OXYGENHUB
# 1966f5db15ee oxygen / crates / oxy-core / src / subrepo.rs
10.0 KiB · Rust · last changed Unbound hosted repositories adopt the first pusher's id 1 month ago source blame history raw
1
//! Subrepositories (spec §7.8): the pin stored as a `subrepo` node's
2
//! content, and the `.oxymodules` file mapping paths to remotes.
3
//!
4
//! Pin encoding: `{0: 1, 1: kind, 2: locator, 3: pin, 4: bookmark}`; kind 0
5
//! is an Oxygen repository (locator = repository id, pin = sorted heads),
6
//! kind 1 a Git repository (locator = null, pin = commit sha as text).
7
8
use crate::cbor::{self, Value};
9
use crate::hash::ChangeHash;
10
use crate::objects::{
11
    array, bytes, check_version, field, oref, text, uint, ObjectError, FORMAT_VERSION,
12
};
13
use std::collections::BTreeMap;
14
15
#[derive(Clone, Debug, PartialEq, Eq)]
16
pub enum Pin {
17
    Oxy {
18
        repo: [u8; 16],
19
        heads: Vec<ChangeHash>,
20
        bookmark: String,
21
    },
22
    Git {
23
        sha: String,
24
        branch: String,
25
    },
26
}
27
28
impl Pin {
29
    pub fn encode(&self) -> Vec<u8> {
30
        let v = match self {
31
            Pin::Oxy {
32
                repo,
33
                heads,
34
                bookmark,
35
            } => {
36
                let mut heads = heads.clone();
37
                heads.sort();
38
                heads.dedup();
39
                Value::map([
40
                    (0, Value::Uint(FORMAT_VERSION)),
41
                    (1, Value::Uint(0)),
42
                    (2, Value::Bytes(repo.to_vec())),
43
                    (
44
                        3,
45
                        Value::Array(heads.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()),
46
                    ),
47
                    (4, Value::Text(bookmark.clone())),
48
                ])
49
            }
50
            Pin::Git { sha, branch } => Value::map([
51
                (0, Value::Uint(FORMAT_VERSION)),
52
                (1, Value::Uint(1)),
53
                (2, Value::Null),
54
                (3, Value::Text(sha.clone())),
55
                (4, Value::Text(branch.clone())),
56
            ]),
57
        };
58
        cbor::encode(&v)
59
    }
60
61
    pub fn decode(b: &[u8]) -> Result<Pin, ObjectError> {
62
        let v = cbor::decode(b)?;
63
        let m = v
64
            .as_map()
65
            .ok_or_else(|| ObjectError::Schema("pin: expected map".into()))?;
66
        check_version(m)?;
67
        let bookmark = text(field(m, 4, "bookmark")?, "bookmark")?.to_owned();
68
        match uint(field(m, 1, "kind")?, "kind")? {
69
            0 => {
70
                let repo: [u8; 16] = bytes(field(m, 2, "repo")?, "repo")?
71
                    .try_into()
72
                    .map_err(|_| ObjectError::Schema("pin: repo id must be 16 bytes".into()))?;
73
                let heads = array(field(m, 3, "heads")?, "heads")?
74
                    .iter()
75
                    .map(|h| oref(h, "head"))
76
                    .collect::<Result<Vec<_>, _>>()?;
77
                Ok(Pin::Oxy {
78
                    repo,
79
                    heads,
80
                    bookmark,
81
                })
82
            }
83
            1 => Ok(Pin::Git {
84
                sha: text(field(m, 3, "sha")?, "sha")?.to_owned(),
85
                branch: bookmark,
86
            }),
87
            k => Err(ObjectError::Schema(format!("pin: unknown kind {k}"))),
88
        }
89
    }
90
91
    /// A one-line description for listings.
92
    pub fn summary(&self) -> String {
93
        match self {
94
            Pin::Oxy {
95
                heads, bookmark, ..
96
            } => format!(
97
                "oxy {bookmark} {}",
98
                heads
99
                    .iter()
100
                    .map(|h| h.short(12))
101
                    .collect::<Vec<_>>()
102
                    .join(" ")
103
            ),
104
            Pin::Git { sha, branch } => format!("git {branch} {}", &sha[..sha.len().min(12)]),
105
        }
106
    }
107
108
    pub fn is_git(&self) -> bool {
109
        matches!(self, Pin::Git { .. })
110
    }
111
}
112
113
/// One `.oxymodules` entry.
114
#[derive(Clone, Debug, Default, PartialEq, Eq)]
115
pub struct Module {
116
    pub url: String,
117
    /// Oxygen bookmark or Git branch to track.
118
    pub bookmark: Option<String>,
119
    pub git: bool,
120
}
121
122
/// `.oxymodules`: `[path]` sections with `url`, and `bookmark` (Oxygen) or
123
/// `branch` (Git) keys.
124
#[derive(Clone, Debug, Default, PartialEq, Eq)]
125
pub struct Modules {
126
    pub entries: BTreeMap<String, Module>,
127
}
128
129
impl Modules {
130
    pub fn parse(text: &str) -> Modules {
131
        let mut out = Modules::default();
132
        let mut current: Option<String> = None;
133
        for line in text.lines() {
134
            let line = line.trim();
135
            if line.is_empty() || line.starts_with('#') {
136
                continue;
137
            }
138
            if let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
139
                let path = inner
140
                    .trim()
141
                    .trim_matches('"')
142
                    .trim_end_matches('/')
143
                    .to_string();
144
                out.entries.entry(path.clone()).or_default();
145
                current = Some(path);
146
                continue;
147
            }
148
            let (Some(path), Some((k, v))) = (&current, line.split_once('=')) else {
149
                continue;
150
            };
151
            let v = v.trim().trim_matches('"').to_string();
152
            let m = out.entries.get_mut(path).unwrap();
153
            match k.trim() {
154
                "url" => m.url = v,
155
                "bookmark" => {
156
                    m.bookmark = Some(v);
157
                    m.git = false;
158
                }
159
                "branch" => {
160
                    m.bookmark = Some(v);
161
                    m.git = true;
162
                }
163
                "kind" => m.git = v == "git",
164
                _ => {}
165
            }
166
        }
167
        out
168
    }
169
170
    pub fn to_text(&self) -> String {
171
        let mut s = String::new();
172
        for (path, m) in &self.entries {
173
            s.push_str(&format!("[{path}]\nurl = {}\n", m.url));
174
            if m.git {
175
                s.push_str("kind = git\n");
176
            }
177
            if let Some(b) = &m.bookmark {
178
                s.push_str(&format!(
179
                    "{} = {b}\n",
180
                    if m.git { "branch" } else { "bookmark" }
181
                ));
182
            }
183
            s.push('\n');
184
        }
185
        s
186
    }
187
188
    /// From Git's `.gitmodules` (`[submodule "name"]` with `path`, `url`, `branch`).
189
    pub fn from_gitmodules(text: &str) -> Modules {
190
        let mut out = Modules::default();
191
        let mut path: Option<String> = None;
192
        let mut url = String::new();
193
        let mut branch: Option<String> = None;
194
        let flush =
195
            |out: &mut Modules, path: &Option<String>, url: &str, branch: &Option<String>| {
196
                if let Some(p) = path {
197
                    out.entries.insert(
198
                        p.clone(),
199
                        Module {
200
                            url: url.to_string(),
201
                            bookmark: branch.clone(),
202
                            git: true,
203
                        },
204
                    );
205
                }
206
            };
207
        for line in text.lines() {
208
            let line = line.trim();
209
            if line.starts_with("[submodule") {
210
                flush(&mut out, &path, &url, &branch);
211
                path = None;
212
                url.clear();
213
                branch = None;
214
                continue;
215
            }
216
            if let Some((k, v)) = line.split_once('=') {
217
                let v = v.trim().to_string();
218
                match k.trim() {
219
                    "path" => path = Some(v.trim_end_matches('/').to_string()),
220
                    "url" => url = v,
221
                    "branch" => branch = Some(v),
222
                    _ => {}
223
                }
224
            }
225
        }
226
        flush(&mut out, &path, &url, &branch);
227
        out
228
    }
229
230
    /// Git's `.gitmodules` form of the Git-kind entries.
231
    pub fn to_gitmodules(&self) -> String {
232
        let mut s = String::new();
233
        for (path, m) in &self.entries {
234
            if !m.git {
235
                continue;
236
            }
237
            s.push_str(&format!(
238
                "[submodule \"{path}\"]\n\tpath = {path}\n\turl = {}\n",
239
                m.url
240
            ));
241
            if let Some(b) = &m.bookmark {
242
                s.push_str(&format!("\tbranch = {b}\n"));
243
            }
244
        }
245
        s
246
    }
247
}
248
249
#[cfg(test)]
250
mod tests {
251
    use super::*;
252
    use crate::hash::ObjectRef;
253
254
    #[test]
255
    fn pins_round_trip() {
256
        let o = Pin::Oxy {
257
            repo: [7; 16],
258
            heads: vec![ObjectRef([2; 32]), ObjectRef([1; 32])],
259
            bookmark: "main".into(),
260
        };
261
        let back = Pin::decode(&o.encode()).unwrap();
262
        assert_eq!(
263
            back,
264
            Pin::Oxy {
265
                repo: [7; 16],
266
                heads: vec![ObjectRef([1; 32]), ObjectRef([2; 32])],
267
                bookmark: "main".into()
268
            }
269
        );
270
        let g = Pin::Git {
271
            sha: "0cb4742f64cf204e5fafd0963749d84afc561892".into(),
272
            branch: "main".into(),
273
        };
274
        assert_eq!(Pin::decode(&g.encode()).unwrap(), g);
275
        assert!(g.is_git() && !o.is_git());
276
    }
277
278
    #[test]
279
    fn modules_files() {
280
        let text = "[libs/sub]\nurl = ../sub\nbookmark = main\n\n[vendor/x]\nurl = https://example.org/x.git\nkind = git\nbranch = trunk\n";
281
        let m = Modules::parse(text);
282
        assert_eq!(
283
            m.entries["libs/sub"],
284
            Module {
285
                url: "../sub".into(),
286
                bookmark: Some("main".into()),
287
                git: false
288
            }
289
        );
290
        assert_eq!(
291
            m.entries["vendor/x"],
292
            Module {
293
                url: "https://example.org/x.git".into(),
294
                bookmark: Some("trunk".into()),
295
                git: true
296
            }
297
        );
298
        assert_eq!(Modules::parse(&m.to_text()), m);
299
        let gm = "[submodule \"rstar-api\"]\n\tpath = rstar-api\n\turl = https://github.com/x/rstar-api.git\n[submodule \"c\"]\n\tpath = cstar-api\n\turl = https://github.com/x/cstar-api.git\n\tbranch = dev\n";
300
        let g = Modules::from_gitmodules(gm);
301
        assert_eq!(g.entries.len(), 2);
302
        assert!(g.entries["rstar-api"].git && g.entries["rstar-api"].bookmark.is_none());
303
        assert_eq!(g.entries["cstar-api"].bookmark.as_deref(), Some("dev"));
304
        assert!(
305
            g.to_gitmodules().contains("[submodule \"cstar-api\"]")
306
                && g.to_gitmodules().contains("branch = dev")
307
        );
308
    }
309
}