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 | + | } |