OXYGENHUB

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