OXYGENHUB

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