OXYGENHUB

Bookmarks with / in their names push and checkpoint 54cd08a1e7bf

A reference attestation and a checkpoint pointer are files named after
their bookmark, and a name like feature/x made a path under a directory
nobody created, so pushing it failed with "No such file or directory".
Such files now use the name with / escaped (%2F, and % as %25): one flat
file each, so feature and feature/x live side by side. Tag attestations
use the same naming.

closes #12
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 21:38 UTC
Signature
✔ signed by Andy McCall
Hash
54cd08a1e7bf523e035abcab53826bdd7d0458a25a3e20c771f8e42ec4191356
Depends on
132b884b9a8d 252e8cb23fbc 982864068a59 cc7a00565161
Depended on by
d36ba2f79f95 767ac6cd0545 61e3bce7ea5b f8c695764c95
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical 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 switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 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
9 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/oxy-core/src/repo/checkpoint.rs +2 −2
…
11 unchanged lines
12 12
13 13
impl Repo {
14 14
    fn checkpoint_pointer(&self, bookmark: &str) -> PathBuf {
15 -
        self.oxy.join("checkpoints").join(bookmark)
15 +
        self.oxy.join("checkpoints").join(name_file(bookmark))
16 16
    }
17 17
18 18
    /// The newest checkpoint recorded for a bookmark.
…
149 unchanged lines
168 168
        let mut bookmarks: Vec<String> = vec![self.config.bookmark.clone()];
169 169
        if let Ok(rd) = fs::read_dir(self.oxy.join("checkpoints")) {
170 170
            for e in rd.flatten() {
171 -
                let name = e.file_name().to_string_lossy().to_string();
171 +
                let name = file_name_to_name(&e.file_name().to_string_lossy());
172 172
                if !bookmarks.contains(&name) {
173 173
                    bookmarks.push(name);
174 174
                }
…
51 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +12 −0
…
103 unchanged lines
104 104
pub use tags::TagInfo;
105 105
pub use workspace::{FileLock, SnapshotLock, WorkspaceInfo};
106 106
107 +
/// The file name under which per-bookmark or per-tag data is kept: `/`
108 +
/// (and `%`) escaped, so `feature/x` is one file beside `feature` rather
109 +
/// than a directory that would clash with it.
110 +
pub(crate) fn name_file(name: &str) -> String {
111 +
    name.replace('%', "%25").replace('/', "%2F")
112 +
}
113 +
114 +
/// The name a [`name_file`] file name stands for.
115 +
pub(crate) fn file_name_to_name(file: &str) -> String {
116 +
    file.replace("%2F", "/").replace("%25", "%")
117 +
}
118 +
107 119
fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
108 120
    Ok(crate::durable::write_atomic(path, data)?)
109 121
}
…
1453 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +1 −1
…
42 unchanged lines
43 43
    // ------------------------------------------------------------ serving (spec §16)
44 44
45 45
    fn ref_attest_path(&self, bookmark: &str) -> PathBuf {
46 -
        self.oxy.join("attest").join(bookmark)
46 +
        self.oxy.join("attest").join(name_file(bookmark))
47 47
    }
48 48
49 49
    /// The latest reference attestation stored for a bookmark.
…
1460 unchanged lines
modified crates/oxy-core/src/repo/tags.rs +1 −1
…
16 unchanged lines
17 17
18 18
impl Repo {
19 19
    fn tag_attest_path(&self, name: &str) -> PathBuf {
20 -
        self.oxy.join("attest").join("tags").join(name)
20 +
        self.oxy.join("attest").join("tags").join(name_file(name))
21 21
    }
22 22
23 23
    /// The attestation recorded for a tag, if any.
…
219 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +47 −0
…
4939 unchanged lines
4940 4940
        ]
4941 4941
    );
4942 4942
}
4943 +
4944 +
/// Bookmarks with `/` in their names: pushing records their
4945 +
/// attestations, and checkpoints are kept for them, beside `feature`
4946 +
/// itself.
4947 +
#[test]
4948 +
fn bookmarks_with_slashes_push_and_checkpoint() {
4949 +
    hermetic();
4950 +
    let dir = tempfile::tempdir().unwrap();
4951 +
    let root = dir.path();
4952 +
    let origin = Repo::init(&root.join("origin")).unwrap();
4953 +
    let url = root.join("origin").to_string_lossy().to_string();
4954 +
    let mut repo = Repo::clone(&url, &root.join("r"))
4955 +
        .unwrap()
4956 +
        .with_home(root.join("home"));
4957 +
    // With an identity, pushes carry reference attestations: the case
4958 +
    // that failed, since the attestation is a file named after the bookmark.
4959 +
    repo.create_identity("Ada", "[email protected]", IdentityScope::Repo)
4960 +
        .unwrap();
4961 +
    let r = root.join("r");
4962 +
    write(&r, "a.txt", b"a\n");
4963 +
    let h_a = repo.commit("a", &[]).unwrap();
4964 +
    assert!(repo.push("origin", false).unwrap()[0].ok);
4965 +
    repo.bookmark_create("feature", None).unwrap();
4966 +
    repo.bookmark_create("feature/x", None).unwrap();
4967 +
    repo.bookmark_switch("feature/x", false).unwrap();
4968 +
    write(&r, "b.txt", b"b\n");
4969 +
    let h_b = repo.commit("b", &[]).unwrap();
4970 +
    for name in ["feature", "feature/x"] {
4971 +
        let res = repo.push_bookmark("origin", name, false).unwrap();
4972 +
        assert!(res[0].ok, "{name}: {res:?}");
4973 +
    }
4974 +
    assert_eq!(origin.resolve_heads("feature").unwrap(), vec![h_a]);
4975 +
    assert_eq!(
4976 +
        origin.resolve_heads("feature/x").unwrap(),
4977 +
        repo.resolve_heads("feature/x").unwrap()
4978 +
    );
4979 +
    assert!(origin.resolve_heads("feature/x").unwrap().contains(&h_b));
4980 +
    assert!(origin.latest_ref_attest("feature/x").unwrap().is_some());
4981 +
    assert!(origin.latest_ref_attest("feature").unwrap().is_some());
4982 +
4983 +
    // A checkpoint of feature/x is found again when loading its state.
4984 +
    let (cp, _, _) = repo.checkpoint().unwrap();
4985 +
    assert_eq!(repo.latest_checkpoint("feature/x").unwrap().unwrap().0, cp);
4986 +
    assert!(repo.latest_checkpoint("feature").unwrap().is_none());
4987 +
    let heads = repo.resolve_heads("feature/x").unwrap();
4988 +
    assert!(repo.load_state_checkpointed(&heads).unwrap().is_some());
4989 +
}