submodule status and doctor before the first commit e7ac6c2e84e4
A subrepo registered in .oxymodules but not committed yet is listed by submodule status as added, with the pin the next commit records (committed: false in --json); it was left out until the first commit. Doctor no longer fails a user identity that a new repository has not stored yet: with its secret key present, it says the identity is stored with the first signed change. closes #19
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 02:00 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
e7ac6c2e84e4e0e57494d6632788c39a80d63326f6e07baf9630a9f6eaea41bc- Depends on
- 1c2bd9abdd4f 3e5772ccf9da 4dee277c327a 6a81dbab471d 75036530b621 75167672ebcd 86fe29fd62ea 982864068a59 c6aa5bd9d1c8 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 4476cdbb0443 7993d465675b 8ab265dc4a51 ed5b9ef990a3
- 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
- 11 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/oxy-core/src/repo/doctor.rs +19 −0
| … | 132 unchanged lines |
||
| 133 | 133 | }; |
|
| 134 | 134 | let id = match self.identity(&key) { |
|
| 135 | 135 | Ok(id) => id, |
|
| 136 | + | // Before the first commit the identity object is stored nowhere |
|
| 137 | + | // yet; with its secret key at hand that is normal. |
|
| 138 | + | Err(_) |
|
| 139 | + | if self |
|
| 140 | + | .keys_dir |
|
| 141 | + | .join(format!("{}.secret", key.to_hex())) |
|
| 142 | + | .is_file() => |
|
| 143 | + | { |
|
| 144 | + | rep.push( |
|
| 145 | + | "identity", |
|
| 146 | + | Health::Ok, |
|
| 147 | + | format!( |
|
| 148 | + | "identity {} has its secret key; it is stored in the repository with your first signed change", |
|
| 149 | + | key.short(12) |
|
| 150 | + | ), |
|
| 151 | + | None, |
|
| 152 | + | ); |
|
| 153 | + | return; |
|
| 154 | + | } |
|
| 136 | 155 | Err(e) => { |
|
| 137 | 156 | rep.push( |
|
| 138 | 157 | "identity", |
|
| … | 186 unchanged lines |
modified crates/oxy-core/src/repo/subrepos.rs +24 −0
| … | 24 unchanged lines |
||
| 25 | 25 | /// The nested repository's own pin now, if present. |
|
| 26 | 26 | pub actual: Option<Pin>, |
|
| 27 | 27 | pub dirty: bool, |
|
| 28 | + | /// Whether the working copy's base records it. An added subrepo |
|
| 29 | + | /// (registered in `.oxymodules`, not committed yet) has `pin` set to |
|
| 30 | + | /// what the next commit records. |
|
| 31 | + | pub committed: bool, |
|
| 28 | 32 | } |
|
| 29 | 33 | ||
| 30 | 34 | fn git_out(dir: &Path, args: &[&str]) -> Result<String> { |
|
| … | 200 unchanged lines |
||
| 231 | 235 | url: modules.entries.get(path).map(|m| m.url.clone()), |
|
| 232 | 236 | actual, |
|
| 233 | 237 | dirty, |
|
| 238 | + | committed: true, |
|
| 239 | + | }); |
|
| 240 | + | } |
|
| 241 | + | // Registered but not committed yet: pinned at what they are now. |
|
| 242 | + | for (path, m) in &modules.entries { |
|
| 243 | + | if snap.files.contains_key(path) { |
|
| 244 | + | continue; |
|
| 245 | + | } |
|
| 246 | + | let dir = self.root.join(path); |
|
| 247 | + | let Ok(pin) = read_pin(&dir, m.bookmark.as_deref()) else { |
|
| 248 | + | continue; |
|
| 249 | + | }; |
|
| 250 | + | out.push(SubrepoInfo { |
|
| 251 | + | path: path.clone(), |
|
| 252 | + | pin: pin.clone(), |
|
| 253 | + | url: Some(m.url.clone()), |
|
| 254 | + | actual: Some(pin), |
|
| 255 | + | dirty: nested_dirty(&dir).unwrap_or(false), |
|
| 256 | + | committed: false, |
|
| 234 | 257 | }); |
|
| 235 | 258 | } |
|
| 259 | + | out.sort_by(|a, b| a.path.cmp(&b.path)); |
|
| 236 | 260 | Ok(out) |
|
| 237 | 261 | } |
|
| 238 | 262 | ||
| … | 112 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +48 −0
| … | 5152 unchanged lines |
||
| 5153 | 5153 | assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"a edited\n"); |
|
| 5154 | 5154 | assert!(!root.join("b.txt").exists(), "h1 has no b.txt"); |
|
| 5155 | 5155 | } |
|
| 5156 | + | ||
| 5157 | + | /// Doctor and subrepos before the first commit (feedback 5 and 8): a |
|
| 5158 | + | /// user identity not stored in a new repository yet is fine while its |
|
| 5159 | + | /// secret key is there; an added subrepo is listed as not committed yet. |
|
| 5160 | + | #[test] |
|
| 5161 | + | fn a_new_repository_is_healthy_before_its_first_commit() { |
|
| 5162 | + | hermetic(); |
|
| 5163 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 5164 | + | let root = dir.path(); |
|
| 5165 | + | let home = root.join("home"); |
|
| 5166 | + | let mut first = Repo::init(&root.join("first")) |
|
| 5167 | + | .unwrap() |
|
| 5168 | + | .with_home(home.clone()); |
|
| 5169 | + | let (key, _) = first |
|
| 5170 | + | .create_identity("Ada", "[email protected]", IdentityScope::User) |
|
| 5171 | + | .unwrap(); |
|
| 5172 | + | let repo = Repo::init(&root.join("second")) |
|
| 5173 | + | .unwrap() |
|
| 5174 | + | .with_home(home.clone()); |
|
| 5175 | + | // Only the secret key: no identity object anywhere this repository sees. |
|
| 5176 | + | let keys = repo.keys_dir.clone(); |
|
| 5177 | + | let _ = fs::remove_file(keys.join(format!("{}.identity", key.to_hex()))); |
|
| 5178 | + | assert!(keys.join(format!("{}.secret", key.to_hex())).is_file()); |
|
| 5179 | + | let r = repo.doctor(true); |
|
| 5180 | + | let id = r.checks.iter().find(|c| c.area == "identity").unwrap(); |
|
| 5181 | + | assert_eq!(id.health, Health::Ok, "{}", id.message); |
|
| 5182 | + | assert!(id.message.contains("first signed change"), "{}", id.message); |
|
| 5183 | + | ||
| 5184 | + | // A subrepo added and not committed yet is listed, pinned where it is. |
|
| 5185 | + | let lib = root.join("lib"); |
|
| 5186 | + | let l = Repo::init(&lib).unwrap().with_home(home.clone()); |
|
| 5187 | + | write(&lib, "x.txt", b"x\n"); |
|
| 5188 | + | l.commit("x", &[]).unwrap(); |
|
| 5189 | + | let second = root.join("second"); |
|
| 5190 | + | write(&second, "a.txt", b"a\n"); |
|
| 5191 | + | repo.submodule_add(&lib.to_string_lossy(), "deps/lib", None) |
|
| 5192 | + | .unwrap(); |
|
| 5193 | + | let subs = repo.subrepos().unwrap(); |
|
| 5194 | + | assert_eq!(subs.len(), 1); |
|
| 5195 | + | assert_eq!( |
|
| 5196 | + | (subs[0].path.as_str(), subs[0].committed), |
|
| 5197 | + | ("deps/lib", false) |
|
| 5198 | + | ); |
|
| 5199 | + | assert_eq!(subs[0].actual.as_ref(), Some(&subs[0].pin)); |
|
| 5200 | + | repo.commit("add lib", &[]).unwrap(); |
|
| 5201 | + | let subs = repo.subrepos().unwrap(); |
|
| 5202 | + | assert!(subs[0].committed, "committed now"); |
|
| 5203 | + | } |
modified docs/backlog-overnight-2.md +1 −1
| … | 76 unchanged lines |
||
| 77 | 77 | - [x] M7 Server push errors name the operation and bookmark and log the error chain (feedback 2). |
|
| 78 | 78 | - [x] M8 `merge` updates a working copy that is clean against the pre-merge base; `checkout` tells "removed by you" from "not yet written"; `bookmark switch --help` says what happens to uncommitted work (feedback 3). |
|
| 79 | 79 | - [x] M9a `oxy describe` naming (tag, else nearest tag + distance + change, else bookmark + change, `-dirty`, `--json`), tags accepted where a change is and `tag list` showing heads (feedback 4); `commit -F` (6). |
|
| 80 | - | - [ ] M9b `submodule status` shows added-but-unpinned and has `--json` (5, 7); doctor before the first commit (8). |
|
| 80 | + | - [x] M9b `submodule status` shows added-but-unpinned and has `--json` (5, 7); doctor before the first commit (8). |
|
| 81 | 81 | - [ ] M10 Hub: say "signed by an unlinked key" on changes and in `/api/v1/user`, pointing at Settings (feedback 14). |
|
| 82 | 82 | - [ ] P1 **Conflict resolution on OxygenHub** (Andy's list, item 1). A resolver page for a conflicted state: |
|
| 83 | 83 | - Per conflict region: pick a side, several sides, or none; edit freely; tree conflicts too (keep a candidate). |
|
| … | 456 unchanged lines |
modified docs/spec.md +1 −1
| … | 305 unchanged lines |
||
| 306 | 306 | ||
| 307 | 307 | 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. |
|
| 308 | 308 | ||
| 309 | - | `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. |
|
| 309 | + | `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; one registered but not committed yet is listed as added, with the pin the next commit records (`committed: false` in `--json`). `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. |
|
| 310 | 310 | ||
| 311 | 311 | ## 8. Dependencies and commutation |
|
| 312 | 312 | ||
| … | 699 unchanged lines |
modified src/cli/sync.rs +4 −0
| … | 625 unchanged lines |
||
| 626 | 626 | ("actual", json::opt(s.actual.as_ref(), pin_json)), |
|
| 627 | 627 | ("at_pin", json::b(s.actual.as_ref() == Some(&s.pin))), |
|
| 628 | 628 | ("dirty", json::b(s.dirty)), |
|
| 629 | + | ("committed", json::b(s.committed)), |
|
| 629 | 630 | ("url", json::opt(s.url.as_deref(), json::s)), |
|
| 630 | 631 | ]) |
|
| 631 | 632 | })) |
|
| … | 5 unchanged lines |
||
| 637 | 638 | } |
|
| 638 | 639 | for s in subs { |
|
| 639 | 640 | let state = match (&s.actual, s.dirty) { |
|
| 641 | + | _ if !s.committed => { |
|
| 642 | + | ui::warn(format!("{} added, not committed yet", ui::glyph("+", "+"))) |
|
| 643 | + | } |
|
| 640 | 644 | (None, _) => ui::warn(format!("{} missing", ui::glyph("⚠", "!"))), |
|
| 641 | 645 | (Some(a), dirty) if *a == s.pin => { |
|
| 642 | 646 | if dirty { |
|
| … | 60 unchanged lines |