Windows paths print plainly (closes #143) 544d0541a7e5
Repository roots are canonicalized, and on Windows canonicalize answers the verbatim form, so every message showed \\?\C:\work\demo. One helper, oxy_core::portable::canonical, drops the prefix where the plain path names the same file; checked on the Windows 11 VM.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 86bbd14ef53f
- When
- · 2026-10-26 00:13 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
544d0541a7e511204d1c83a1fe3865e0219618be03db3efa827253dc394f2140- Depends on
- 2b5ee371060e 4e88e774777a 648189388f1a 75167672ebcd 79ab8528ff62 8fd78eb52ad2 982864068a59 9935d490ddfb c863c7a7a2d1 e02bf0cd0533 e4595038502a fce44ae9ad29 ff0af1ee299d ff6987682725
- Depended on by
- 795b8b502ded 9aec107d1071
- In bookmarks
- identical-sides main push-notes subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 16 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Windows paths print plainly (#143):** a repository's root showed as `\\?\C:\work\demo` in every message, Windows' verbatim form from `canonicalize`; oxy now drops the prefix where the plain path says the same (`oxy_core::portable::canonical`). Found running the shipped Windows build on a Windows 11 VM. |
|
| 5 | 6 | - **Oxygen's CI writes a test report beside its coverage (#139):** the test job runs under cargo-nextest (`.config/nextest.toml`, profile `ci`) as well as cargo-llvm-cov, keeping a JUnit report whether or not the tests pass, for the hub's Tests tab. |
|
| 6 | 7 | - **Pushing a new bookmark sends only what the remote lacks (#142).** A push was worked out against the remote's heads for that bookmark alone, so a bookmark new to the remote sent its whole history again: slow on a big repository, and OxygenHub's push protection scanned all of it (and refused a proposal's bookmark for an example key in a change main had had for weeks). The heads of every bookmark the remote lists now count as already there. |
|
| 7 | 8 | - **A binary file's or subrepo pin's conflict can be settled from the command line (#140).** Two changes that replaced the same binary file, or moved the same subrepo pin, leave a replace conflict with no markers to edit, and every commit was refused until it was settled, which only a hosted edit could do. `oxy resolve PATH…` now settles it with what the working copy holds at the path (the file, or the subrepo's pin): the next commit is a resolution replacing every version with it. A path with no replace conflict is refused, and a choice is forgotten once committed. |
|
| … | 269 unchanged lines |
modified crates/oxy-core/src/portable.rs +65 −0
| … | 99 unchanged lines |
||
| 100 | 100 | out |
|
| 101 | 101 | } |
|
| 102 | 102 | ||
| 103 | + | /// `path.canonicalize()`, but on Windows without the verbatim `\\?\` |
|
| 104 | + | /// prefix when the plain form names the same file (oxygen#143): a root |
|
| 105 | + | /// shown as `\\?\C:\work\demo` in every message otherwise. |
|
| 106 | + | pub fn canonical(path: &std::path::Path) -> std::io::Result<std::path::PathBuf> { |
|
| 107 | + | path.canonicalize().map(plain) |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | /// [`canonical`]'s rule alone: `\\?\C:\…` as `C:\…` and `\\?\UNC\…` as |
|
| 111 | + | /// `\\…`, when the result is under Windows' old length limit and has no |
|
| 112 | + | /// component the plain form would read differently (`.`, `..`, a trailing |
|
| 113 | + | /// dot or space, a reserved name). Anything else is left as it is. |
|
| 114 | + | pub fn plain(p: std::path::PathBuf) -> std::path::PathBuf { |
|
| 115 | + | let Some(s) = p.to_str() else { return p }; |
|
| 116 | + | let unchanged_by_plain = |rest: &str| { |
|
| 117 | + | rest.len() < 260 |
|
| 118 | + | && !rest.split('\\').any(|c| { |
|
| 119 | + | c == "." |
|
| 120 | + | || c == ".." |
|
| 121 | + | || c.ends_with('.') |
|
| 122 | + | || c.ends_with(' ') |
|
| 123 | + | || (!c.is_empty() && windows_name_problem(c).is_some()) |
|
| 124 | + | }) |
|
| 125 | + | }; |
|
| 126 | + | if let Some(rest) = s.strip_prefix(r"\\?\UNC\") { |
|
| 127 | + | if unchanged_by_plain(rest) { |
|
| 128 | + | return std::path::PathBuf::from(format!(r"\\{rest}")); |
|
| 129 | + | } |
|
| 130 | + | } else if let Some(rest) = s.strip_prefix(r"\\?\") { |
|
| 131 | + | let b = rest.as_bytes(); |
|
| 132 | + | let drive = b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && b[2] == b'\\'; |
|
| 133 | + | if drive && unchanged_by_plain(&rest[3..]) { |
|
| 134 | + | return std::path::PathBuf::from(rest); |
|
| 135 | + | } |
|
| 136 | + | } |
|
| 137 | + | p |
|
| 138 | + | } |
|
| 139 | + | ||
| 103 | 140 | /// Why Windows would refuse a path, if it would. |
|
| 104 | 141 | pub fn windows_name_problem(path: &str) -> Option<&'static str> { |
|
| 105 | 142 | const RESERVED: &[&str] = &[ |
|
| … | 21 unchanged lines |
||
| 127 | 164 | #[cfg(test)] |
|
| 128 | 165 | mod tests { |
|
| 129 | 166 | use super::*; |
|
| 167 | + | ||
| 168 | + | /// oxygen#143: Windows' verbatim prefix is dropped where the plain path |
|
| 169 | + | /// says the same, and kept where it would not. |
|
| 170 | + | #[test] |
|
| 171 | + | fn a_verbatim_windows_path_is_shown_plain_when_it_can_be() { |
|
| 172 | + | use std::path::PathBuf; |
|
| 173 | + | let p = |s: &str| plain(PathBuf::from(s)).to_string_lossy().into_owned(); |
|
| 174 | + | assert_eq!(p(r"\\?\C:\work\demo"), r"C:\work\demo"); |
|
| 175 | + | assert_eq!(p(r"\\?\UNC\server\share\repo"), r"\\server\share\repo"); |
|
| 176 | + | assert_eq!( |
|
| 177 | + | p(r"\\?\C:\work\CON"), |
|
| 178 | + | r"\\?\C:\work\CON", |
|
| 179 | + | "a reserved name stays verbatim" |
|
| 180 | + | ); |
|
| 181 | + | assert_eq!(p(r"\\?\C:\work\trailing."), r"\\?\C:\work\trailing."); |
|
| 182 | + | assert_eq!( |
|
| 183 | + | p(r"\\?\Volume{1234}\x"), |
|
| 184 | + | r"\\?\Volume{1234}\x", |
|
| 185 | + | "not a drive path" |
|
| 186 | + | ); |
|
| 187 | + | let long = format!(r"\\?\C:\{}", "a".repeat(300)); |
|
| 188 | + | assert_eq!(p(&long), long, "past the old length limit"); |
|
| 189 | + | assert_eq!( |
|
| 190 | + | p("/home/andy/repo"), |
|
| 191 | + | "/home/andy/repo", |
|
| 192 | + | "nothing to do elsewhere" |
|
| 193 | + | ); |
|
| 194 | + | } |
|
| 130 | 195 | ||
| 131 | 196 | #[test] |
|
| 132 | 197 | fn collisions_and_names() { |
|
| … | 25 unchanged lines |
modified crates/oxy-core/src/repo/git.rs +3 −5
| … | 443 unchanged lines |
||
| 444 | 444 | ||
| 445 | 445 | impl Repo { |
|
| 446 | 446 | fn git_marks_dir(&self, git_dir: &Path) -> Result<PathBuf> { |
|
| 447 | - | let canonical = git_dir |
|
| 448 | - | .canonicalize() |
|
| 449 | - | .unwrap_or_else(|_| git_dir.to_path_buf()); |
|
| 447 | + | let canonical = |
|
| 448 | + | crate::portable::canonical(git_dir).unwrap_or_else(|_| git_dir.to_path_buf()); |
|
| 450 | 449 | let key = hash_object(ObjectType::Chunk, canonical.to_string_lossy().as_bytes()).short(16); |
|
| 451 | 450 | let dir = self.oxy.join("git").join(key); |
|
| 452 | 451 | fs::create_dir_all(&dir)?; |
|
| … | 81 unchanged lines |
||
| 534 | 533 | /// new changes join the bookmark like a pull. |
|
| 535 | 534 | pub fn git_fetch(&self, url: &str, branch: &str) -> Result<GitImportReport> { |
|
| 536 | 535 | // A local path, made absolute: git runs it from inside the mirror. |
|
| 537 | - | let local = Path::new(url) |
|
| 538 | - | .canonicalize() |
|
| 536 | + | let local = crate::portable::canonical(Path::new(url)) |
|
| 539 | 537 | .ok() |
|
| 540 | 538 | .filter(|_| !url.contains("://")); |
|
| 541 | 539 | let absolute = local.as_ref().map(|p| p.to_string_lossy().into_owned()); |
|
| … | 1555 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +3 −3
| … | 372 unchanged lines |
||
| 373 | 373 | /// on the store at `storage` (`storage.url`), or in `.oxy/objects`. |
|
| 374 | 374 | pub fn init_on(root: &Path, id: [u8; 16], storage: Option<&str>) -> Result<Repo> { |
|
| 375 | 375 | fs::create_dir_all(root)?; |
|
| 376 | - | let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); |
|
| 376 | + | let root = crate::portable::canonical(root).unwrap_or_else(|_| root.to_path_buf()); |
|
| 377 | 377 | let oxy = root.join(OXY_DIR); |
|
| 378 | 378 | if oxy.exists() { |
|
| 379 | 379 | return fail(format!("{} is already an oxy repository", root.display())); |
|
| … | 62 unchanged lines |
||
| 442 | 442 | /// [`Repo::init_on`] for a bare repository. |
|
| 443 | 443 | pub fn init_bare_on(root: &Path, id: [u8; 16], storage: Option<&str>) -> Result<Repo> { |
|
| 444 | 444 | fs::create_dir_all(root)?; |
|
| 445 | - | let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); |
|
| 445 | + | let root = crate::portable::canonical(root).unwrap_or_else(|_| root.to_path_buf()); |
|
| 446 | 446 | let oxy = root.join(OXY_DIR); |
|
| 447 | 447 | if oxy.exists() { |
|
| 448 | 448 | return fail(format!("{} is already an oxy repository", root.display())); |
|
| … | 74 unchanged lines |
||
| 523 | 523 | ||
| 524 | 524 | /// Open the repository containing `start` (searching upwards). |
|
| 525 | 525 | pub fn open(start: &Path) -> Result<Repo> { |
|
| 526 | - | let start = start.canonicalize()?; |
|
| 526 | + | let start = crate::portable::canonical(start)?; |
|
| 527 | 527 | let mut dir: Option<&Path> = Some(&start); |
|
| 528 | 528 | while let Some(d) = dir { |
|
| 529 | 529 | let oxy = d.join(OXY_DIR); |
|
| … | 2262 unchanged lines |
modified crates/oxy-core/src/repo/workspace.rs +1 −1
| … | 200 unchanged lines |
||
| 201 | 201 | return fail(format!("{} exists and is not empty", dir.display())); |
|
| 202 | 202 | } |
|
| 203 | 203 | fs::create_dir_all(dir)?; |
|
| 204 | - | let dir = dir.canonicalize()?; |
|
| 204 | + | let dir = crate::portable::canonical(&dir)?; |
|
| 205 | 205 | if dir == self.root || dir.starts_with(&self.root) || self.root.starts_with(&dir) { |
|
| 206 | 206 | return fail("a workspace cannot be inside another workspace of the same repository"); |
|
| 207 | 207 | } |
|
| … | 154 unchanged lines |
modified crates/oxy-core/src/store.rs +1 −1
| … | 615 unchanged lines |
||
| 616 | 616 | return None; |
|
| 617 | 617 | } |
|
| 618 | 618 | static STORES: OnceLock<Mutex<HashMap<PathBuf, Arc<MemoryStore>>>> = OnceLock::new(); |
|
| 619 | - | let key = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); |
|
| 619 | + | let key = crate::portable::canonical(dir).unwrap_or_else(|_| dir.to_path_buf()); |
|
| 620 | 620 | let mut map = STORES |
|
| 621 | 621 | .get_or_init(Default::default) |
|
| 622 | 622 | .lock() |
|
| … | 4045 unchanged lines |
modified src/cli/sync.rs +1 −1
| … | 723 unchanged lines |
||
| 724 | 724 | tls: Option<(PathBuf, PathBuf)>, |
|
| 725 | 725 | ) -> Result<i32> { |
|
| 726 | 726 | let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding {bind}"))?; |
|
| 727 | - | let root = root.canonicalize()?; |
|
| 727 | + | let root = oxy_core::portable::canonical(&root)?; |
|
| 728 | 728 | let tls = tls.map(|(cert, key)| oxy_server::TlsFiles { cert, key }); |
|
| 729 | 729 | println!( |
|
| 730 | 730 | "Serving repositories under {} on {}://{bind}/<repo>/oxy/v1/ ({})", |
|
| … | 439 unchanged lines |