OXYGENHUB

A subrepo put back after its removal is pinned in the change that registers it; oxy identity create needs no repository (closes #152, closes #144) 64dc5197f9df

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 5296559e1a92
When
· 2026-10-28 21:04 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
64dc5197f9dfe3311fc883ec24681ffb529300c8b6e22ca6a7e99892853c4b82
Depends on
28fc23d7706a 4275ecb110f5 4dee277c327a 6a81dbab471d 75167672ebcd 76740263089c 7f617ee9b3ad 84b9e9ac0ab4 8fd78eb52ad2 982864068a59 b5e98094eb8f c6aa5bd9d1c8 d4dbab97e948 fce44ae9ad29
Depended on by
642da2636de2
In bookmarks
main
In tags
v0.10.0 v0.11.0 v0.9.0
Edits
11 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +3 −0
…
3 unchanged lines
4 4
5 5
- **Added: oxy on Haiku (#154).** oxy builds and runs on Haiku R1/beta6 (x86_64) with HaikuPorts' Rust; `oxy-VERSION-haiku-x86_64.tar.gz` is on the releases page from 0.8.0, with its own `SHA256SUMS-haiku`. The README says how to install it and how to build it (`ZSTD_SYS_USE_PKG_CONFIG=1`).
6 6
7 +
- **Fixed: a subrepo put back after its removal is pinned in the change that registers it (#152).** `oxy submodule remove` leaves a marker so the next snapshot drops the node while the directory stays; the snapshot cleared it only after reading it, so when nothing looked at the working copy between the removal's commit and the subrepo's return, the change that registered it again in `.oxymodules` left it out and a second commit was needed. A marker is now dropped before it is read once the removal is recorded or the path is registered again.
8 +
- **Fixed: `oxy identity create` needs no repository (#144).** Outside one it makes a user identity: the secret and identity go to the oxy home's keys and the user file names it as the author, so a new user or machine sets up an identity first; a repository made or cloned afterwards signs with it. Only `--local` needs a repository, and inside one it is kept there too, as before.
9 +
7 10
## 0.8.0 (2026-10-27)
8 11
9 12
- **A server can add a note to a reference a push moved (#148).** `oxy-server`'s `Backend` has a `push_notes` step: each note is added to that accepted reference's result, which `oxy push` shows in place of "updated". OxygenHub will use it to say when a push moves the bookmark of a proposal that has already landed (oxygenhub#329).
…
293 unchanged lines
modified crates/oxy-core/src/repo/identity.rs +35 −0
…
17 unchanged lines
18 18
    Invalid(String),
19 19
}
20 20
21 +
/// Make a user identity with no repository (oxygen#144), so a first
22 +
/// identity comes before any repository: its secret and a copy of it go to
23 +
/// this home's keys, and the user file names it as the author, as `oxy
24 +
/// identity create` does inside one. A repository reads it from the keys
25 +
/// when it first signs with it. `name` and `email` default to the user
26 +
/// file's author, then to `$USER` and `OXY_AUTHOR_*`.
27 +
pub fn create_user_identity(
28 +
    name: Option<&str>,
29 +
    email: Option<&str>,
30 +
    expires: Option<u64>,
31 +
) -> Result<(KeyId, Identity)> {
32 +
    let user = default_user_config();
33 +
    let defaults = Config::load(Some(&user), Path::new(""))?;
34 +
    let name = name.map(str::to_string).unwrap_or(defaults.author_name);
35 +
    let email = email.map(str::to_string).unwrap_or(defaults.author_email);
36 +
    let sk = SecretKey::generate();
37 +
    let mut id = sk.identity(&name, &email, Hlc::now().wall_ms);
38 +
    id.expires = expires;
39 +
    let key = id.hash();
40 +
    let keys = default_keys_dir();
41 +
    fs::create_dir_all(&keys)?;
42 +
    crate::durable::write_atomic_private(
43 +
        &keys.join(format!("{}.secret", key.to_hex())),
44 +
        &sk.encode(&key),
45 +
    )?;
46 +
    write_atomic(
47 +
        &keys.join(format!("{}.identity", key.to_hex())),
48 +
        &id.encode(),
49 +
    )?;
50 +
    Config::set_user(&user, "author.name", &id.name)?;
51 +
    Config::set_user(&user, "author.email", &id.email)?;
52 +
    Config::set_user(&user, "author.key", &key.to_hex())?;
53 +
    Ok((key, id))
54 +
}
55 +
21 56
/// The outcome of replacing the configured identity (spec §14.2).
22 57
#[derive(Clone, Debug)]
23 58
pub struct Rotation {
…
756 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +12 −5
…
142 unchanged lines
143 143
pub use git::{GitExportReport, GitImportReport, GitVerifyReport};
144 144
pub use hooks::{json_array, json_str, HookOutcome};
145 145
pub use hosted::FileEdit;
146 -
pub use identity::{Rotation, SignatureStatus};
146 +
pub use identity::{create_user_identity, Rotation, SignatureStatus};
147 147
pub use inspect::{BisectStep, BlameLine, Description};
148 148
pub use keyless::home_key;
149 149
pub use ops::{RestoreReport, RevertReport, Undone, ViewChange};
…
2068 unchanged lines
2218 2218
            }
2219 2219
            FsEntry::new(NodeKind::Subrepo, false, pin.encode())
2220 2220
        };
2221 -
        let removed = self.removed_subrepos();
2222 -
        for rel in &removed {
2223 -
            if tracked.get(rel).map(|(k, _)| *k) != Some(NodeKind::Subrepo) {
2221 +
        // A removal's marker is spent once the removal is recorded (no
2222 +
        // subrepo node is tracked there) or the path is registered again:
2223 +
        // dropped before it is read, or the change registering it again
2224 +
        // would leave it out (oxygen#152).
2225 +
        let mut removed = self.removed_subrepos();
2226 +
        removed.retain(|rel| {
2227 +
            let spent = tracked.get(rel).map(|(k, _)| *k) != Some(NodeKind::Subrepo)
2228 +
                || modules.entries.contains_key(rel);
2229 +
            if spent {
2224 2230
                self.clear_removed_subrepo(rel);
2225 2231
            }
2226 -
        }
2232 +
            !spent
2233 +
        });
2227 2234
        let mut untracked_nested = Vec::new();
2228 2235
        for dir in nested {
2229 2236
            let rel = self.rel_path(&dir);
…
577 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +28 −0
…
4975 unchanged lines
4976 4976
        !clone.snapshot("status").unwrap().changed,
4977 4977
        "an unregistered nested repository is ignored"
4978 4978
    );
4979 +
4980 +
    // Registered again, it is pinned again; removed and committed, then
4981 +
    // registered again with no snapshot between, the change that adds it
4982 +
    // back pins it too: the removal's marker is spent (oxygen#152).
4983 +
    let modules = format!("[libs/lib]\nurl = {lib_url}\n");
4984 +
    let pinned = |c: ChangeHash| {
4985 +
        clone
4986 +
            .load_state(&[c])
4987 +
            .unwrap()
4988 +
            .materialize()
4989 +
            .files
4990 +
            .get("libs/lib")
4991 +
            .map(|n| n.kind)
4992 +
    };
4993 +
    write(&clone_dir, ".oxymodules", modules.as_bytes());
4994 +
    let c = clone.commit("lib again", &[]).unwrap();
4995 +
    assert_eq!(pinned(c), Some(NodeKind::Subrepo));
4996 +
    clone.submodule_remove("libs/lib").unwrap();
4997 +
    let c = clone.commit("drop lib again", &[]).unwrap();
4998 +
    assert_eq!(pinned(c), None);
4999 +
    write(&clone_dir, ".oxymodules", modules.as_bytes());
5000 +
    let c = clone.commit("lib back", &[]).unwrap();
5001 +
    assert_eq!(
5002 +
        pinned(c),
5003 +
        Some(NodeKind::Subrepo),
5004 +
        "pinned in the change that registers it"
5005 +
    );
5006 +
    assert!(!clone.snapshot("status").unwrap().changed);
4979 5007
}
4980 5008
4981 5009
#[test]
…
5798 unchanged lines
modified src/cli/identity.rs +36 −1
…
154 unchanged lines
155 155
    local: bool,
156 156
    expires: Option<u64>,
157 157
) -> Result<i32> {
158 -
    let mut repo = open()?;
158 +
    // A first identity comes before any repository (oxygen#144): only
159 +
    // --local needs one; inside one, it is kept there too.
160 +
    let mut repo = match open() {
161 +
        Ok(r) => r,
162 +
        Err(e) if local => return Err(e),
163 +
        Err(_) => {
164 +
            let (key, id) = repo::create_user_identity(
165 +
                name.as_deref(),
166 +
                email.as_deref(),
167 +
                expires.and_then(expiry_from_days),
168 +
            )?;
169 +
            println!(
170 +
                "{} identity {} <{}>",
171 +
                ui::done("Created"),
172 +
                ui::author(&id.name),
173 +
                id.email
174 +
            );
175 +
            println!("{}", ui::kv("key", key));
176 +
            println!(
177 +
                "{}",
178 +
                ui::kv("secret", ui::path(repo::default_keys_dir().display()))
179 +
            );
180 +
            println!(
181 +
                "{}",
182 +
                ui::kv(
183 +
                    "scope",
184 +
                    format!(
185 +
                        "default author in {}",
186 +
                        ui::path(repo::default_user_config().display())
187 +
                    )
188 +
                )
189 +
            );
190 +
            println!("  {} {}", ui::note(), ui::dim("changes in every repository carry this author; run oxy config --user core.sign change to sign each commit"));
191 +
            return Ok(0);
192 +
        }
193 +
    };
159 194
    let name = name.unwrap_or_else(|| repo.config.author_name.clone());
160 195
    let email = email.unwrap_or_else(|| repo.config.author_email.clone());
161 196
    let scope = if local {
…
792 unchanged lines
modified tests/cli_json.rs +49 −0
…
256 unchanged lines
257 257
258 258
/// `oxy commit -F`, `oxy describe` naming and tags as changes (feedback
259 259
/// 4 and 6).
260 +
/// oxygen#144: a first identity needs no repository; the next repository
261 +
/// signs as it.
260 262
#[test]
263 +
fn a_first_identity_needs_no_repository() {
264 +
    let tmp = tempfile::tempdir().unwrap();
265 +
    let home = tmp.path().join("home");
266 +
    let nowhere = tmp.path().join("nowhere");
267 +
    std::fs::create_dir_all(&nowhere).unwrap();
268 +
    let local = ["identity", "create", "--local"];
269 +
    let (code, _, err) = oxy(&nowhere, &home, &local);
270 +
    assert_ne!(code, 0, "--local needs a repository");
271 +
    assert!(err.contains("no oxy repository"), "{err}");
272 +
    let ident = [
273 +
        "identity",
274 +
        "create",
275 +
        "--name",
276 +
        "Ada",
277 +
        "--email",
278 +
        "[email protected]",
279 +
    ];
280 +
    let (code, out, err) = oxy(&nowhere, &home, &ident);
281 +
    assert_eq!(code, 0, "{err}");
282 +
    let out = String::from_utf8_lossy(&out);
283 +
    assert!(
284 +
        out.contains("Created identity Ada <[email protected]>"),
285 +
        "{out}"
286 +
    );
287 +
    let user = oxy_core::repo::read_config_file(&home.join("config.toml")).unwrap();
288 +
    let key = user["author.key"].clone();
289 +
    assert_eq!(user["author.name"], "Ada");
290 +
    assert!(home.join("keys").join(format!("{key}.secret")).exists());
291 +
    assert!(!nowhere.join(".oxy").exists(), "no repository made");
292 +
293 +
    // A repository made afterwards signs its changes with it.
294 +
    let dir = tmp.path().join("r");
295 +
    std::fs::create_dir_all(&dir).unwrap();
296 +
    assert_eq!(oxy(&dir, &home, &["init"]).0, 0);
297 +
    assert_eq!(oxy(&dir, &home, &["config", "core.sign", "change"]).0, 0);
298 +
    write(&dir, "a.txt", "a\n");
299 +
    let (code, _, err) = oxy(&dir, &home, &["commit", "-m", "First"]);
300 +
    assert_eq!(code, 0, "{err}");
301 +
    let log = json(&dir, &home, &["log", "--json", "-n", "2"]);
302 +
    let first = &log["changes"][1];
303 +
    assert_eq!(first["message"], "First");
304 +
    assert_eq!(first["signature"]["status"], "signed", "{first}");
305 +
    assert_eq!(first["signature"]["key"], key.as_str());
306 +
    assert_eq!(first["signature"]["name"], "Ada");
307 +
}
308 +
309 +
#[test]
261 310
fn commit_from_a_file_and_describe_for_builds() {
262 311
    let tmp = tempfile::tempdir().unwrap();
263 312
    let home = tmp.path().join("home");
…
449 unchanged lines