A subrepo at its pinned state is unmodified on any bookmark (closes #147) b5e98094eb8f
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 0358d6693a8f
- When
- · 2026-10-26 17:29 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
b5e98094eb8fc7123b7331db9a051e6deb771db299e8ab60b6d3e5312d1e6dd3- Depends on
- 4dee277c327a 72484195899a 75167672ebcd 767ac6cd0545 7b9809e67c3d 80b069facc69 8fd78eb52ad2 982864068a59 e4595038502a
- Depended on by
- 64dc5197f9df 7a3cc5149a14
- In bookmarks
- main subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.8.0 v0.9.0
- Edits
- 11 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **A subrepo at its pinned state is unmodified on any bookmark (#147).** A subrepo whose heads were exactly its pin's but which sat on another bookmark than the pin named counted as modified, so `oxy bookmark switch` refused ("the working copy has changes"). Only the state counts now: the same repository at the same heads, or the same Git commit; the recorded pin stands. |
|
| 6 | + | ||
| 5 | 7 | - **A shallow clone fetches its checkpoint in parts (#137).** The checkpoint of a big state came as one answer, the whole state's graph blobs in one body (about 2 GB for the VS Code history), built and held whole on both sides. A server now answers `checkpoint-index` (the checkpoint and its history, no graphs), and the client fetches the graph blobs it lacks with `objects`, 2,048 at a time; `objects` reads them in pack order. An older server answers 404 and gets the one-piece request as before. |
|
| 6 | 8 | ||
| 7 | 9 | - **A keyless agent follows its owner's newest identity (#82).** Every delegation rotates its owner's identity, so the `author.key` an agent's home was given goes stale, and its changes named an identity two rotations old (verifying only because a grant from before a rotation still counts). With `author.for = ACCOUNT` in the home, the hub sends the account's newest linked identity with each certificate, with its chain back to the grant's key; oxy keeps it in the home (`keys/author.for`), stores the chain beside the change so the grant checks, and authors as it from then on. A repository's own `author.key` pin still wins. |
|
| … | 283 unchanged lines |
modified crates/oxy-core/src/repo/bookmarks.rs +1 −1
| … | 497 unchanged lines |
||
| 498 | 498 | .get(p) |
|
| 499 | 499 | .and_then(|m| m.bookmark.clone()); |
|
| 500 | 500 | super::subrepos::read_pin(&self.root.join(p), hint.as_deref()) |
|
| 501 | - | .is_ok_and(|have| have == want) |
|
| 501 | + | .is_ok_and(|have| have.same_state(&want)) |
|
| 502 | 502 | }; |
|
| 503 | 503 | touched.retain(|p| !at_target_pin(p)); |
|
| 504 | 504 | let same = |p: &String| { |
|
| … | 210 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +20 −5
| … | 2200 unchanged lines |
||
| 2201 | 2201 | // already tracked as subrepo nodes, they contribute their pin; |
|
| 2202 | 2202 | // unregistered ones are left alone. |
|
| 2203 | 2203 | let modules = self.modules_on_disk(); |
|
| 2204 | + | // A subrepo at the state its recorded pin names is unmodified, |
|
| 2205 | + | // whatever bookmark it sits on (#147): the recorded pin stands. |
|
| 2206 | + | let subrepo_entry = |rel: &str, pin: crate::subrepo::Pin| -> FsEntry { |
|
| 2207 | + | if let Some((NodeKind::Subrepo, Some(b))) = tracked.get(rel) { |
|
| 2208 | + | let recorded = self |
|
| 2209 | + | .read_blob(b) |
|
| 2210 | + | .ok() |
|
| 2211 | + | .and_then(|x| crate::subrepo::Pin::decode(&x).ok()); |
|
| 2212 | + | if recorded.is_some_and(|r| r.same_state(&pin)) { |
|
| 2213 | + | return FsEntry { |
|
| 2214 | + | blob: Some(*b), |
|
| 2215 | + | ..FsEntry::new(NodeKind::Subrepo, false, vec![]) |
|
| 2216 | + | }; |
|
| 2217 | + | } |
|
| 2218 | + | } |
|
| 2219 | + | FsEntry::new(NodeKind::Subrepo, false, pin.encode()) |
|
| 2220 | + | }; |
|
| 2204 | 2221 | let removed = self.removed_subrepos(); |
|
| 2205 | 2222 | for rel in &removed { |
|
| 2206 | 2223 | if tracked.get(rel).map(|(k, _)| *k) != Some(NodeKind::Subrepo) { |
|
| … | 13 unchanged lines |
||
| 2220 | 2237 | continue; |
|
| 2221 | 2238 | } |
|
| 2222 | 2239 | let pin = subrepos::read_pin(&dir, registered.and_then(|m| m.bookmark.as_deref()))?; |
|
| 2223 | - | out.insert(rel, FsEntry::new(NodeKind::Subrepo, false, pin.encode())); |
|
| 2240 | + | let entry = subrepo_entry(&rel, pin); |
|
| 2241 | + | out.insert(rel, entry); |
|
| 2224 | 2242 | } |
|
| 2225 | 2243 | untracked_nested.sort(); |
|
| 2226 | 2244 | *self.untracked_nested.borrow_mut() = untracked_nested; |
|
| … | 14 unchanged lines |
||
| 2241 | 2259 | &path, |
|
| 2242 | 2260 | modules.entries.get(rel).and_then(|m| m.bookmark.as_deref()), |
|
| 2243 | 2261 | )?; |
|
| 2244 | - | out.insert( |
|
| 2245 | - | rel.clone(), |
|
| 2246 | - | FsEntry::new(NodeKind::Subrepo, false, pin.encode()), |
|
| 2247 | - | ); |
|
| 2262 | + | out.insert(rel.clone(), subrepo_entry(rel, pin)); |
|
| 2248 | 2263 | } else if let Some(b) = blob { |
|
| 2249 | 2264 | // Not cloned (or emptied): the pin stays as recorded, as a |
|
| 2250 | 2265 | // Git gitlink does when its directory is absent. |
|
| … | 541 unchanged lines |
modified crates/oxy-core/src/repo/subrepos.rs +1 −10
| … | 181 unchanged lines |
||
| 182 | 182 | } |
|
| 183 | 183 | } |
|
| 184 | 184 | let actual = read_pin(&dir, module.as_ref().and_then(|m| m.bookmark.as_deref()))?; |
|
| 185 | - | let same = match (pin, &actual) { |
|
| 186 | - | (Pin::Oxy { heads: a, .. }, Pin::Oxy { heads: b, .. }) => { |
|
| 187 | - | let (mut a, mut b) = (a.clone(), b.clone()); |
|
| 188 | - | a.sort(); |
|
| 189 | - | b.sort(); |
|
| 190 | - | a == b |
|
| 191 | - | } |
|
| 192 | - | (Pin::Git { sha: a, .. }, Pin::Git { sha: b, .. }) => a == b, |
|
| 193 | - | _ => false, |
|
| 194 | - | }; |
|
| 185 | + | let same = pin.same_state(&actual); |
|
| 195 | 186 | if same { |
|
| 196 | 187 | return Ok(if cloned { |
|
| 197 | 188 | SubrepoSync::Updated |
|
| … | 242 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +57 −0
| … | 10426 unchanged lines |
||
| 10427 | 10427 | assert!(format!("{:?}", e.unwrap_err()).contains("does not exist")); |
|
| 10428 | 10428 | } |
|
| 10429 | 10429 | ||
| 10430 | + | /// oxygen#147: a subrepo at its pinned state is unmodified whatever |
|
| 10431 | + | /// bookmark it sits on, so the parent's status is clean and a bookmark |
|
| 10432 | + | /// switch goes ahead; a new state in it is still a change. |
|
| 10433 | + | #[test] |
|
| 10434 | + | fn a_subrepo_at_its_pinned_state_on_another_bookmark_is_unmodified() { |
|
| 10435 | + | hermetic(); |
|
| 10436 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 10437 | + | let root = dir.path(); |
|
| 10438 | + | let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap(); |
|
| 10439 | + | let lib_url = root.join("lib-origin").to_string_lossy().to_string(); |
|
| 10440 | + | let lib = Repo::clone(&lib_url, &root.join("lib")) |
|
| 10441 | + | .unwrap() |
|
| 10442 | + | .with_home(root.join("home")); |
|
| 10443 | + | write(&root.join("lib"), "lib.txt", b"v1\n"); |
|
| 10444 | + | lib.commit("lib v1", &[]).unwrap(); |
|
| 10445 | + | assert!(lib.push("origin", false).unwrap()[0].ok); |
|
| 10446 | + | let _app_origin = Repo::init(&root.join("app-origin")).unwrap(); |
|
| 10447 | + | let app_dir = root.join("app"); |
|
| 10448 | + | let mut app = Repo::clone(&root.join("app-origin").to_string_lossy(), &app_dir) |
|
| 10449 | + | .unwrap() |
|
| 10450 | + | .with_home(root.join("home")); |
|
| 10451 | + | write(&app_dir, "main.txt", b"app\n"); |
|
| 10452 | + | app.submodule_add(&lib_url, "libs/lib", None).unwrap(); |
|
| 10453 | + | let a1 = app.commit("app with lib", &[]).unwrap(); |
|
| 10454 | + | ||
| 10455 | + | // The nested repository moves to another bookmark at the same heads. |
|
| 10456 | + | let mut nested = Repo::open(&app_dir.join("libs/lib")) |
|
| 10457 | + | .unwrap() |
|
| 10458 | + | .with_home(root.join("home")); |
|
| 10459 | + | let heads = nested.bookmark_heads().unwrap(); |
|
| 10460 | + | nested.bookmark_create("feature", Some(&heads)).unwrap(); |
|
| 10461 | + | nested.bookmark_switch("feature", false).unwrap(); |
|
| 10462 | + | let snap = app.snapshot("status").unwrap(); |
|
| 10463 | + | assert!( |
|
| 10464 | + | !snap.changed, |
|
| 10465 | + | "only the bookmark differs: {:?}", |
|
| 10466 | + | snap.statuses |
|
| 10467 | + | ); |
|
| 10468 | + | app.bookmark_create("other", Some(&[a1])).unwrap(); |
|
| 10469 | + | app.bookmark_switch("other", false).unwrap(); |
|
| 10470 | + | ||
| 10471 | + | // A new state in it is a change, as before. |
|
| 10472 | + | let nested = Repo::open(&app_dir.join("libs/lib")) |
|
| 10473 | + | .unwrap() |
|
| 10474 | + | .with_home(root.join("home")); |
|
| 10475 | + | write(&app_dir.join("libs/lib"), "lib.txt", b"v2\n"); |
|
| 10476 | + | nested.commit("lib v2", &[]).unwrap(); |
|
| 10477 | + | let snap = app.snapshot("status").unwrap(); |
|
| 10478 | + | assert_eq!( |
|
| 10479 | + | snap.statuses |
|
| 10480 | + | .iter() |
|
| 10481 | + | .map(|s| (s.path.as_str(), s.status)) |
|
| 10482 | + | .collect::<Vec<_>>(), |
|
| 10483 | + | vec![("libs/lib", ChangeStatus::Modified)] |
|
| 10484 | + | ); |
|
| 10485 | + | } |
|
| 10486 | + | ||
| 10430 | 10487 | /// oxygenhub#249: a handle reads the operation head once and keeps it, |
|
| 10431 | 10488 | /// so one command or request never mixes two heads; another handle's |
|
| 10432 | 10489 | /// operation shows only after `refresh`. Its own operations move it, and |
|
| … | 204 unchanged lines |
modified crates/oxy-core/src/subrepo.rs +30 −0
| … | 25 unchanged lines |
||
| 26 | 26 | } |
|
| 27 | 27 | ||
| 28 | 28 | impl Pin { |
|
| 29 | + | /// Whether two pins name the same state: the same repository at the |
|
| 30 | + | /// same heads, in any order, or the same Git commit. The bookmark or |
|
| 31 | + | /// branch each names does not count (#147): a subrepo at its pinned |
|
| 32 | + | /// state is at it whatever bookmark it sits on. |
|
| 33 | + | pub fn same_state(&self, other: &Pin) -> bool { |
|
| 34 | + | match (self, other) { |
|
| 35 | + | ( |
|
| 36 | + | Pin::Oxy { |
|
| 37 | + | repo: r1, |
|
| 38 | + | heads: h1, |
|
| 39 | + | .. |
|
| 40 | + | }, |
|
| 41 | + | Pin::Oxy { |
|
| 42 | + | repo: r2, |
|
| 43 | + | heads: h2, |
|
| 44 | + | .. |
|
| 45 | + | }, |
|
| 46 | + | ) => { |
|
| 47 | + | let (mut a, mut b) = (h1.clone(), h2.clone()); |
|
| 48 | + | a.sort(); |
|
| 49 | + | a.dedup(); |
|
| 50 | + | b.sort(); |
|
| 51 | + | b.dedup(); |
|
| 52 | + | r1 == r2 && a == b |
|
| 53 | + | } |
|
| 54 | + | (Pin::Git { sha: a, .. }, Pin::Git { sha: b, .. }) => a == b, |
|
| 55 | + | _ => false, |
|
| 56 | + | } |
|
| 57 | + | } |
|
| 58 | + | ||
| 29 | 59 | pub fn encode(&self) -> Vec<u8> { |
|
| 30 | 60 | let v = match self { |
|
| 31 | 61 | Pin::Oxy { |
|
| … | 278 unchanged lines |