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 |