OXYGENHUB

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