OXYGENHUB

A hosted change can move a subrepo's pin f2f91ceb0e9f

Repo::build_pin_change builds a change that moves the subrepo at a path to
a new pin, leaving everything else as it is; the path must already be a
subrepo. The hub uses it to move a consumer's pin for Diffusion
(oxygenhub#148).

closes #109
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 00:41 UTC
Signature
✔ signed by Andy McCall
Hash
f2f91ceb0e9f3b142481ddf2e851fa037beb14cc00a186b7cfe61bf45b030ba4
Depends on
84b9e9ac0ab4 982864068a59 aa4939e07a09 eae595568832 f7510b63cceb
Depended on by
767ac6cd0545
In bookmarks
carried-not-removed 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
In tags
v0.10.0 v0.11.0 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
7 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified crates/oxy-core/src/repo/hosted.rs +46 −1
…
25 unchanged lines
26 26
        message: &str,
27 27
        author: KeyId,
28 28
    ) -> Result<Change> {
29 +
        self.build_change_with(heads, edits, &[], message, author)
30 +
    }
31 +
32 +
    /// Build (but do not store or sign) a change that moves the subrepo at
33 +
    /// `path` in the state with `heads` to `pin`: a hub moving a consumer's
34 +
    /// pin for Diffusion (oxygenhub#148). The path must already be a
35 +
    /// subrepo; nothing else changes.
36 +
    pub fn build_pin_change(
37 +
        &self,
38 +
        heads: &[ChangeHash],
39 +
        path: &str,
40 +
        pin: &crate::subrepo::Pin,
41 +
        message: &str,
42 +
        author: KeyId,
43 +
    ) -> Result<Change> {
44 +
        self.build_change_with(
45 +
            heads,
46 +
            &[],
47 +
            &[(path.to_string(), pin.clone())],
48 +
            message,
49 +
            author,
50 +
        )
51 +
    }
52 +
53 +
    fn build_change_with(
54 +
        &self,
55 +
        heads: &[ChangeHash],
56 +
        edits: &[FileEdit],
57 +
        pins: &[(String, crate::subrepo::Pin)],
58 +
        message: &str,
59 +
        author: KeyId,
60 +
    ) -> Result<Change> {
29 61
        if message.trim().is_empty() {
30 62
            return fail("a change message is required");
31 63
        }
32 -
        if edits.is_empty() {
64 +
        if edits.is_empty() && pins.is_empty() {
33 65
            return fail("nothing to change");
34 66
        }
35 67
        let base = self.load_state(heads)?;
…
59 unchanged lines
95 127
                        }
96 128
                    }
97 129
                }
130 +
            }
131 +
        }
132 +
        for (path, pin) in pins {
133 +
            let path = path.trim_matches('/');
134 +
            match base_snapshot.files.get(path) {
135 +
                Some(f) if f.kind == NodeKind::Subrepo => {}
136 +
                Some(_) => return fail(format!("{path} is not a subrepo")),
137 +
                None => return fail(format!("{path} does not exist")),
98 138
            }
139 +
            entries.insert(
140 +
                path.to_string(),
141 +
                FsEntry::new(NodeKind::Subrepo, false, pin.encode()),
142 +
            );
99 143
        }
100 144
        // A path the user writes settles its replace conflict, if any: they
101 145
        // chose that content (spec §9.3).
…
1 unchanged lines
103 147
            .iter()
104 148
            .filter(|e| e.content.is_some())
105 149
            .map(|e| e.path.trim_matches('/').to_string())
150 +
            .chain(pins.iter().map(|(p, _)| p.trim_matches('/').to_string()))
106 151
            .collect();
107 152
        let built = self.build_from_entries_resolving(
108 153
            &base,
…
651 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +56 −0
…
8537 unchanged lines
8538 8538
        .to_string()
8539 8539
        .contains("no change matches"));
8540 8540
}
8541 +
8542 +
#[test]
8543 +
fn a_pin_change_moves_only_the_pin() {
8544 +
    hermetic();
8545 +
    let dir = tempfile::tempdir().unwrap();
8546 +
    let root = dir.path();
8547 +
    let _lib_origin = Repo::init(&root.join("lib-origin")).unwrap();
8548 +
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
8549 +
    let lib = Repo::clone(&lib_url, &root.join("lib"))
8550 +
        .unwrap()
8551 +
        .with_home(root.join("home"));
8552 +
    write(&root.join("lib"), "lib.txt", b"v1\n");
8553 +
    lib.commit("lib v1", &[]).unwrap();
8554 +
    assert!(lib.push("origin", false).unwrap()[0].ok);
8555 +
    let _app_origin = Repo::init(&root.join("app-origin")).unwrap();
8556 +
    let app_dir = root.join("app");
8557 +
    let app = Repo::clone(&root.join("app-origin").to_string_lossy(), &app_dir)
8558 +
        .unwrap()
8559 +
        .with_home(root.join("home"));
8560 +
    write(&app_dir, "main.txt", b"app\n");
8561 +
    let pin1 = app.submodule_add(&lib_url, "libs/lib", None).unwrap();
8562 +
    let a1 = app.commit("app with lib", &[]).unwrap();
8563 +
    // The library moves on.
8564 +
    write(&root.join("lib"), "more.txt", b"v2\n");
8565 +
    let l2 = lib.commit("lib v2", &[]).unwrap();
8566 +
    let crate::subrepo::Pin::Oxy { repo, bookmark, .. } = pin1 else {
8567 +
        panic!("an Oxygen pin")
8568 +
    };
8569 +
    let pin2 = crate::subrepo::Pin::Oxy {
8570 +
        repo,
8571 +
        heads: vec![l2],
8572 +
        bookmark,
8573 +
    };
8574 +
    let author = app.store().get_change(&a1).unwrap().author;
8575 +
    let change = app
8576 +
        .build_pin_change(&[a1], "libs/lib", &pin2, "Move lib to v2", author)
8577 +
        .unwrap();
8578 +
    let mut state = app.load_state(&[a1]).unwrap();
8579 +
    state.add(change).unwrap();
8580 +
    let files = state.materialize().files;
8581 +
    assert_eq!(files["libs/lib"].kind, NodeKind::Subrepo);
8582 +
    let FileContent::Opaque { blobs } = &files["libs/lib"].content else {
8583 +
        panic!("a pin is opaque")
8584 +
    };
8585 +
    let read = crate::subrepo::Pin::decode(&app.read_blob(&blobs[0]).unwrap()).unwrap();
8586 +
    assert_eq!(read, pin2, "the new pin");
8587 +
    assert!(
8588 +
        matches!(&files["main.txt"].content, FileContent::Text { bytes, .. } if bytes.as_slice() == b"app\n"),
8589 +
        "nothing else changes"
8590 +
    );
8591 +
    // Only a subrepo's pin can be moved this way.
8592 +
    let e = app.build_pin_change(&[a1], "main.txt", &pin2, "x", author);
8593 +
    assert!(format!("{:?}", e.unwrap_err()).contains("not a subrepo"));
8594 +
    let e = app.build_pin_change(&[a1], "libs/none", &pin2, "x", author);
8595 +
    assert!(format!("{:?}", e.unwrap_err()).contains("does not exist"));
8596 +
}