OXYGENHUB

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) 4dee277c327a

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 14:19 UTC
Signature
✔ signed by Andy McCall
Hash
4dee277c327ae9e908daff85af60474484fb0ccbc9fdf4262a754b607aa3e6f9
Depends on
132b884b9a8d 135212de5370 252e8cb23fbc 30ea9c1e70fa 4e88e774777a 50a046e439e9 614541c3cebd 79ab8528ff62 7b9809e67c3d 83e0677b07fe 86fe29fd62ea 94084087a876 96f7610fa666 982864068a59 9ac1fbe211b4 b7f102fcbb17 c3069537d7c5 cdb5beba7f05 ee06a6c557b7 fbda43e7d8ed fce44ae9ad29 ff0af1ee299d
Depended on by
284fe47d98b9 642da2636de2 64dc5197f9df b5e98094eb8f 4afe288b7946 4476cdbb0443 51d4862a16d8 e59512f2f075 c895bc52e4e0 f53cbe7a204e 9b723f69b032 7993d465675b a5f76c79666c 14edbaaf5291 ff1f62fee654 d4dbab97e948 f57bf6702628 76740263089c a07f60a37fb2 d0990c2bff74 2306a4d24779 eaf3e9068180 d3cd7ef95da6 c5ce0367eddf 1a3b62032ed9 958fb27dda7c 03571027793e fc1a466a1e66 e7ac6c2e84e4 0955469ac0e1 696bbf29d3eb 0bdc3f91a39f c863c7a7a2d1 62794ecc7f74 d42369abf180 75167672ebcd e02bf0cd0533 c6aa5bd9d1c8 957fee30826e
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
65 atoms, 2 tree operations, 17 files

Browse the tree at this change

added crates/oxy-core/src/repo/subrepos.rs +271 −0
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 +
}
added crates/oxy-core/src/subrepo.rs +219 −0
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::{array, bytes, check_version, field, oref, text, uint, ObjectError, FORMAT_VERSION};
11 +
use std::collections::BTreeMap;
12 +
13 +
#[derive(Clone, Debug, PartialEq, Eq)]
14 +
pub enum Pin {
15 +
    Oxy { repo: [u8; 16], heads: Vec<ChangeHash>, bookmark: String },
16 +
    Git { sha: String, branch: String },
17 +
}
18 +
19 +
impl Pin {
20 +
    pub fn encode(&self) -> Vec<u8> {
21 +
        let v = match self {
22 +
            Pin::Oxy { repo, heads, bookmark } => {
23 +
                let mut heads = heads.clone();
24 +
                heads.sort();
25 +
                heads.dedup();
26 +
                Value::map([
27 +
                    (0, Value::Uint(FORMAT_VERSION)),
28 +
                    (1, Value::Uint(0)),
29 +
                    (2, Value::Bytes(repo.to_vec())),
30 +
                    (3, Value::Array(heads.iter().map(|h| Value::Bytes(h.0.to_vec())).collect())),
31 +
                    (4, Value::Text(bookmark.clone())),
32 +
                ])
33 +
            }
34 +
            Pin::Git { sha, branch } => Value::map([
35 +
                (0, Value::Uint(FORMAT_VERSION)),
36 +
                (1, Value::Uint(1)),
37 +
                (2, Value::Null),
38 +
                (3, Value::Text(sha.clone())),
39 +
                (4, Value::Text(branch.clone())),
40 +
            ]),
41 +
        };
42 +
        cbor::encode(&v)
43 +
    }
44 +
45 +
    pub fn decode(b: &[u8]) -> Result<Pin, ObjectError> {
46 +
        let v = cbor::decode(b)?;
47 +
        let m = v.as_map().ok_or_else(|| ObjectError::Schema("pin: expected map".into()))?;
48 +
        check_version(m)?;
49 +
        let bookmark = text(field(m, 4, "bookmark")?, "bookmark")?.to_owned();
50 +
        match uint(field(m, 1, "kind")?, "kind")? {
51 +
            0 => {
52 +
                let repo: [u8; 16] = bytes(field(m, 2, "repo")?, "repo")?.try_into().map_err(|_| ObjectError::Schema("pin: repo id must be 16 bytes".into()))?;
53 +
                let heads = array(field(m, 3, "heads")?, "heads")?.iter().map(|h| oref(h, "head")).collect::<Result<Vec<_>, _>>()?;
54 +
                Ok(Pin::Oxy { repo, heads, bookmark })
55 +
            }
56 +
            1 => Ok(Pin::Git { sha: text(field(m, 3, "sha")?, "sha")?.to_owned(), branch: bookmark }),
57 +
            k => Err(ObjectError::Schema(format!("pin: unknown kind {k}"))),
58 +
        }
59 +
    }
60 +
61 +
    /// A one-line description for listings.
62 +
    pub fn summary(&self) -> String {
63 +
        match self {
64 +
            Pin::Oxy { heads, bookmark, .. } => format!("oxy {bookmark} {}", heads.iter().map(|h| h.short(12)).collect::<Vec<_>>().join(" ")),
65 +
            Pin::Git { sha, branch } => format!("git {branch} {}", &sha[..sha.len().min(12)]),
66 +
        }
67 +
    }
68 +
69 +
    pub fn is_git(&self) -> bool {
70 +
        matches!(self, Pin::Git { .. })
71 +
    }
72 +
}
73 +
74 +
/// One `.oxymodules` entry.
75 +
#[derive(Clone, Debug, Default, PartialEq, Eq)]
76 +
pub struct Module {
77 +
    pub url: String,
78 +
    /// Oxygen bookmark or Git branch to track.
79 +
    pub bookmark: Option<String>,
80 +
    pub git: bool,
81 +
}
82 +
83 +
/// `.oxymodules`: `[path]` sections with `url`, and `bookmark` (Oxygen) or
84 +
/// `branch` (Git) keys.
85 +
#[derive(Clone, Debug, Default, PartialEq, Eq)]
86 +
pub struct Modules {
87 +
    pub entries: BTreeMap<String, Module>,
88 +
}
89 +
90 +
impl Modules {
91 +
    pub fn parse(text: &str) -> Modules {
92 +
        let mut out = Modules::default();
93 +
        let mut current: Option<String> = None;
94 +
        for line in text.lines() {
95 +
            let line = line.trim();
96 +
            if line.is_empty() || line.starts_with('#') {
97 +
                continue;
98 +
            }
99 +
            if let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
100 +
                let path = inner.trim().trim_matches('"').trim_end_matches('/').to_string();
101 +
                out.entries.entry(path.clone()).or_default();
102 +
                current = Some(path);
103 +
                continue;
104 +
            }
105 +
            let (Some(path), Some((k, v))) = (&current, line.split_once('=')) else { continue };
106 +
            let v = v.trim().trim_matches('"').to_string();
107 +
            let m = out.entries.get_mut(path).unwrap();
108 +
            match k.trim() {
109 +
                "url" => m.url = v,
110 +
                "bookmark" => {
111 +
                    m.bookmark = Some(v);
112 +
                    m.git = false;
113 +
                }
114 +
                "branch" => {
115 +
                    m.bookmark = Some(v);
116 +
                    m.git = true;
117 +
                }
118 +
                "kind" => m.git = v == "git",
119 +
                _ => {}
120 +
            }
121 +
        }
122 +
        out
123 +
    }
124 +
125 +
    pub fn to_text(&self) -> String {
126 +
        let mut s = String::new();
127 +
        for (path, m) in &self.entries {
128 +
            s.push_str(&format!("[{path}]\nurl = {}\n", m.url));
129 +
            if m.git {
130 +
                s.push_str("kind = git\n");
131 +
            }
132 +
            if let Some(b) = &m.bookmark {
133 +
                s.push_str(&format!("{} = {b}\n", if m.git { "branch" } else { "bookmark" }));
134 +
            }
135 +
            s.push('\n');
136 +
        }
137 +
        s
138 +
    }
139 +
140 +
    /// From Git's `.gitmodules` (`[submodule "name"]` with `path`, `url`, `branch`).
141 +
    pub fn from_gitmodules(text: &str) -> Modules {
142 +
        let mut out = Modules::default();
143 +
        let mut path: Option<String> = None;
144 +
        let mut url = String::new();
145 +
        let mut branch: Option<String> = None;
146 +
        let flush = |out: &mut Modules, path: &Option<String>, url: &str, branch: &Option<String>| {
147 +
            if let Some(p) = path {
148 +
                out.entries.insert(p.clone(), Module { url: url.to_string(), bookmark: branch.clone(), git: true });
149 +
            }
150 +
        };
151 +
        for line in text.lines() {
152 +
            let line = line.trim();
153 +
            if line.starts_with("[submodule") {
154 +
                flush(&mut out, &path, &url, &branch);
155 +
                path = None;
156 +
                url.clear();
157 +
                branch = None;
158 +
                continue;
159 +
            }
160 +
            if let Some((k, v)) = line.split_once('=') {
161 +
                let v = v.trim().to_string();
162 +
                match k.trim() {
163 +
                    "path" => path = Some(v.trim_end_matches('/').to_string()),
164 +
                    "url" => url = v,
165 +
                    "branch" => branch = Some(v),
166 +
                    _ => {}
167 +
                }
168 +
            }
169 +
        }
170 +
        flush(&mut out, &path, &url, &branch);
171 +
        out
172 +
    }
173 +
174 +
    /// Git's `.gitmodules` form of the Git-kind entries.
175 +
    pub fn to_gitmodules(&self) -> String {
176 +
        let mut s = String::new();
177 +
        for (path, m) in &self.entries {
178 +
            if !m.git {
179 +
                continue;
180 +
            }
181 +
            s.push_str(&format!("[submodule \"{path}\"]\n\tpath = {path}\n\turl = {}\n", m.url));
182 +
            if let Some(b) = &m.bookmark {
183 +
                s.push_str(&format!("\tbranch = {b}\n"));
184 +
            }
185 +
        }
186 +
        s
187 +
    }
188 +
}
189 +
190 +
#[cfg(test)]
191 +
mod tests {
192 +
    use super::*;
193 +
    use crate::hash::ObjectRef;
194 +
195 +
    #[test]
196 +
    fn pins_round_trip() {
197 +
        let o = Pin::Oxy { repo: [7; 16], heads: vec![ObjectRef([2; 32]), ObjectRef([1; 32])], bookmark: "main".into() };
198 +
        let back = Pin::decode(&o.encode()).unwrap();
199 +
        assert_eq!(back, Pin::Oxy { repo: [7; 16], heads: vec![ObjectRef([1; 32]), ObjectRef([2; 32])], bookmark: "main".into() });
200 +
        let g = Pin::Git { sha: "0cb4742f64cf204e5fafd0963749d84afc561892".into(), branch: "main".into() };
201 +
        assert_eq!(Pin::decode(&g.encode()).unwrap(), g);
202 +
        assert!(g.is_git() && !o.is_git());
203 +
    }
204 +
205 +
    #[test]
206 +
    fn modules_files() {
207 +
        let text = "[libs/sub]\nurl = ../sub\nbookmark = main\n\n[vendor/x]\nurl = https://example.org/x.git\nkind = git\nbranch = trunk\n";
208 +
        let m = Modules::parse(text);
209 +
        assert_eq!(m.entries["libs/sub"], Module { url: "../sub".into(), bookmark: Some("main".into()), git: false });
210 +
        assert_eq!(m.entries["vendor/x"], Module { url: "https://example.org/x.git".into(), bookmark: Some("trunk".into()), git: true });
211 +
        assert_eq!(Modules::parse(&m.to_text()), m);
212 +
        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";
213 +
        let g = Modules::from_gitmodules(gm);
214 +
        assert_eq!(g.entries.len(), 2);
215 +
        assert!(g.entries["rstar-api"].git && g.entries["rstar-api"].bookmark.is_none());
216 +
        assert_eq!(g.entries["cstar-api"].bookmark.as_deref(), Some("dev"));
217 +
        assert!(g.to_gitmodules().contains("[submodule \"cstar-api\"]") && g.to_gitmodules().contains("branch = dev"));
218 +
    }
219 +
}
modified crates/oxy-core/src/lib.rs +1 −0
…
18 unchanged lines
19 19
pub mod rewrite;
20 20
pub mod state;
21 21
pub mod store;
22 +
pub mod subrepo;
22 23
pub mod tree;
modified crates/oxy-core/src/objects.rs +5 −1
…
98 unchanged lines
99 99
    File,
100 100
    Dir,
101 101
    Symlink,
102 +
    /// A nested repository pinned to a state (spec §7.8); content is a pin.
103 +
    Subrepo,
102 104
}
103 105
104 106
impl NodeKind {
…
2 unchanged lines
107 109
            NodeKind::File => 0,
108 110
            NodeKind::Dir => 1,
109 111
            NodeKind::Symlink => 2,
112 +
            NodeKind::Subrepo => 3,
110 113
        }
111 114
    }
112 115
    pub(crate) fn from_tag(t: u64) -> Result<NodeKind, ObjectError> {
…
1 unchanged lines
114 117
            0 => NodeKind::File,
115 118
            1 => NodeKind::Dir,
116 119
            2 => NodeKind::Symlink,
120 +
            3 => NodeKind::Subrepo,
117 121
            _ => return schema(format!("unknown node kind {t}")),
118 122
        })
119 123
    }
…
586 unchanged lines
706 710
            assert_eq!(Mode::from_bits(m.bits()).unwrap(), m);
707 711
        }
708 712
        assert!(Mode::from_bits(4).is_err());
709 -
        for k in [NodeKind::File, NodeKind::Dir, NodeKind::Symlink] {
713 +
        for k in [NodeKind::File, NodeKind::Dir, NodeKind::Symlink, NodeKind::Subrepo] {
710 714
            assert_eq!(NodeKind::from_tag(k.tag()).unwrap(), k);
711 715
        }
712 716
    }
…
25 unchanged lines
modified crates/oxy-core/src/repo/checkout.rs +21 −3
…
6 unchanged lines
7 7
    pub written: usize,
8 8
    pub removed: usize,
9 9
    pub conflicted: bool,
10 +
    /// Nested repositories that could not be moved, and other remarks.
11 +
    pub notes: Vec<String>,
10 12
}
11 13
12 14
impl Repo {
…
12 unchanged lines
25 27
        let current = self.walk_working_copy()?;
26 28
        let mut report = CheckoutReport::default();
27 29
28 -
        // Remove tracked paths that the target does not have.
30 +
        // Remove tracked paths that the target does not have. A nested
31 +
        // repository is never deleted (spec §7.8); it is left in place.
29 32
        let mut dirs_to_prune: BTreeSet<PathBuf> = BTreeSet::new();
30 -
        for path in current.keys() {
33 +
        for (path, entry) in &current {
31 34
            if !target.files.contains_key(path) {
35 +
                if entry.kind == NodeKind::Subrepo {
36 +
                    report.notes.push(format!("{path}: nested repository left in place"));
37 +
                    continue;
38 +
                }
32 39
                let full = self.safe_path(path)?;
33 40
                fs::remove_file(&full)?;
34 41
                report.removed += 1;
…
24 unchanged lines
59 66
                FileContent::Opaque { blobs } => {
60 67
                    let Some(blob) = blobs.first() else { continue };
61 68
                    let bytes = self.read_blob(blob)?;
69 +
                    if f.kind == NodeKind::Subrepo {
70 +
                        let pin = crate::subrepo::Pin::decode(&bytes)?;
71 +
                        match self.sync_subrepo(path, &pin, false)? {
72 +
                            SubrepoSync::Updated => report.written += 1,
73 +
                            SubrepoSync::UpToDate => {}
74 +
                            SubrepoSync::Missing => report.notes.push(format!("{path}: nested repository not present; run oxy submodule update --init")),
75 +
                            SubrepoSync::Dirty => report.notes.push(format!("{path}: nested repository has uncommitted changes; left as is")),
76 +
                            SubrepoSync::Failed(e) => report.notes.push(format!("{path}: {e}")),
77 +
                        }
78 +
                        continue;
79 +
                    }
62 80
                    if f.kind == NodeKind::Symlink {
63 81
                        let same = current.get(path).is_some_and(|c| c.kind == NodeKind::Symlink && c.content == bytes);
64 82
                        if !same {
…
43 unchanged lines
108 126
                _ => None,
109 127
            })
110 128
            .collect();
111 -
        let entries = self.walk_working_copy_with(None, &Self::tracked_paths(&target))?;
129 +
        let entries = self.walk_working_copy_with(None, &Self::tracked_kinds(&target))?;
112 130
        self.write_manifest_entries(report.conflicted, false, &entries, &blobs)?;
113 131
        let _ = self.run_hook(
114 132
            "post-checkout",
…
68 unchanged lines
modified crates/oxy-core/src/repo/git.rs +51 −2
…
13 unchanged lines
14 14
pub struct GitImportReport {
15 15
    pub commits: usize,
16 16
    pub merges: usize,
17 -
    pub skipped_submodules: usize,
17 +
    /// Gitlink entries imported as subrepo nodes with Git pins.
18 +
    pub submodules: usize,
18 19
    pub heads: usize,
19 20
}
20 21
…
130 unchanged lines
151 152
pub struct GitExportReport {
152 153
    pub exported: usize,
153 154
    pub skipped: usize,
155 +
    /// Subrepo nodes with Oxygen pins, which Git cannot represent.
156 +
    pub skipped_subrepos: usize,
154 157
    pub branch: String,
155 158
    pub heads: usize,
156 159
}
…
256 unchanged lines
413 416
        let text: String = marks.iter().map(|(h, m)| format!("{} {m}\n", h.to_hex())).collect();
414 417
        write_atomic(&oxy_marks_path, text.as_bytes())?;
415 418
        let _ = git(git_dir, &["update-ref", "-d", "refs/oxy/export"]);
419 +
        report.skipped_subrepos = self.git_export_skipped.replace(0);
416 420
        Ok(report)
417 421
    }
418 422
…
55 unchanged lines
474 478
475 479
            // The commit's tree as in-memory entries.
476 480
            let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
481 +
            let mut gitlinks: Vec<(String, String)> = Vec::new();
477 482
            for (path, (mode, sha)) in list_tree(git_dir, &gc.sha)? {
478 483
                let (kind, exec) = match mode.as_str() {
479 484
                    "100644" | "100664" => (NodeKind::File, false),
480 485
                    "100755" => (NodeKind::File, true),
481 486
                    "120000" => (NodeKind::Symlink, false),
482 487
                    "160000" => {
483 -
                        report.skipped_submodules += 1;
488 +
                        gitlinks.push((path, sha));
484 489
                        continue;
485 490
                    }
486 491
                    _ => continue,
…
14 unchanged lines
501 506
                entries.insert(path, FsEntry::new(kind, exec, content));
502 507
            }
503 508
509 +
            // Submodules (spec §23): gitlinks become subrepo nodes with Git
510 +
            // pins, and `.gitmodules` also yields `.oxymodules`.
511 +
            if !gitlinks.is_empty() {
512 +
                report.submodules += gitlinks.len();
513 +
                let gm = entries.get(".gitmodules").map(|e| crate::subrepo::Modules::from_gitmodules(&String::from_utf8_lossy(&e.content))).unwrap_or_default();
514 +
                let mut modules = entries.get(".oxymodules").map(|e| crate::subrepo::Modules::parse(&String::from_utf8_lossy(&e.content))).unwrap_or_default();
515 +
                for (path, sha) in &gitlinks {
516 +
                    let m = gm.entries.get(path);
517 +
                    let branch = m.and_then(|m| m.bookmark.clone()).unwrap_or_else(|| "HEAD".into());
518 +
                    entries.insert(path.clone(), FsEntry::new(NodeKind::Subrepo, false, crate::subrepo::Pin::Git { sha: sha.clone(), branch }.encode()));
519 +
                    if let Some(m) = m {
520 +
                        modules.entries.insert(path.clone(), m.clone());
521 +
                    }
522 +
                }
523 +
                if !modules.entries.is_empty() {
524 +
                    entries.insert(".oxymodules".into(), FsEntry::new(NodeKind::File, false, modules.to_text().into_bytes()));
525 +
                }
526 +
            }
504 527
            let built = self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?;
505 528
            let mut deps: BTreeSet<ChangeHash> = built.contributors.iter().copied().collect();
506 529
            let mut message = gc.message.clone();
…
125 unchanged lines
632 655
            out.extend_from_slice(format!("{} {p}\n", if i == 0 { "from" } else { "merge" }).as_bytes());
633 656
        }
634 657
        out.extend_from_slice(b"deleteall\n");
658 +
        let has_gitmodules = snapshot.files.contains_key(".gitmodules");
635 659
        for (path, f) in &snapshot.files {
660 +
            if path == ".oxymodules" {
661 +
                // Git reads `.gitmodules`; derive it unless the tree has one.
662 +
                if !has_gitmodules {
663 +
                    if let FileContent::Text { bytes, .. } = &f.content {
664 +
                        let gm = crate::subrepo::Modules::parse(&String::from_utf8_lossy(bytes)).to_gitmodules();
665 +
                        if !gm.is_empty() {
666 +
                            out.extend_from_slice(format!("M 100644 inline .gitmodules\ndata {}\n", gm.len()).as_bytes());
667 +
                            out.extend_from_slice(gm.as_bytes());
668 +
                            out.push(b'\n');
669 +
                        }
670 +
                    }
671 +
                }
672 +
                continue;
673 +
            }
674 +
            if f.kind == NodeKind::Subrepo {
675 +
                if let FileContent::Opaque { blobs } = &f.content {
676 +
                    if let Some(b) = blobs.first() {
677 +
                        match crate::subrepo::Pin::decode(&self.read_blob(b)?)? {
678 +
                            crate::subrepo::Pin::Git { sha, .. } => out.extend_from_slice(format!("M 160000 {sha} {path}\n").as_bytes()),
679 +
                            crate::subrepo::Pin::Oxy { .. } => self.git_export_skipped.set(self.git_export_skipped.get() + 1),
680 +
                        }
681 +
                    }
682 +
                }
683 +
                continue;
684 +
            }
636 685
            let (mode, bytes): (&str, Vec<u8>) = match (&f.kind, &f.content) {
637 686
                (NodeKind::Dir, _) => continue,
638 687
                (NodeKind::Symlink, FileContent::Opaque { blobs }) => ("120000", blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default()),
…
12 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +98 −18
…
9 unchanged lines
10 10
use crate::attest::{Attest, AttestBody};
11 11
use crate::diff::{ranges, split_lines, text_edits};
12 12
use crate::protocol::{open_remote, Bundle, RefResult, RefUpdate, RefsResponse, RemoteOptions, RemoteRef, SignedAttest, SignedPolicy};
13 +
#[allow(unused_imports)]
14 +
use crate::subrepo::Pin;
13 15
use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType};
14 16
use crate::identity::{verify as verify_signature, Domain, Identity, SecretKey, Signature};
15 17
use crate::ids::{ChangeId, NodeId, WorkspaceId};
…
37 unchanged lines
53 55
54 56
mod bookmarks;
55 57
mod checkout;
58 +
mod subrepos;
56 59
mod checkpoint;
57 60
mod config;
58 61
mod gc;
…
11 unchanged lines
70 73
mod tests;
71 74
72 75
pub use bookmarks::{BookmarkInfo, MergeReport};
76 +
pub use subrepos::{SubrepoInfo, SubrepoSync};
73 77
pub use checkout::CheckoutReport;
74 78
pub use config::{parse_size, read_config_file, Config};
75 79
pub use gc::GcReport;
…
48 unchanged lines
124 128
}
125 129
126 130
pub struct Repo {
131 +
    /// Oxygen-pinned subrepos skipped by the running Git export.
132 +
    pub(crate) git_export_skipped: std::cell::Cell<usize>,
127 133
    root: PathBuf,
128 134
    oxy: PathBuf,
129 135
    store: Store,
…
59 unchanged lines
189 195
        let workspace = WorkspaceId::random();
190 196
        write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?;
191 197
        let store = Store::open(oxy.join("objects"))?;
192 -
        let repo = Repo { root, oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
198 +
        let repo = Repo { git_export_skipped: Default::default(), root, oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
193 199
        repo.register_workspace(&workspace, &repo.root)?;
194 200
195 201
        let wc = repo.new_working_copy(vec![], "");
…
23 unchanged lines
219 225
                }
220 226
                let config = Config::load(Some(&default_user_config()), &shared.join("config"))?;
221 227
                let store = Store::open(shared.join("objects"))?;
222 -
                let mut repo = Repo { root: d.to_path_buf(), oxy: shared, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
228 +
                let mut repo = Repo { git_export_skipped: Default::default(), root: d.to_path_buf(), oxy: shared, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
223 229
                repo.apply_workspace_bookmark();
224 230
                return Ok(repo);
225 231
            }
…
3 unchanged lines
229 235
                let workspace = WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim())
230 236
                    .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?;
231 237
                let store = Store::open(oxy.join("objects"))?;
232 -
                let mut repo = Repo { root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
238 +
                let mut repo = Repo { git_export_skipped: Default::default(), root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
233 239
                repo.apply_workspace_bookmark();
234 240
                if format == FORMAT1_LINE {
235 241
                    repo.migrate_format1()?;
…
354 unchanged lines
590 596
    }
591 597
592 598
    fn walk_working_copy(&self) -> Result<BTreeMap<String, FsEntry>> {
593 -
        self.walk_working_copy_with(None, &BTreeSet::new())
599 +
        self.walk_working_copy_with(None, &BTreeMap::new())
600 +
    }
601 +
602 +
    /// A walker over the working copy that never enters a nested repository
603 +
    /// (a directory below the root holding `.oxy` or `.git`); the directories
604 +
    /// it skipped are collected into `nested`.
605 +
    pub(crate) fn walker(&self, nested: &std::sync::Arc<std::sync::Mutex<Vec<PathBuf>>>) -> ignore::WalkBuilder {
606 +
        let mut wb = ignore::WalkBuilder::new(&self.root);
607 +
        wb.hidden(false)
608 +
            .git_ignore(false)
609 +
            .git_global(false)
610 +
            .git_exclude(false)
611 +
            .require_git(false)
612 +
            .follow_links(false)
613 +
            .add_custom_ignore_filename(".oxyignore");
614 +
        let nested = nested.clone();
615 +
        wb.filter_entry(move |e| {
616 +
            if e.file_name() == OXY_DIR {
617 +
                return false;
618 +
            }
619 +
            if e.depth() > 0 && e.file_type().is_some_and(|t| t.is_dir()) && (e.path().join(OXY_DIR).exists() || e.path().join(".git").exists()) {
620 +
                nested.lock().unwrap().push(e.path().to_path_buf());
621 +
                return false;
622 +
            }
623 +
            true
624 +
        });
625 +
        wb
594 626
    }
595 627
596 628
    /// Walk the working copy; with a manifest, a file whose size and mtime
…
4 unchanged lines
601 633
    /// `.oxyignore` applies to untracked paths only: every path in `tracked`
602 634
    /// (the base state's files) that exists on disk is included even when
603 635
    /// an ignore rule matches it, as in Git.
604 -
    fn walk_working_copy_with(&self, cache: Option<&prompt::Manifest>, tracked: &BTreeSet<String>) -> Result<BTreeMap<String, FsEntry>> {
605 -
        let mut wb = ignore::WalkBuilder::new(&self.root);
606 -
        wb.hidden(false)
607 -
            .git_ignore(false)
608 -
            .git_global(false)
609 -
            .git_exclude(false)
610 -
            .require_git(false)
611 -
            .follow_links(false)
612 -
            .add_custom_ignore_filename(".oxyignore");
613 -
        wb.filter_entry(|e| e.file_name() != OXY_DIR);
636 +
    fn walk_working_copy_with(&self, cache: Option<&prompt::Manifest>, tracked: &BTreeMap<String, (NodeKind, Option<ObjectRef>)>) -> Result<BTreeMap<String, FsEntry>> {
637 +
        let nested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
638 +
        let wb = self.walker(&nested);
614 639
        let mut out = BTreeMap::new();
615 640
        for entry in wb.build() {
616 641
            let entry = entry.map_err(|e| RepoError::Msg(e.to_string()))?;
…
16 unchanged lines
633 658
                out.insert(rel, e);
634 659
            }
635 660
        }
661 +
        // Nested repositories (spec §7.8): registered in `.oxymodules` or
662 +
        // already tracked as subrepo nodes, they contribute their pin;
663 +
        // unregistered ones are left alone.
664 +
        let modules = self.modules_on_disk();
665 +
        let removed = self.removed_subrepos();
666 +
        for rel in &removed {
667 +
            if tracked.get(rel).map(|(k, _)| *k) != Some(NodeKind::Subrepo) {
668 +
                self.clear_removed_subrepo(rel);
669 +
            }
670 +
        }
671 +
        let nested_dirs = std::mem::take(&mut *nested.lock().unwrap());
672 +
        for dir in nested_dirs {
673 +
            let rel = self.rel_path(&dir);
674 +
            if !self.in_scope(&rel) || removed.contains(&rel) {
675 +
                continue;
676 +
            }
677 +
            let registered = modules.entries.get(&rel);
678 +
            if registered.is_none() && tracked.get(&rel).map(|(k, _)| *k) != Some(NodeKind::Subrepo) {
679 +
                continue;
680 +
            }
681 +
            let pin = subrepos::read_pin(&dir, registered.and_then(|m| m.bookmark.as_deref()))?;
682 +
            out.insert(rel, FsEntry::new(NodeKind::Subrepo, false, pin.encode()));
683 +
        }
636 684
        // Tracked paths the ignore rules hid.
637 -
        for rel in tracked {
685 +
        for (rel, (kind, blob)) in tracked {
638 686
            if out.contains_key(rel) || !self.in_scope(rel) {
639 687
                continue;
640 688
            }
641 689
            let path = self.root.join(rel);
690 +
            if *kind == NodeKind::Subrepo {
691 +
                if removed.contains(rel) {
692 +
                    continue;
693 +
                }
694 +
                let present = path.is_dir() && (path.join(OXY_DIR).exists() || path.join(".git").exists());
695 +
                if present {
696 +
                    let pin = subrepos::read_pin(&path, modules.entries.get(rel).and_then(|m| m.bookmark.as_deref()))?;
697 +
                    out.insert(rel.clone(), FsEntry::new(NodeKind::Subrepo, false, pin.encode()));
698 +
                } else if let Some(b) = blob {
699 +
                    // Not cloned (or emptied): the pin stays as recorded, as a
700 +
                    // Git gitlink does when its directory is absent.
701 +
                    out.insert(rel.clone(), FsEntry { kind: NodeKind::Subrepo, exec: false, content: vec![], blob: Some(*b), content_hash: None });
702 +
                }
703 +
                continue;
704 +
            }
642 705
            let Ok(meta) = fs::symlink_metadata(&path) else { continue };
643 706
            let ft = meta.file_type();
644 707
            if ft.is_symlink() {
…
23 unchanged lines
668 731
        })
669 732
    }
670 733
671 -
    /// The non-directory paths of a snapshot, for the walker's `tracked` set.
672 -
    pub(crate) fn tracked_paths(snapshot: &Snapshot) -> BTreeSet<String> {
673 -
        snapshot.files.iter().filter(|(_, f)| f.kind != NodeKind::Dir).map(|(p, _)| p.clone()).collect()
734 +
    /// The non-directory paths of a snapshot with their kinds, for the
735 +
    /// walker's `tracked` set.
736 +
    pub(crate) fn tracked_kinds(snapshot: &Snapshot) -> BTreeMap<String, (NodeKind, Option<ObjectRef>)> {
737 +
        snapshot
738 +
            .files
739 +
            .iter()
740 +
            .filter(|(_, f)| f.kind != NodeKind::Dir)
741 +
            .map(|(p, f)| {
742 +
                let blob = match &f.content {
743 +
                    FileContent::Opaque { blobs } => blobs.first().copied(),
744 +
                    _ => None,
745 +
                };
746 +
                (p.clone(), (f.kind, blob))
747 +
            })
748 +
            .collect()
749 +
    }
750 +
751 +
    /// A path under the root as a `/`-separated relative string.
752 +
    pub(crate) fn rel_path(&self, path: &Path) -> String {
753 +
        path.strip_prefix(&self.root).unwrap_or(path).components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/")
674 754
    }
675 755
}
676 756
…
121 unchanged lines
modified crates/oxy-core/src/repo/prompt.rs +2 −3
…
208 unchanged lines
209 209
            return Ok(Some(true));
210 210
        }
211 211
        let mut seen = 0usize;
212 -
        let mut wb = ignore::WalkBuilder::new(&self.root);
213 -
        wb.hidden(false).git_ignore(false).git_global(false).git_exclude(false).require_git(false).follow_links(false).add_custom_ignore_filename(".oxyignore");
214 -
        wb.filter_entry(|e| e.file_name() != OXY_DIR);
212 +
        let nested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
213 +
        let wb = self.walker(&nested);
215 214
        for entry in wb.build() {
216 215
            let Ok(entry) = entry else { continue };
217 216
            let Some(ft) = entry.file_type() else { continue };
…
70 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +3 −3
…
64 unchanged lines
65 65
    /// change from `base` to the working directory. `reuse` maps paths to the
66 66
    /// NodeIds an earlier version of the same change assigned to new paths.
67 67
    pub(crate) fn build_against(&self, base: &State, base_snapshot: &Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Result<BuiltChange> {
68 -
        let fs_entries = self.walk_working_copy_with(None, &Self::tracked_paths(base_snapshot))?;
68 +
        let fs_entries = self.walk_working_copy_with(None, &Self::tracked_kinds(base_snapshot))?;
69 69
        self.build_from_entries(base, base_snapshot, reuse, &fs_entries)
70 70
    }
71 71
…
72 unchanged lines
144 144
        let reuse = added_node_paths(&old_wc, &base_snapshot);
145 145
        // The last manifest lets unchanged opaque files go unread.
146 146
        let manifest = self.read_manifest()?.map(|(_, _, m)| m);
147 -
        let fs_entries = self.walk_working_copy_with(manifest.as_ref(), &Self::tracked_paths(&base_snapshot))?;
147 +
        let fs_entries = self.walk_working_copy_with(manifest.as_ref(), &Self::tracked_kinds(&base_snapshot))?;
148 148
        let built = self.build_from_entries(&base, &base_snapshot, reuse, &fs_entries)?;
149 149
150 150
        let mut wc = Change {
…
251 unchanged lines
402 402
        if e.blob.is_some() {
403 403
            return true;
404 404
        }
405 -
        if e.kind == NodeKind::Symlink {
405 +
        if e.kind == NodeKind::Symlink || e.kind == NodeKind::Subrepo {
406 406
            return true;
407 407
        }
408 408
        match self.attrs.lookup(path).binary {
…
470 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +146 −1
…
325 unchanged lines
326 326
    let h2 = repo.commit("second", &[]).unwrap();
327 327
328 328
    let r = repo.checkout(&[h1], false, "oxy checkout").unwrap();
329 -
    assert_eq!(r, CheckoutReport { written: 2, removed: 1, conflicted: false });
329 +
    assert_eq!(r, CheckoutReport { written: 2, removed: 1, conflicted: false, notes: vec![] });
330 330
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n");
331 331
    assert_eq!(fs::read(root.join("sub/deep/b.txt")).unwrap(), b"b\n");
332 332
    assert!(!root.join("bin.dat").exists());
…
1835 unchanged lines
2168 2168
    assert!(repo.checkout(&[h_a], false, "checkout").is_ok(), "changes of a deleted bookmark remain");
2169 2169
}
2170 2170
2171 +
#[test]
2172 +
fn oxygen_subrepos_pin_follow_and_update() {
2173 +
    hermetic();
2174 +
    let dir = tempfile::tempdir().unwrap();
2175 +
    let root = dir.path();
2176 +
    // A library repository with two commits, and a bare-ish origin for it.
2177 +
    let lib_origin = Repo::init(&root.join("lib-origin")).unwrap();
2178 +
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
2179 +
    let lib = Repo::clone(&lib_url, &root.join("lib")).unwrap().with_home(root.join("home"));
2180 +
    write(&root.join("lib"), "lib.txt", b"v1\n");
2181 +
    let l1 = lib.commit("lib v1", &[]).unwrap();
2182 +
    assert!(lib.push("origin", false).unwrap()[0].ok);
2183 +
    let _ = lib_origin;
2184 +
2185 +
    // The application (a clone of its own origin) adds it as a subrepo.
2186 +
    let app_origin = Repo::init(&root.join("app-origin")).unwrap();
2187 +
    let app_url = root.join("app-origin").to_string_lossy().to_string();
2188 +
    let _ = app_origin;
2189 +
    let app_dir = root.join("app");
2190 +
    let app = Repo::clone(&app_url, &app_dir).unwrap().with_home(root.join("home"));
2191 +
    write(&app_dir, "main.txt", b"app\n");
2192 +
    let pin = app.submodule_add(&lib_url, "libs/lib", None).unwrap();
2193 +
    assert!(matches!(&pin, crate::subrepo::Pin::Oxy { heads, .. } if heads == &vec![l1]));
2194 +
    assert!(app_dir.join(".oxymodules").exists() && app_dir.join("libs/lib/.oxy").is_dir());
2195 +
    let snap = app.snapshot("status").unwrap();
2196 +
    let paths: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect();
2197 +
    assert_eq!(paths, vec![".oxymodules", "libs/lib", "main.txt"], "the nested files are not walked");
2198 +
    let a1 = app.commit("app with lib", &[]).unwrap();
2199 +
    let files = app.load_state(&[a1]).unwrap().materialize().files;
2200 +
    assert_eq!(files["libs/lib"].kind, NodeKind::Subrepo);
2201 +
    assert!(!files.contains_key("libs/lib/lib.txt"));
2202 +
    assert!(!app.snapshot("status").unwrap().changed);
2203 +
    let subs = app.subrepos().unwrap();
2204 +
    assert_eq!(subs.len(), 1);
2205 +
    assert_eq!(subs[0].actual.as_ref(), Some(&subs[0].pin));
2206 +
2207 +
    // Advance the nested repository: the parent sees a modified pin.
2208 +
    let nested = Repo::open(&app_dir.join("libs/lib")).unwrap().with_home(root.join("home"));
2209 +
    write(&app_dir.join("libs/lib"), "lib.txt", b"v2\n");
2210 +
    let l2 = nested.commit("lib v2", &[]).unwrap();
2211 +
    assert!(nested.push("origin", false).unwrap()[0].ok);
2212 +
    let snap = app.snapshot("status").unwrap();
2213 +
    assert_eq!(snap.statuses.iter().map(|s| (s.path.as_str(), s.status)).collect::<Vec<_>>(), vec![("libs/lib", ChangeStatus::Modified)]);
2214 +
    let a2 = app.commit("bump lib", &[]).unwrap();
2215 +
    // Checking out the older app state moves the nested repository back.
2216 +
    let r = app.checkout(&[a1], false, "checkout").unwrap();
2217 +
    assert!(r.notes.is_empty(), "{:?}", r.notes);
2218 +
    assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v1\n");
2219 +
    assert_eq!(Repo::open(&app_dir.join("libs/lib")).unwrap().bookmark_heads().unwrap(), vec![l1]);
2220 +
    assert!(!app.snapshot("status").unwrap().changed);
2221 +
    // A dirty nested repository is left alone and reported.
2222 +
    write(&app_dir.join("libs/lib"), "scratch.txt", b"wip\n");
2223 +
    let r = app.checkout(&[a2], false, "checkout").unwrap();
2224 +
    assert!(r.notes.iter().any(|n| n.contains("uncommitted")), "{:?}", r.notes);
2225 +
    assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v1\n");
2226 +
    fs::remove_file(app_dir.join("libs/lib/scratch.txt")).unwrap();
2227 +
    let r = app.submodule_update(false, &[]).unwrap();
2228 +
    assert_eq!(r, vec![("libs/lib".to_string(), SubrepoSync::Updated)]);
2229 +
    assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v2\n");
2230 +
    let _ = l2;
2231 +
2232 +
    // A fresh clone of the app has the pin but no nested repository until update --init.
2233 +
    assert!(app.push("origin", false).unwrap()[0].ok);
2234 +
    let clone_dir = root.join("app-clone");
2235 +
    let clone = Repo::clone(&app_url, &clone_dir).unwrap().with_home(root.join("home"));
2236 +
    assert!(!clone_dir.join("libs/lib/.oxy").exists());
2237 +
    assert_eq!(clone.submodule_update(false, &[]).unwrap(), vec![("libs/lib".to_string(), SubrepoSync::Missing)]);
2238 +
    assert_eq!(clone.submodule_update(true, &[]).unwrap(), vec![("libs/lib".to_string(), SubrepoSync::Updated)]);
2239 +
    assert_eq!(fs::read(clone_dir.join("libs/lib/lib.txt")).unwrap(), b"v2\n");
2240 +
    assert!(!clone.snapshot("status").unwrap().changed);
2241 +
    // Removing the subrepo leaves the directory and drops the node.
2242 +
    clone.submodule_remove("libs/lib").unwrap();
2243 +
    let snap = clone.snapshot("status").unwrap();
2244 +
    assert!(snap.statuses.iter().any(|s| s.path == "libs/lib" && s.status == ChangeStatus::Removed), "{:?}", snap.statuses);
2245 +
    let c = clone.commit("drop lib", &[]).unwrap();
2246 +
    assert!(!clone.load_state(&[c]).unwrap().materialize().files.contains_key("libs/lib"));
2247 +
    assert!(clone_dir.join("libs/lib/lib.txt").exists());
2248 +
    assert!(!clone.snapshot("status").unwrap().changed, "an unregistered nested repository is ignored");
2249 +
}
2250 +
2251 +
#[test]
2252 +
fn git_submodules_import_and_export_as_gitlinks() {
2253 +
    hermetic();
2254 +
    if std::process::Command::new("git").arg("--version").output().is_err() {
2255 +
        eprintln!("git not installed; skipping");
2256 +
        return;
2257 +
    }
2258 +
    let dir = tempfile::tempdir().unwrap();
2259 +
    let root = dir.path();
2260 +
    let git_in = |d: &Path, args: &[&str]| -> String {
2261 +
        let out = std::process::Command::new("git")
2262 +
            .arg("-C")
2263 +
            .arg(d)
2264 +
            .args(["-c", "user.name=Grace", "-c", "[email protected]", "-c", "commit.gpgsign=false", "-c", "protocol.file.allow=always"])
2265 +
            .args(args)
2266 +
            .output()
2267 +
            .unwrap();
2268 +
        assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
2269 +
        String::from_utf8_lossy(&out.stdout).trim().to_string()
2270 +
    };
2271 +
    let sub = root.join("sub");
2272 +
    fs::create_dir_all(&sub).unwrap();
2273 +
    git_in(&sub, &["init", "-q", "-b", "main"]);
2274 +
    write(&sub, "s.txt", b"sub\n");
2275 +
    git_in(&sub, &["add", "."]);
2276 +
    git_in(&sub, &["commit", "-q", "-m", "sub initial"]);
2277 +
    let sub_sha = git_in(&sub, &["rev-parse", "HEAD"]);
2278 +
    let g = root.join("main.git");
2279 +
    fs::create_dir_all(&g).unwrap();
2280 +
    git_in(&g, &["init", "-q", "-b", "main"]);
2281 +
    write(&g, "README.md", b"# app\n");
2282 +
    git_in(&g, &["add", "."]);
2283 +
    git_in(&g, &["commit", "-q", "-m", "initial"]);
2284 +
    git_in(&g, &["submodule", "add", "-q", sub.to_str().unwrap(), "vendor/sub"]);
2285 +
    git_in(&g, &["commit", "-q", "-m", "add submodule"]);
2286 +
2287 +
    let dest = root.join("oxy");
2288 +
    let repo = Repo::init(&dest).unwrap().with_home(root.join("home"));
2289 +
    let rep = repo.import_git(&g, "main").unwrap();
2290 +
    assert_eq!((rep.commits, rep.submodules), (2, 1));
2291 +
    let files = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize().files;
2292 +
    assert_eq!(files["vendor/sub"].kind, NodeKind::Subrepo);
2293 +
    let FileContent::Opaque { blobs } = &files["vendor/sub"].content else { panic!() };
2294 +
    assert_eq!(crate::subrepo::Pin::decode(&repo.read_blob(&blobs[0]).unwrap()).unwrap(), crate::subrepo::Pin::Git { sha: sub_sha.clone(), branch: "HEAD".into() });
2295 +
    let modules = crate::subrepo::Modules::parse(&fs::read_to_string(dest.join(".oxymodules")).unwrap());
2296 +
    assert_eq!(modules.entries["vendor/sub"].url, sub.to_string_lossy());
2297 +
    assert!(modules.entries["vendor/sub"].git);
2298 +
    assert!(files.contains_key(".gitmodules"), "the Git file is kept for round trips");
2299 +
    assert!(!dest.join("vendor/sub/.git").exists(), "not cloned until update --init");
2300 +
    assert!(!repo.snapshot("status").unwrap().changed);
2301 +
    // update --init clones it at the pinned commit; status is unchanged.
2302 +
    assert_eq!(repo.submodule_update(true, &[]).unwrap(), vec![("vendor/sub".to_string(), SubrepoSync::Updated)]);
2303 +
    assert_eq!(git_in(&dest.join("vendor/sub"), &["rev-parse", "HEAD"]), sub_sha);
2304 +
    assert!(!repo.snapshot("status").unwrap().changed);
2305 +
    // Export writes the gitlink and .gitmodules back.
2306 +
    let mirror = root.join("mirror.git");
2307 +
    let rep = repo.git_export(&mirror).unwrap();
2308 +
    assert_eq!(rep.skipped_subrepos, 0);
2309 +
    let ls = git_in(&mirror, &["ls-tree", "main", "vendor/sub"]);
2310 +
    assert!(ls.starts_with("160000 commit ") && ls.contains(&sub_sha), "{ls}");
2311 +
    assert_eq!(git_in(&mirror, &["ls-tree", "--name-only", "main"]), ".gitmodules\nREADME.md\nvendor");
2312 +
    let orig_tree = git_in(&g, &["rev-parse", "main^{tree}"]);
2313 +
    assert_eq!(git_in(&mirror, &["rev-parse", "main^{tree}"]), orig_tree, "tree round trip");
2314 +
}
2315 +
modified crates/oxy-core/src/state.rs +1 −1
…
425 unchanged lines
426 426
        for (path, e) in &tree.entries {
427 427
            let content = match e.kind {
428 428
                NodeKind::Dir => FileContent::Dir,
429 -
                NodeKind::Symlink => FileContent::Opaque { blobs: self.live_blobs(&e.node) },
429 +
                NodeKind::Symlink | NodeKind::Subrepo => FileContent::Opaque { blobs: self.live_blobs(&e.node) },
430 430
                NodeKind::File if e.mode.opaque => FileContent::Opaque { blobs: self.live_blobs(&e.node) },
431 431
                NodeKind::File => {
432 432
                    let (bytes, materialized) = self.render_file(&e.node);
…
162 unchanged lines
modified docs/guide.md +20 −0
…
270 unchanged lines
271 271
left exactly as it was. A shallow clone starts from a *checkpoint* of the
272 272
state, so status and commit work at full speed from the first minute.
273 273
274 +
## 11a. Nested repositories (submodules)
275 +
276 +
A subrepo is another repository checked out at a path and pinned to a state,
277 +
Oxygen or Git. The pin is versioned like a file, so two people pinning
278 +
different states get an ordinary conflict.
279 +
280 +
```
281 +
oxy submodule add https://host/lib.git libs/lib     # Git or Oxygen URL; records .oxymodules
282 +
oxy commit -m "Add lib"                              # the pin is committed with the parent
283 +
oxy submodule status                                 # pin vs what is on disk
284 +
oxy submodule update --init                          # after a clone: fetch nested repositories
285 +
oxy submodule remove libs/lib                        # drop the node; the directory stays
286 +
```
287 +
288 +
Committing the parent records whatever the nested repository is at, and
289 +
checking out an older parent state moves the nested repository back, unless
290 +
it has uncommitted changes, which are never overwritten. An absent nested
291 +
repository keeps its pin until you run `update --init`. Git submodules
292 +
survive `oxy git import` as Git pins and go back out as gitlinks on export.
293 +
274 294
## 12. Git interoperability
275 295
276 296
```
…
92 unchanged lines
modified docs/plan.md +1 −1
…
40 unchanged lines
41 41
Rules as above. Spec Draft 0.8 defines each item; implement in this order, each with tests, a gate, and an oxy commit.
42 42
43 43
- [x] Multiple bookmarks (spec §10.1): `oxy bookmark create|set|delete|list|switch`, per-workspace current bookmark, `oxy merge BOOKMARK`, `oxy checkout NAME`, bookmark names resolved before change prefixes; push/pull/log act on the current bookmark; `oxy push -b`.
44 -
- [ ] Subrepositories (spec §7.8, §23): node kind `subrepo`, pin encoding, `.oxymodules`, snapshot stops at nested repositories and reads their pin, checkout moves or leaves nested repositories, `oxy submodule add|update|status|remove`; Git import/export of gitlinks and `.gitmodules` (test with a mirror of tetriblox, never the live repository).
44 +
- [x] Subrepositories (spec §7.8, §23): node kind `subrepo`, pin encoding, `.oxymodules`, snapshot stops at nested repositories and reads their pin, checkout moves or leaves nested repositories, `oxy submodule add|update|status|remove`; Git import/export of gitlinks and `.gitmodules` (test with a mirror of tetriblox, never the live repository).
45 45
- [ ] Partial commits (spec §12.3): `oxy commit PATH..` and `oxy commit --interactive` (hunk = one edit or tree operation, dependent hunks pulled in), sharing the split machinery.
46 46
- [ ] Blame (spec §27): `oxy blame PATH [--json]` from atom keys, with author and date; renames followed.
47 47
- [ ] Bisect (spec §27): `oxy bisect start|good|bad|run|reset` over dependency-closed prefixes; state under `.oxy/bisect/`.
…
25 unchanged lines
modified docs/spec.md +4 −3
…
301 unchanged lines
302 302
303 303
The mapping from path to remote lives in `.oxymodules` at the repository root, a tracked file of `[path]` sections with `url` and optional `bookmark` (Oxygen) or `branch` (Git) keys, edited by `oxy submodule add|remove` and readable by hand. A pin without a `.oxymodules` entry is checked out only when the nested repository is already present.
304 304
305 -
In the working copy a subrepo is a directory holding its own `.oxy/` (or `.git/`). The snapshot MUST NOT descend into it; it reads the nested repository's current bookmark heads (or Git `HEAD`) as the pin, so committing the parent records whatever the nested repository is at, as in Git. `oxy checkout` of the parent MUST move an existing nested repository to the pinned state (`oxy checkout` of those heads, or `git checkout --detach <sha>`), fetching from the recorded remote when objects are missing, and MUST leave an absent nested repository absent until `oxy submodule update --init` clones it. A nested working copy with uncommitted changes is never overwritten: the checkout reports it and continues.
305 +
In the working copy a subrepo is a directory holding its own `.oxy/` (or `.git/`). The snapshot MUST NOT descend into it; it reads the nested repository's current bookmark heads (or Git `HEAD`) as the pin, so committing the parent records whatever the nested repository is at, as in Git. A nested repository that is neither registered in `.oxymodules` nor tracked as a subrepo node is left alone entirely: not walked, not added. A tracked subrepo whose directory is absent keeps its recorded pin, as a Git gitlink does. `oxy checkout` of the parent MUST move an existing nested repository to the pinned state (`oxy checkout` of those heads, or `git checkout --detach <sha>`), fetching from the recorded remote when objects are missing, MUST leave an absent nested repository absent until `oxy submodule update --init` clones it, and MUST never delete a nested repository, even when the target state has no node for it. A nested working copy with uncommitted changes is never overwritten: the checkout reports it and continues.
306 306
307 -
`oxy submodule add URL PATH [--bookmark NAME]` clones URL into PATH, records `.oxymodules`, and adds the node pinned at the clone's heads. `oxy submodule update [--init] [PATH..]` brings nested repositories to their pins. `oxy submodule status` lists each subrepo with its pin and whether the nested repository matches it. `oxy submodule remove PATH` removes the node and the `.oxymodules` entry; the directory is left in place.
307 +
`oxy submodule add URL PATH [--bookmark NAME]` clones URL into PATH (an Oxygen remote when URL answers as one, otherwise Git), records `.oxymodules`, and the next snapshot adds the node pinned at the clone's heads. `oxy submodule update [--init] [PATH..]` brings nested repositories to their pins. `oxy submodule status` lists each subrepo with its pin and whether the nested repository matches it. `oxy submodule remove PATH` drops the `.oxymodules` entry and records that the node is to go with the next snapshot; the directory is left in place and thereafter ignored as unregistered.
308 308
309 309
## 8. Dependencies and commutation
310 310
…
496 unchanged lines
807 807
- **Import.** Each Git commit becomes one change whose edits are the diff between the union of its parents' states and its tree, so a merge commit becomes a resolution exactly when the parents conflict in the change model, and an ordinary change otherwise. The commit's author and hash are recorded as `Git-Author` and `Git-Commit` trailers; the author key is the zero KeyId, since nobody can sign on the original author's behalf. Submodule entries are skipped. Renames are detected on import exactly as for a snapshot (§7.1, similarity ≥ 50%), then tracked exactly. The Git commit's `Change-Id` trailer, if present and 128 bits, becomes the ChangeId; otherwise one is derived as BLAKE3-128 of the Git commit hash, so re-import is idempotent. Note that a change's own closure is what it depends on, not the Git snapshot: the imported bookmark's state equals the Git tree, but checking out an individual imported change yields only the files it and its dependencies touch.
808 808
- **Export.** Each state along a bookmark becomes a Git commit whose tree is the materialized state. The ChangeId is written as a `Change-Id:` trailer and the ChangeHash as `Oxy-Hash:`. Conflicted states are exported with §9.5 markers and an `Oxy-Conflicted: true` trailer. Signatures are not exported; Git commits MAY be additionally signed with the Ed25519 key via SSH signature format.
809 809
- **Fetch and push against a Git remote.** `oxy git fetch URL` keeps a bare mirror under `.oxy/git/` for the remote, fetches the branch into it, and imports the commits the bookmark does not have yet, seeding the import with the commits already known: an imported commit maps to its change, and a commit this repository exported maps to the bookmark state it was made from (so a Git commit on top of exported work is based on everything that work contained, including changes Git never saw). The bookmark moves to the union of its heads and the new changes, like a pull; the working copy follows when it was clean. A fetched state whose previous state was itself a Git tree is remembered as equal to the fetched tip. `oxy git push URL` refuses until the remote's tip is known, exports the bookmark's later states as commits chained onto that tip, and pushes the branch, so the push is always a fast-forward.
810 -
- **Submodules.** A Git gitlink entry (mode `160000`) imports as a `subrepo` node with a Git pin (§7.8) and the matching `.gitmodules` entry becomes a `.oxymodules` entry with `branch` set to the submodule's configured branch when there is one. Export writes a Git pin back as a gitlink and `.oxymodules` back as `.gitmodules`; an Oxygen pin has no Git form and is exported as a gitlink only when the nested repository has a Git mirror commit for that state (§23, fetch and push), otherwise as an empty directory with a warning.
810 +
- **Submodules.** A Git gitlink entry (mode `160000`) imports as a `subrepo` node with a Git pin (§7.8) and the matching `.gitmodules` entry becomes a `.oxymodules` entry with `branch` set to the submodule's configured branch when there is one. Export writes a Git pin back as a gitlink, keeps a tracked `.gitmodules` as is, and otherwise derives `.gitmodules` from `.oxymodules`; an Oxygen pin has no Git form and is omitted from the exported tree, which the export report counts.
811 811
- **Round trip.** Import followed by export MUST reproduce every tree hash. Export followed by import MUST reproduce every ChangeId.
812 812
813 813
## 24. Command-line interface
…
66 unchanged lines
880 880
| Change graph, commutation, conflicts and resolution (§6–§9) | Implemented, including order, delete/insert, tree, replace, and mode conflicts; workspace conflicts (§9.7) and exit status 3 are not detected |
881 881
| Rewrites (§8.4) | Implemented: describe, amend (also into older changes), split, abandon; orphaned references re-anchored |
882 882
| Bookmarks, per-workspace current bookmark, merge by union (§10.1) | Implemented |
883 +
| Subrepositories with Oxygen and Git pins, `.oxymodules`, Git gitlink import and export (§7.8, §23) | Implemented |
883 884
| Operation log, undo (§12.1) | Implemented; `redo` reserved |
884 885
| Snapshots and the stat cache (§12.3, §12.4) | Implemented; no file-system monitor yet |
885 886
| Storage: loose objects, packs, GC, checkpoints, batching (§11, §13, §21) | Implemented; pack indexes are read fully rather than mapped |
…
84 unchanged lines
modified src/cli/mod.rs +34 −0
…
142 unchanged lines
143 143
    },
144 144
    /// Union another bookmark's heads into the current one (no merge change)
145 145
    Merge { bookmark: String },
146 +
    /// Subrepositories: nested repositories pinned to a state
147 +
    Submodule {
148 +
        #[command(subcommand)]
149 +
        cmd: SubmoduleCmd,
150 +
    },
146 151
    /// Tags: immutable named states
147 152
    Tag {
148 153
        #[command(subcommand)]
…
96 unchanged lines
245 250
}
246 251
247 252
#[derive(Subcommand)]
253 +
enum SubmoduleCmd {
254 +
    /// Clone URL (Oxygen or Git) into PATH and register it in .oxymodules
255 +
    Add {
256 +
        url: String,
257 +
        path: String,
258 +
        /// Bookmark (Oxygen) or branch (Git) to check out and track
259 +
        #[arg(long)]
260 +
        bookmark: Option<String>,
261 +
    },
262 +
    /// Bring nested repositories to their pins
263 +
    Update {
264 +
        /// Clone nested repositories that are absent
265 +
        #[arg(long)]
266 +
        init: bool,
267 +
        paths: Vec<String>,
268 +
    },
269 +
    /// List subrepos with their pins and on-disk state
270 +
    Status,
271 +
    /// Unregister PATH; the directory stays, the node goes with the next commit
272 +
    Remove { path: String },
273 +
}
274 +
275 +
#[derive(Subcommand)]
248 276
enum TagCmd {
249 277
    /// Tag the state of CHANGE... (default: the bookmark heads)
250 278
    Create {
…
196 unchanged lines
447 475
            BookmarkCmd::Switch { name, discard } => workspace::bookmark_switch(&name, discard),
448 476
        },
449 477
        Cmd::Merge { bookmark } => workspace::merge(&bookmark),
478 +
        Cmd::Submodule { cmd } => match cmd {
479 +
            SubmoduleCmd::Add { url, path, bookmark } => sync::submodule_add(&url, &path, bookmark.as_deref()),
480 +
            SubmoduleCmd::Update { init, paths } => sync::submodule_update(init, &paths),
481 +
            SubmoduleCmd::Status => sync::submodule_status(),
482 +
            SubmoduleCmd::Remove { path } => sync::submodule_remove(&path),
483 +
        },
450 484
        Cmd::Tag { cmd } => match cmd {
451 485
            TagCmd::Create { name, changes, message } => workspace::tag_create(&name, &changes, &message),
452 486
            TagCmd::List => workspace::tag_list(),
…
43 unchanged lines
modified src/cli/sync.rs +74 −4
…
187 unchanged lines
188 188
            println!("  {} {}", ui::note(), ui::dim("run oxy checkout to update the working copy"));
189 189
        }
190 190
    }
191 -
    if r.skipped_submodules > 0 {
192 -
        println!("  {} {}", ui::note(), ui::dim(format!("skipped {} submodule entries", r.skipped_submodules)));
191 +
    if r.submodules > 0 {
192 +
        println!("  {} {}", ui::note(), ui::dim(format!("{} submodule {} imported as subrepos with Git pins", r.submodules, if r.submodules == 1 { "entry" } else { "entries" })));
193 193
    }
194 194
    Ok(0)
195 195
}
…
30 unchanged lines
226 226
        ui::path(repo.root().display()),
227 227
        ui::dim(format!("in {:.1?}; {} now has {}", started.elapsed(), repo.config.bookmark, plural(r.heads, "head")))
228 228
    );
229 -
    if r.skipped_submodules > 0 {
230 -
        println!("  {} {}", ui::note(), ui::dim(format!("skipped {} submodule entries", r.skipped_submodules)));
229 +
    if r.submodules > 0 {
230 +
        println!("  {} {}", ui::note(), ui::dim(format!("{} submodule {} imported as subrepos with Git pins", r.submodules, if r.submodules == 1 { "entry" } else { "entries" })));
231 +
    }
232 +
    Ok(0)
233 +
}
234 +
235 +
pub fn submodule_add(url: &str, path: &str, bookmark: Option<&str>) -> Result<i32> {
236 +
    let repo = open()?;
237 +
    let sp = ui::spinner(&format!("Cloning {url} into {path}…"));
238 +
    let pin = repo.submodule_add(url, path, bookmark)?;
239 +
    sp.done(&format!("{} subrepo {} {} {}", ui::done("Added"), ui::path(path), ui::dim(ui::glyph("→", "->")), ui::dim(pin.summary())));
240 +
    println!("  {} {}", ui::note(), ui::dim("recorded in .oxymodules; commit to pin it"));
241 +
    Ok(0)
242 +
}
243 +
244 +
pub fn submodule_update(init: bool, paths: &[String]) -> Result<i32> {
245 +
    let repo = open()?;
246 +
    let sp = ui::spinner("Updating subrepos…");
247 +
    let results = repo.submodule_update(init, paths)?;
248 +
    sp.clear();
249 +
    if results.is_empty() {
250 +
        println!("{}", ui::dim("No subrepos."));
251 +
    }
252 +
    let mut code = 0;
253 +
    for (path, r) in results {
254 +
        use oxy_core::repo::SubrepoSync::*;
255 +
        let line = match r {
256 +
            Updated => ui::done("Updated"),
257 +
            UpToDate => ui::ok(format!("{} up to date", ui::glyph("✔", "*"))),
258 +
            Missing => {
259 +
                code = 1;
260 +
                ui::warn(format!("{} missing (pass --init to clone)", ui::glyph("⚠", "!")))
261 +
            }
262 +
            Dirty => {
263 +
                code = 1;
264 +
                ui::warn(format!("{} has uncommitted changes; left as is", ui::glyph("⚠", "!")))
265 +
            }
266 +
            Failed(e) => {
267 +
                code = 1;
268 +
                ui::err(format!("{} {e}", ui::glyph("✖", "x")))
269 +
            }
270 +
        };
271 +
        println!("{line} {}", ui::path(path));
272 +
    }
273 +
    Ok(code)
274 +
}
275 +
276 +
pub fn submodule_status() -> Result<i32> {
277 +
    let repo = open()?;
278 +
    let subs = repo.subrepos()?;
279 +
    if subs.is_empty() {
280 +
        println!("{}", ui::dim("No subrepos."));
231 281
    }
282 +
    for s in subs {
283 +
        let state = match (&s.actual, s.dirty) {
284 +
            (None, _) => ui::warn(format!("{} missing", ui::glyph("⚠", "!"))),
285 +
            (Some(a), dirty) if *a == s.pin => {
286 +
                if dirty { ui::warn(format!("{} at pin, uncommitted changes", ui::glyph("✎", "~"))) } else { ui::ok(format!("{} at pin", ui::glyph("✔", "*"))) }
287 +
            }
288 +
            (Some(a), _) => ui::warn(format!("{} at {}", ui::glyph("↕", "<>"), a.summary())),
289 +
        };
290 +
        println!("{} {}{}{}{}{state}", ui::ok(ui::glyph("⧉", "#")), ui::path(&s.path), ui::sep(), ui::dim(s.pin.summary()), ui::sep());
291 +
        if let Some(u) = s.url {
292 +
            println!("    {} {}", ui::dim(ui::glyph("↳", "->")), ui::dim(u));
293 +
        }
294 +
    }
295 +
    Ok(0)
296 +
}
297 +
298 +
pub fn submodule_remove(path: &str) -> Result<i32> {
299 +
    let repo = open()?;
300 +
    repo.submodule_remove(path)?;
301 +
    println!("{} subrepo {} {}", ui::done("Removed"), ui::path(path), ui::dim("(directory left in place; commit to record the removal)"));
232 302
    Ok(0)
233 303
}
234 304
…
11 unchanged lines
modified src/cli/workspace.rs +3 −0
…
195 unchanged lines
196 196
    let sp = ui::spinner(&format!("Checking out {spec}…"));
197 197
    let r = repo.checkout(&heads, discard, &format!("oxy checkout {spec}"))?;
198 198
    sp.done(&format!("{} on {}: {} written, {} removed", ui::done("Working copy"), ui::bookmark(&spec), plural(r.written, "file"), r.removed));
199 +
    for n in &r.notes {
200 +
        println!("  {} {n}", ui::note());
201 +
    }
199 202
    if r.conflicted {
200 203
        println!("  {} {}", ui::caution(), ui::warn("this state has conflicts; see oxy status"));
201 204
    }
…
371 unchanged lines