OXYGENHUB

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