Portability: exec bits, symlinks and path names on macOS and Windows c863c7a7a2d1
The working copy's file system is described by a Platform (detected, and settable so tests can act as Windows or macOS on Linux). Without executable bits a snapshot keeps each file's recorded bit instead of reading it as cleared; without symlinks a plain file holding a link's target is still that link, as spec §7.7 requires, and editing it retargets the link. Paths a case-insensitive file system cannot hold side by side, and names Windows forbids (CON, aux.c, a:b, trailing dots), are listed by checkout on such a platform and by oxy doctor everywhere, so a Linux user hears before a Windows colleague does. CRLF was already handled by the eol attribute. Tests act as Windows and macOS.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 09:58 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
c863c7a7a2d14078197b5c87df8250057cc367a60ddf08b97988f1bcf27a41c1- Depends on
- 3e5772ccf9da 4dee277c327a 5b9c6ad45f6e 62794ecc7f74 75167672ebcd 83e0677b07fe 982864068a59 b7f102fcbb17 c3069537d7c5 ff0af1ee299d
- Depended on by
- 544d0541a7e5 157d02e95586 ad2496f7e608 c2e1d09047db f53cbe7a204e acadb5ed28a1 519c7c061bcb c5ce0367eddf 57eaac9107df afaba5f1306d 696bbf29d3eb 0bdc3f91a39f bc489b6c7f69
- 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
- 23 atoms, 1 tree operation, 8 files
Browse the tree at this change
added crates/oxy-core/src/portable.rs +157 −0
| 1 | + | //! What the file system of the working copy can represent (spec §7.7), |
|
| 2 | + | //! and which paths of a state it cannot. |
|
| 3 | + | //! |
|
| 4 | + | //! Executable bits and symbolic links exist on Unix only; elsewhere a |
|
| 5 | + | //! snapshot keeps the recorded executable bit and reads a symlink written |
|
| 6 | + | //! out as a plain file back as the symlink. macOS and Windows file systems |
|
| 7 | + | //! ignore case by default and Windows forbids some names, so paths that |
|
| 8 | + | //! differ only in case or use such names are reported before they are |
|
| 9 | + | //! written. |
|
| 10 | + | ||
| 11 | + | use std::collections::BTreeMap; |
|
| 12 | + | ||
| 13 | + | /// Capabilities of the working copy's file system. |
|
| 14 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 15 | + | pub struct Platform { |
|
| 16 | + | pub exec_bits: bool, |
|
| 17 | + | pub symlinks: bool, |
|
| 18 | + | pub case_insensitive: bool, |
|
| 19 | + | /// Windows' name rules: reserved device names, `<>:"\|?*`, trailing dots and spaces. |
|
| 20 | + | pub windows_names: bool, |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | impl Platform { |
|
| 24 | + | /// The platform oxy runs on, with its default file system. |
|
| 25 | + | pub fn current() -> Platform { |
|
| 26 | + | Platform { |
|
| 27 | + | exec_bits: cfg!(unix), |
|
| 28 | + | symlinks: cfg!(unix), |
|
| 29 | + | case_insensitive: cfg!(any(target_os = "macos", target_os = "windows")), |
|
| 30 | + | windows_names: cfg!(target_os = "windows"), |
|
| 31 | + | } |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | pub fn windows() -> Platform { |
|
| 35 | + | Platform { |
|
| 36 | + | exec_bits: false, |
|
| 37 | + | symlinks: false, |
|
| 38 | + | case_insensitive: true, |
|
| 39 | + | windows_names: true, |
|
| 40 | + | } |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | pub fn macos() -> Platform { |
|
| 44 | + | Platform { |
|
| 45 | + | exec_bits: true, |
|
| 46 | + | symlinks: true, |
|
| 47 | + | case_insensitive: true, |
|
| 48 | + | windows_names: false, |
|
| 49 | + | } |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | /// Problems this platform would have with these paths. |
|
| 53 | + | pub fn problems<'a>(&self, paths: impl IntoIterator<Item = &'a str>) -> Vec<String> { |
|
| 54 | + | let paths: Vec<&str> = paths.into_iter().collect(); |
|
| 55 | + | let mut out = Vec::new(); |
|
| 56 | + | if self.case_insensitive { |
|
| 57 | + | out.extend( |
|
| 58 | + | case_collisions(&paths).into_iter().map(|(a, b)| { |
|
| 59 | + | format!("{a} and {b} differ only in case; one will hide the other") |
|
| 60 | + | }), |
|
| 61 | + | ); |
|
| 62 | + | } |
|
| 63 | + | if self.windows_names { |
|
| 64 | + | out.extend( |
|
| 65 | + | paths |
|
| 66 | + | .iter() |
|
| 67 | + | .filter_map(|p| windows_name_problem(p).map(|why| format!("{p}: {why}"))), |
|
| 68 | + | ); |
|
| 69 | + | } |
|
| 70 | + | out |
|
| 71 | + | } |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | /// Pairs of paths equal but for case (including a directory's case). |
|
| 75 | + | pub fn case_collisions(paths: &[&str]) -> Vec<(String, String)> { |
|
| 76 | + | let mut seen: BTreeMap<String, &str> = BTreeMap::new(); |
|
| 77 | + | let mut out = Vec::new(); |
|
| 78 | + | // Every prefix counts: `Docs/a` and `docs/b` put two directories in one place. |
|
| 79 | + | let mut prefixes: Vec<&str> = Vec::new(); |
|
| 80 | + | for p in paths { |
|
| 81 | + | let mut end = 0; |
|
| 82 | + | for part in p.split('/') { |
|
| 83 | + | end += part.len(); |
|
| 84 | + | prefixes.push(&p[..end]); |
|
| 85 | + | end += 1; |
|
| 86 | + | } |
|
| 87 | + | } |
|
| 88 | + | prefixes.sort(); |
|
| 89 | + | prefixes.dedup(); |
|
| 90 | + | for p in prefixes { |
|
| 91 | + | let key = p.to_lowercase(); |
|
| 92 | + | match seen.get(&key) { |
|
| 93 | + | Some(first) if *first != p => out.push((first.to_string(), p.to_string())), |
|
| 94 | + | Some(_) => {} |
|
| 95 | + | None => { |
|
| 96 | + | seen.insert(key, p); |
|
| 97 | + | } |
|
| 98 | + | } |
|
| 99 | + | } |
|
| 100 | + | out |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | /// Why Windows would refuse a path, if it would. |
|
| 104 | + | pub fn windows_name_problem(path: &str) -> Option<&'static str> { |
|
| 105 | + | const RESERVED: &[&str] = &[ |
|
| 106 | + | "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", |
|
| 107 | + | "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", |
|
| 108 | + | ]; |
|
| 109 | + | for part in path.split('/') { |
|
| 110 | + | if part |
|
| 111 | + | .chars() |
|
| 112 | + | .any(|c| matches!(c, '<' | '>' | ':' | '"' | '\\' | '|' | '?' | '*') || (c as u32) < 32) |
|
| 113 | + | { |
|
| 114 | + | return Some("contains a character Windows does not allow in names"); |
|
| 115 | + | } |
|
| 116 | + | if part.ends_with('.') || part.ends_with(' ') { |
|
| 117 | + | return Some("a name ending in a dot or space cannot be created on Windows"); |
|
| 118 | + | } |
|
| 119 | + | let stem = part.split('.').next().unwrap_or("").to_ascii_uppercase(); |
|
| 120 | + | if RESERVED.contains(&stem.as_str()) { |
|
| 121 | + | return Some("a reserved device name on Windows"); |
|
| 122 | + | } |
|
| 123 | + | } |
|
| 124 | + | None |
|
| 125 | + | } |
|
| 126 | + | ||
| 127 | + | #[cfg(test)] |
|
| 128 | + | mod tests { |
|
| 129 | + | use super::*; |
|
| 130 | + | ||
| 131 | + | #[test] |
|
| 132 | + | fn collisions_and_names() { |
|
| 133 | + | assert_eq!( |
|
| 134 | + | case_collisions(&["README.md", "readme.md", "src/a.rs"]), |
|
| 135 | + | vec![("README.md".into(), "readme.md".into())] |
|
| 136 | + | ); |
|
| 137 | + | assert_eq!( |
|
| 138 | + | case_collisions(&["Docs/a", "docs/b"]), |
|
| 139 | + | vec![("Docs".into(), "docs".into())] |
|
| 140 | + | ); |
|
| 141 | + | assert!(case_collisions(&["a/b", "a/c", "b"]).is_empty()); |
|
| 142 | + | assert!(windows_name_problem("src/con.rs").is_some()); |
|
| 143 | + | assert!(windows_name_problem("notes/a:b.txt").is_some()); |
|
| 144 | + | assert!(windows_name_problem("trailing./x").is_some()); |
|
| 145 | + | assert!(windows_name_problem("src/console.rs").is_none()); |
|
| 146 | + | let w = Platform::windows(); |
|
| 147 | + | assert_eq!(w.problems(["A.txt", "a.txt", "aux.c"]).len(), 2); |
|
| 148 | + | assert!(Platform { |
|
| 149 | + | exec_bits: true, |
|
| 150 | + | symlinks: true, |
|
| 151 | + | case_insensitive: false, |
|
| 152 | + | windows_names: false |
|
| 153 | + | } |
|
| 154 | + | .problems(["A", "a"]) |
|
| 155 | + | .is_empty()); |
|
| 156 | + | } |
|
| 157 | + | } |
modified crates/oxy-core/src/lib.rs +1 −0
| … | 15 unchanged lines |
||
| 16 | 16 | pub mod objects; |
|
| 17 | 17 | pub mod oplog; |
|
| 18 | 18 | pub mod policy; |
|
| 19 | + | pub mod portable; |
|
| 19 | 20 | pub mod protocol; |
|
| 20 | 21 | pub mod repo; |
|
| 21 | 22 | pub mod rewrite; |
|
| … | 4 unchanged lines |
modified crates/oxy-core/src/repo/checkout.rs +11 −2
| … | 30 unchanged lines |
||
| 31 | 31 | let attrs = Repo::state_attributes(&target); |
|
| 32 | 32 | let current = self.walk_working_copy()?; |
|
| 33 | 33 | let mut report = CheckoutReport::default(); |
|
| 34 | + | // Paths this file system cannot hold side by side (spec §7.7). |
|
| 35 | + | report.notes.extend( |
|
| 36 | + | self.platform |
|
| 37 | + | .problems(target.files.keys().map(String::as_str)), |
|
| 38 | + | ); |
|
| 34 | 39 | ||
| 35 | 40 | // Remove tracked paths that the target does not have. A nested |
|
| 36 | 41 | // repository is never deleted (spec §7.8); it is left in place. |
|
| … | 30 unchanged lines |
||
| 67 | 72 | bytes.as_slice().into() |
|
| 68 | 73 | }; |
|
| 69 | 74 | let same = current.get(path).is_some_and(|c| { |
|
| 70 | - | c.kind == NodeKind::File && c.content == *on_disk && c.exec == f.mode.exec |
|
| 75 | + | c.kind == NodeKind::File |
|
| 76 | + | && c.content == *on_disk |
|
| 77 | + | && (!self.platform.exec_bits || c.exec == f.mode.exec) |
|
| 71 | 78 | }); |
|
| 72 | 79 | if !same { |
|
| 73 | 80 | self.write_file(path, &on_disk, f.mode.exec)?; |
|
| … | 27 unchanged lines |
||
| 101 | 108 | } |
|
| 102 | 109 | } else { |
|
| 103 | 110 | let same = current.get(path).is_some_and(|c| { |
|
| 104 | - | c.kind == NodeKind::File && c.content == bytes && c.exec == f.mode.exec |
|
| 111 | + | c.kind == NodeKind::File |
|
| 112 | + | && c.content == bytes |
|
| 113 | + | && (!self.platform.exec_bits || c.exec == f.mode.exec) |
|
| 105 | 114 | }); |
|
| 106 | 115 | if !same { |
|
| 107 | 116 | self.write_file(path, &bytes, f.mode.exec)?; |
|
| … | 129 unchanged lines |
modified crates/oxy-core/src/repo/doctor.rs +43 −0
| … | 60 unchanged lines |
||
| 61 | 61 | self.doctor_repository(&mut rep); |
|
| 62 | 62 | self.doctor_identity(&mut rep); |
|
| 63 | 63 | self.doctor_author(&mut rep); |
|
| 64 | + | self.doctor_portability(&mut rep); |
|
| 64 | 65 | for name in self.config.remotes.keys() { |
|
| 65 | 66 | self.doctor_remote(&mut rep, name, online); |
|
| 66 | 67 | } |
|
| … | 116 unchanged lines |
||
| 183 | 184 | } |
|
| 184 | 185 | } |
|
| 185 | 186 | ||
| 187 | + | /// Paths of the current bookmark that another platform's file system |
|
| 188 | + | /// cannot check out as they are (spec §7.7). |
|
| 189 | + | fn doctor_portability(&self, rep: &mut DoctorReport) { |
|
| 190 | + | let Ok(heads) = self.bookmark_heads() else { |
|
| 191 | + | return; |
|
| 192 | + | }; |
|
| 193 | + | let Ok(state) = self.load_state(&heads) else { |
|
| 194 | + | return; |
|
| 195 | + | }; |
|
| 196 | + | let view = state.tree().view(); |
|
| 197 | + | let paths: Vec<&str> = view.entries.keys().map(String::as_str).collect(); |
|
| 198 | + | let mut problems: Vec<String> = |
|
| 199 | + | crate::portable::Platform::windows().problems(paths.iter().copied()); |
|
| 200 | + | problems.sort(); |
|
| 201 | + | problems.dedup(); |
|
| 202 | + | if problems.is_empty() { |
|
| 203 | + | rep.push( |
|
| 204 | + | "portability", |
|
| 205 | + | Health::Ok, |
|
| 206 | + | format!( |
|
| 207 | + | "every path of {} checks out on Linux, macOS and Windows", |
|
| 208 | + | self.config.bookmark |
|
| 209 | + | ), |
|
| 210 | + | None, |
|
| 211 | + | ); |
|
| 212 | + | return; |
|
| 213 | + | } |
|
| 214 | + | let shown: Vec<&String> = problems.iter().take(5).collect(); |
|
| 215 | + | rep.push( |
|
| 216 | + | "portability", |
|
| 217 | + | Health::Warn, |
|
| 218 | + | format!( |
|
| 219 | + | "{} path problem{} on case-insensitive or Windows file systems: {}{}", |
|
| 220 | + | problems.len(), |
|
| 221 | + | if problems.len() == 1 { "" } else { "s" }, |
|
| 222 | + | shown.iter().map(|s| s.as_str()).collect::<Vec<_>>().join("; "), |
|
| 223 | + | if problems.len() > 5 { "; …" } else { "" } |
|
| 224 | + | ), |
|
| 225 | + | Some("rename the paths so they differ by more than case and avoid Windows' reserved names and characters".into()), |
|
| 226 | + | ); |
|
| 227 | + | } |
|
| 228 | + | ||
| 186 | 229 | fn doctor_author(&self, rep: &mut DoctorReport) { |
|
| 187 | 230 | let defaulted = |
|
| 188 | 231 | self.config.author_email.ends_with("@localhost") || self.config.author_name.is_empty(); |
|
| … | 93 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +17 −0
| … | 154 unchanged lines |
||
| 155 | 155 | keys_dir: PathBuf, |
|
| 156 | 156 | user_config: PathBuf, |
|
| 157 | 157 | pub config: Config, |
|
| 158 | + | /// What the working copy's file system can represent (spec §7.7). |
|
| 159 | + | platform: crate::portable::Platform, |
|
| 158 | 160 | } |
|
| 159 | 161 | ||
| 160 | 162 | /// Marker file of a bare repository, inside `.oxy/`. |
|
| … | 66 unchanged lines |
||
| 227 | 229 | workspace, |
|
| 228 | 230 | bare: false, |
|
| 229 | 231 | keys_dir: default_keys_dir(), |
|
| 232 | + | platform: crate::portable::Platform::current(), |
|
| 230 | 233 | user_config: default_user_config(), |
|
| 231 | 234 | config, |
|
| 232 | 235 | }; |
|
| … | 42 unchanged lines |
||
| 275 | 278 | workspace: WorkspaceId::ZERO, |
|
| 276 | 279 | bare: true, |
|
| 277 | 280 | keys_dir: default_keys_dir(), |
|
| 281 | + | platform: crate::portable::Platform::current(), |
|
| 278 | 282 | user_config: default_user_config(), |
|
| 279 | 283 | config, |
|
| 280 | 284 | }; |
|
| … | 64 unchanged lines |
||
| 345 | 349 | workspace, |
|
| 346 | 350 | bare: false, |
|
| 347 | 351 | keys_dir: default_keys_dir(), |
|
| 352 | + | platform: crate::portable::Platform::current(), |
|
| 348 | 353 | user_config: default_user_config(), |
|
| 349 | 354 | config, |
|
| 350 | 355 | }; |
|
| … | 19 unchanged lines |
||
| 370 | 375 | workspace, |
|
| 371 | 376 | bare, |
|
| 372 | 377 | keys_dir: default_keys_dir(), |
|
| 378 | + | platform: crate::portable::Platform::current(), |
|
| 373 | 379 | user_config: default_user_config(), |
|
| 374 | 380 | config, |
|
| 375 | 381 | }; |
|
| … | 98 unchanged lines |
||
| 474 | 480 | ||
| 475 | 481 | /// Use a different user directory for keys and the user config file |
|
| 476 | 482 | /// (tests, or `$OXY_HOME`). Reloads the effective configuration. |
|
| 483 | + | /// Act as if the working copy were on another platform's file system |
|
| 484 | + | /// (tests of Windows and macOS behaviour on any host). |
|
| 485 | + | pub fn with_platform(mut self, platform: crate::portable::Platform) -> Repo { |
|
| 486 | + | self.platform = platform; |
|
| 487 | + | self |
|
| 488 | + | } |
|
| 489 | + | ||
| 490 | + | pub fn platform(&self) -> crate::portable::Platform { |
|
| 491 | + | self.platform |
|
| 492 | + | } |
|
| 493 | + | ||
| 477 | 494 | pub fn with_home(mut self, dir: impl Into<PathBuf>) -> Repo { |
|
| 478 | 495 | let dir = dir.into(); |
|
| 479 | 496 | self.keys_dir = dir.join("keys"); |
|
| … | 890 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +28 −2
| … | 72 unchanged lines |
||
| 73 | 73 | base_snapshot: &Snapshot, |
|
| 74 | 74 | reuse: HashMap<String, (NodeId, NodeKind)>, |
|
| 75 | 75 | ) -> Result<BuiltChange> { |
|
| 76 | - | let fs_entries = self.walk_working_copy_with(None, &Self::tracked_kinds(base_snapshot))?; |
|
| 76 | + | let mut fs_entries = |
|
| 77 | + | self.walk_working_copy_with(None, &Self::tracked_kinds(base_snapshot))?; |
|
| 78 | + | self.platform_normalize(&mut fs_entries, base_snapshot); |
|
| 77 | 79 | self.build_from_entries(base, base_snapshot, reuse, &fs_entries) |
|
| 78 | 80 | } |
|
| 79 | 81 | ||
| 82 | + | /// Read the working copy as the platform left it (spec §7.7): without |
|
| 83 | + | /// executable bits a file keeps the bit it has in the base, and without |
|
| 84 | + | /// symbolic links a plain file where the base has a symlink is that |
|
| 85 | + | /// symlink, written out as its target, not a new file. |
|
| 86 | + | fn platform_normalize(&self, entries: &mut BTreeMap<String, FsEntry>, base: &Snapshot) { |
|
| 87 | + | let p = self.platform; |
|
| 88 | + | if p.exec_bits && p.symlinks { |
|
| 89 | + | return; |
|
| 90 | + | } |
|
| 91 | + | for (path, e) in entries.iter_mut() { |
|
| 92 | + | let Some(f) = base.files.get(path) else { |
|
| 93 | + | continue; |
|
| 94 | + | }; |
|
| 95 | + | if !p.symlinks && f.kind == NodeKind::Symlink && e.kind == NodeKind::File { |
|
| 96 | + | e.kind = NodeKind::Symlink; |
|
| 97 | + | e.exec = false; |
|
| 98 | + | } |
|
| 99 | + | if !p.exec_bits && f.kind == NodeKind::File && e.kind == NodeKind::File { |
|
| 100 | + | e.exec = f.mode.exec; |
|
| 101 | + | } |
|
| 102 | + | } |
|
| 103 | + | } |
|
| 104 | + | ||
| 80 | 105 | /// Like `build_against`, for files given in memory (imports). |
|
| 81 | 106 | pub(crate) fn build_from_entries( |
|
| 82 | 107 | &self, |
|
| … | 101 unchanged lines |
||
| 184 | 209 | let reuse = added_node_paths(&old_wc, &base_snapshot); |
|
| 185 | 210 | // The last manifest lets unchanged opaque files go unread. |
|
| 186 | 211 | let manifest = self.read_manifest()?.map(|(_, _, m)| m); |
|
| 187 | - | let fs_entries = |
|
| 212 | + | let mut fs_entries = |
|
| 188 | 213 | self.walk_working_copy_with(manifest.as_ref(), &Self::tracked_kinds(&base_snapshot))?; |
|
| 214 | + | self.platform_normalize(&mut fs_entries, &base_snapshot); |
|
| 189 | 215 | let built = self.build_from_entries(&base, &base_snapshot, reuse, &fs_entries)?; |
|
| 190 | 216 | ||
| 191 | 217 | let mut wc = Change { |
|
| … | 1827 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +77 −0
| … | 4447 unchanged lines |
||
| 4448 | 4448 | }; |
|
| 4449 | 4449 | assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\0base"); |
|
| 4450 | 4450 | } |
|
| 4451 | + | ||
| 4452 | + | /// Spec §7.7 on a file system without executable bits or symlinks: the |
|
| 4453 | + | /// recorded bit is kept, and a symlink checked out as a plain file holding |
|
| 4454 | + | /// its target is still that symlink. |
|
| 4455 | + | #[cfg(unix)] |
|
| 4456 | + | #[test] |
|
| 4457 | + | fn windows_file_systems_keep_exec_bits_and_symlinks() { |
|
| 4458 | + | use crate::portable::Platform; |
|
| 4459 | + | use std::os::unix::fs::PermissionsExt; |
|
| 4460 | + | hermetic(); |
|
| 4461 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 4462 | + | let root = dir.path(); |
|
| 4463 | + | let repo = Repo::init(root).unwrap(); |
|
| 4464 | + | write(root, "run.sh", b"#!/bin/sh\n"); |
|
| 4465 | + | fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap(); |
|
| 4466 | + | std::os::unix::fs::symlink("run.sh", root.join("link")).unwrap(); |
|
| 4467 | + | repo.commit("script and link", &[]).unwrap(); |
|
| 4468 | + | drop(repo); |
|
| 4469 | + | ||
| 4470 | + | // What checking out on Windows leaves: no exec bit, the link as a file. |
|
| 4471 | + | fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o644)).unwrap(); |
|
| 4472 | + | fs::remove_file(root.join("link")).unwrap(); |
|
| 4473 | + | write(root, "link", b"run.sh"); |
|
| 4474 | + | let win = Repo::open(root).unwrap().with_platform(Platform::windows()); |
|
| 4475 | + | let s = win.snapshot("oxy status").unwrap(); |
|
| 4476 | + | assert!( |
|
| 4477 | + | s.statuses.is_empty(), |
|
| 4478 | + | "nothing changed on Windows: {:?}", |
|
| 4479 | + | statuses(&s) |
|
| 4480 | + | ); |
|
| 4481 | + | // Editing the file is a content change, not a mode change; retargeting the link keeps it a link. |
|
| 4482 | + | write(root, "run.sh", b"#!/bin/sh\necho hi\n"); |
|
| 4483 | + | write(root, "link", b"other"); |
|
| 4484 | + | let h = win.commit("edit on Windows", &[]).unwrap(); |
|
| 4485 | + | let snap = win.load_state(&[h]).unwrap().materialize(); |
|
| 4486 | + | assert!(snap.files["run.sh"].mode.exec, "the exec bit survives"); |
|
| 4487 | + | assert_eq!(snap.files["link"].kind, NodeKind::Symlink); |
|
| 4488 | + | // The same working copy read as Linux sees the lost bit and the plain file. |
|
| 4489 | + | let linux = Repo::open(root).unwrap(); |
|
| 4490 | + | assert!(!linux.snapshot("oxy status").unwrap().statuses.is_empty()); |
|
| 4491 | + | } |
|
| 4492 | + | ||
| 4493 | + | /// Paths that collide on case-insensitive or Windows file systems are |
|
| 4494 | + | /// named at checkout there, and by doctor everywhere. |
|
| 4495 | + | #[test] |
|
| 4496 | + | fn paths_other_platforms_cannot_hold_are_reported() { |
|
| 4497 | + | use crate::portable::Platform; |
|
| 4498 | + | hermetic(); |
|
| 4499 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 4500 | + | let root = dir.path(); |
|
| 4501 | + | let repo = Repo::init(root).unwrap(); |
|
| 4502 | + | write(root, "README.md", b"a\n"); |
|
| 4503 | + | write(root, "readme.md", b"b\n"); |
|
| 4504 | + | write(root, "src/aux.c", b"int x;\n"); |
|
| 4505 | + | let h = repo.commit("unportable", &[]).unwrap(); |
|
| 4506 | + | let r = repo.doctor(false); |
|
| 4507 | + | let port = r.checks.iter().find(|c| c.area == "portability").unwrap(); |
|
| 4508 | + | assert_eq!(port.health, Health::Warn); |
|
| 4509 | + | assert!( |
|
| 4510 | + | port.message.contains("differ only in case") && port.message.contains("aux.c"), |
|
| 4511 | + | "{}", |
|
| 4512 | + | port.message |
|
| 4513 | + | ); |
|
| 4514 | + | let mac = Repo::open(root).unwrap().with_platform(Platform::macos()); |
|
| 4515 | + | let rep = mac.checkout(&[h], true, "oxy checkout").unwrap(); |
|
| 4516 | + | assert!( |
|
| 4517 | + | rep.notes |
|
| 4518 | + | .iter() |
|
| 4519 | + | .any(|n| n.contains("README.md and readme.md")), |
|
| 4520 | + | "{:?}", |
|
| 4521 | + | rep.notes |
|
| 4522 | + | ); |
|
| 4523 | + | assert!( |
|
| 4524 | + | !rep.notes.iter().any(|n| n.contains("aux.c")), |
|
| 4525 | + | "macOS allows aux.c" |
|
| 4526 | + | ); |
|
| 4527 | + | } |
modified docs/spec.md +3 −1
| … | 292 unchanged lines |
||
| 293 | 293 | - A placement named `.oxy` under the root, or any placement that resolves to a path inside `.oxy/`, MUST be refused at snapshot and at checkout. |
|
| 294 | 294 | - `.oxyignore` and `.oxyattributes` files that are symlinks MUST be treated as absent, with a warning from `oxy status`. |
|
| 295 | 295 | - On file systems without symlink support, a symlink node is written as a plain file containing the target path, and `oxy status` reports it; snapshot MUST NOT convert such a file back into a content change. |
|
| 296 | + | - On file systems without executable bits, snapshot keeps each file's recorded executable bit instead of reading it as cleared. |
|
| 297 | + | - Paths that a case-insensitive file system (macOS and Windows by default) cannot hold side by side (equal but for case, including a directory's case), and names Windows forbids (the reserved device names such as `CON` and `AUX` with any extension, the characters `<>:"\|?*` and control characters, a trailing dot or space), are reported by checkout on such a platform, and by `oxy doctor` (§21.2) on every platform so they can be renamed before anyone meets them. |
|
| 296 | 298 | ||
| 297 | 299 | ### 7.8 Subrepositories |
|
| 298 | 300 | ||
| … | 638 unchanged lines |
||
| 937 | 939 | ||
| 938 | 940 | --- |
|
| 939 | 941 | ||
| 940 | - | *Changelog.* Draft 0.8i (2026-09-28): found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and `oxy git verify` (§23). Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 942 | + | *Changelog.* Draft 0.8i (2026-09-28): portability rules for exec bits and path names (§7.7); found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and `oxy git verify` (§23). Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 941 | 943 | ||
| 942 | 944 | Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23). |
|
| 943 | 945 | ||
| … | 59 unchanged lines |