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))) = (¤t, 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 ¤t { |
|
| 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 |