OXYGENHUB

.oxyattributes: binary, text, eol=lf|crlf, nocompress (raw chunks in packs), lockable with advisory local oxy lock/unlock (spec 0.6j) cdb5beba7f05

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 22:49 UTC
Signature
✔ signed by Andy McCall
Hash
cdb5beba7f05e5b9ca2674080d40370d77d9f15712620c2cac2942c0bd3cc8e4
Depends on
135212de5370 4e88e774777a 5b9c6ad45f6e 614541c3cebd 7421309f5372 86e682c242c6 982864068a59 c3069537d7c5 d02e545dc265 e993a086c13f fce44ae9ad29 ff0af1ee299d
Depended on by
4afe288b7946 3652104ae7d3 648189388f1a 5b4c51307b14 360aa74a6277 d4dbab97e948 f9466021286a f9575918477f 40a40bb7b798 2306a4d24779 da652d6392d8 01a47cd7f0c8 c5ce0367eddf dfb23a3a8cfa f7510b63cceb d42369abf180 e7ddd552b619 75167672ebcd 4dee277c327a b7f102fcbb17 8f10a07a1dbe 6e723b81f5b9 94084087a876 23b217d75848 6d3eefbce2f0
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
41 atoms, 1 tree operation, 12 files

Browse the tree at this change

added crates/oxy-core/src/attributes.rs +172 −0
1 +
//! `.oxyattributes` (spec §19): per-path attributes in gitattributes syntax.
2 +
//! Each line is `pattern attr...` where an attribute is `binary`, `text`
3 +
//! (or `-binary`), `eol=lf`, `eol=crlf`, `nocompress`, or `lockable`. A
4 +
//! pattern without a slash matches a basename anywhere below the file's
5 +
//! directory; one with a slash is anchored to that directory. Later rules
6 +
//! override earlier ones per attribute.
7 +
8 +
use globset::{Glob, GlobMatcher};
9 +
use std::collections::BTreeMap;
10 +
11 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12 +
pub enum Eol {
13 +
    Lf,
14 +
    Crlf,
15 +
}
16 +
17 +
/// The attributes in force for one path.
18 +
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19 +
pub struct Attrs {
20 +
    /// `Some(true)` forces opaque, `Some(false)` forces text (absent NUL bytes).
21 +
    pub binary: Option<bool>,
22 +
    pub eol: Option<Eol>,
23 +
    pub nocompress: bool,
24 +
    pub lockable: bool,
25 +
}
26 +
27 +
struct Rule {
28 +
    matcher: GlobMatcher,
29 +
    attrs: Vec<(String, Option<String>)>,
30 +
}
31 +
32 +
#[derive(Default)]
33 +
pub struct Attributes {
34 +
    rules: Vec<Rule>,
35 +
}
36 +
37 +
impl Attributes {
38 +
    /// Add the rules of one `.oxyattributes` file living in `dir` (`""` for the root).
39 +
    pub fn add_file(&mut self, dir: &str, text: &str) {
40 +
        for line in text.lines() {
41 +
            let line = line.trim();
42 +
            if line.is_empty() || line.starts_with('#') {
43 +
                continue;
44 +
            }
45 +
            let mut parts = line.split_whitespace();
46 +
            let Some(pattern) = parts.next() else { continue };
47 +
            let attrs: Vec<(String, Option<String>)> = parts
48 +
                .map(|a| match a.split_once('=') {
49 +
                    Some((k, v)) => (k.to_string(), Some(v.to_string())),
50 +
                    None => (a.to_string(), None),
51 +
                })
52 +
                .collect();
53 +
            let anchored = pattern.contains('/');
54 +
            let core = pattern.trim_start_matches('/');
55 +
            let full = match (dir.is_empty(), anchored) {
56 +
                (true, true) => core.to_string(),
57 +
                (true, false) => format!("**/{core}"),
58 +
                (false, true) => format!("{dir}/{core}"),
59 +
                (false, false) => format!("{dir}/**/{core}"),
60 +
            };
61 +
            if let Ok(g) = Glob::new(&full) {
62 +
                self.rules.push(Rule { matcher: g.compile_matcher(), attrs });
63 +
            }
64 +
        }
65 +
    }
66 +
67 +
    /// Build from every `.oxyattributes` in `files` (path → content).
68 +
    pub fn from_files<'a>(files: impl IntoIterator<Item = (&'a str, &'a [u8])>) -> Attributes {
69 +
        let mut sorted: BTreeMap<&str, &[u8]> = BTreeMap::new();
70 +
        for (p, c) in files {
71 +
            if p == ".oxyattributes" || p.ends_with("/.oxyattributes") {
72 +
                sorted.insert(p, c);
73 +
            }
74 +
        }
75 +
        let mut out = Attributes::default();
76 +
        // Shallower files first so deeper ones override.
77 +
        let mut entries: Vec<(&str, &[u8])> = sorted.into_iter().collect();
78 +
        entries.sort_by_key(|(p, _)| p.matches('/').count());
79 +
        for (p, c) in entries {
80 +
            let dir = p.strip_suffix(".oxyattributes").unwrap_or("").trim_end_matches('/');
81 +
            out.add_file(dir, &String::from_utf8_lossy(c));
82 +
        }
83 +
        out
84 +
    }
85 +
86 +
    pub fn lookup(&self, path: &str) -> Attrs {
87 +
        let mut a = Attrs::default();
88 +
        for r in &self.rules {
89 +
            if !r.matcher.is_match(path) {
90 +
                continue;
91 +
            }
92 +
            for (k, v) in &r.attrs {
93 +
                match (k.as_str(), v.as_deref()) {
94 +
                    ("binary", None) => a.binary = Some(true),
95 +
                    ("-binary", None) | ("text", None) => a.binary = Some(false),
96 +
                    ("eol", Some("lf")) => a.eol = Some(Eol::Lf),
97 +
                    ("eol", Some("crlf")) => a.eol = Some(Eol::Crlf),
98 +
                    ("-eol", None) => a.eol = None,
99 +
                    ("nocompress", None) => a.nocompress = true,
100 +
                    ("-nocompress", None) => a.nocompress = false,
101 +
                    ("lockable", None) => a.lockable = true,
102 +
                    ("-lockable", None) => a.lockable = false,
103 +
                    _ => {}
104 +
                }
105 +
            }
106 +
        }
107 +
        a
108 +
    }
109 +
110 +
    pub fn is_empty(&self) -> bool {
111 +
        self.rules.is_empty()
112 +
    }
113 +
}
114 +
115 +
/// `\r\n` → `\n`, as stored for `eol=` files.
116 +
pub fn normalize_eol(bytes: &[u8]) -> Vec<u8> {
117 +
    let mut out = Vec::with_capacity(bytes.len());
118 +
    let mut i = 0;
119 +
    while i < bytes.len() {
120 +
        if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') {
121 +
            i += 1;
122 +
            continue;
123 +
        }
124 +
        out.push(bytes[i]);
125 +
        i += 1;
126 +
    }
127 +
    out
128 +
}
129 +
130 +
/// `\n` → `\r\n` (lines already ending in `\r\n` are left alone).
131 +
pub fn to_crlf(bytes: &[u8]) -> Vec<u8> {
132 +
    let mut out = Vec::with_capacity(bytes.len() + bytes.len() / 16);
133 +
    let mut prev = 0u8;
134 +
    for &b in bytes {
135 +
        if b == b'\n' && prev != b'\r' {
136 +
            out.push(b'\r');
137 +
        }
138 +
        out.push(b);
139 +
        prev = b;
140 +
    }
141 +
    out
142 +
}
143 +
144 +
#[cfg(test)]
145 +
mod tests {
146 +
    use super::*;
147 +
148 +
    #[test]
149 +
    fn parsing_and_precedence() {
150 +
        let a = Attributes::from_files([
151 +
            (".oxyattributes", &b"*.png binary nocompress\n*.txt eol=lf\ndocs/*.txt eol=crlf\nmodels/** lockable\n"[..]),
152 +
            ("src/.oxyattributes", &b"*.dat binary\ngen.txt -eol\n"[..]),
153 +
            ("README.md", &b"not attributes"[..]),
154 +
        ]);
155 +
        assert_eq!(a.lookup("a/b/logo.png"), Attrs { binary: Some(true), eol: None, nocompress: true, lockable: false });
156 +
        assert_eq!(a.lookup("notes.txt").eol, Some(Eol::Lf));
157 +
        assert_eq!(a.lookup("docs/notes.txt").eol, Some(Eol::Crlf));
158 +
        assert!(a.lookup("models/x/y.bin").lockable);
159 +
        assert_eq!(a.lookup("src/deep/x.dat").binary, Some(true));
160 +
        assert_eq!(a.lookup("other/x.dat").binary, None);
161 +
        assert_eq!(a.lookup("src/gen.txt").eol, None, "deeper file overrides");
162 +
        assert_eq!(a.lookup("src/other.txt").eol, Some(Eol::Lf));
163 +
        assert_eq!(a.lookup("plain"), Attrs::default());
164 +
    }
165 +
166 +
    #[test]
167 +
    fn eol_conversions() {
168 +
        assert_eq!(normalize_eol(b"a\r\nb\nc\r\n"), b"a\nb\nc\n");
169 +
        assert_eq!(to_crlf(b"a\nb\r\nc\n"), b"a\r\nb\r\nc\r\n");
170 +
        assert_eq!(normalize_eol(&to_crlf(b"x\ny\n")), b"x\ny\n");
171 +
    }
172 +
}
modified Cargo.lock +1 −0
…
1217 unchanged lines
1218 1218
dependencies = [
1219 1219
 "blake3",
1220 1220
 "ed25519-dalek",
1221 +
 "globset",
1221 1222
 "hex",
1222 1223
 "ignore",
1223 1224
 "ml-dsa",
…
1147 unchanged lines
modified crates/oxy-core/Cargo.toml +1 −0
…
18 unchanged lines
19 19
rustls-pemfile = "2"
20 20
webpki-roots = "1"
21 21
zstd = "0.13"
22 +
globset = "0.4"
22 23
23 24
[dev-dependencies]
24 25
tempfile = "3"
modified crates/oxy-core/src/lib.rs +1 −0
…
2 unchanged lines
3 3
//! See `docs/spec.md` for the specification this implements.
4 4
5 5
pub mod attest;
6 +
pub mod attributes;
6 7
pub mod cbor;
7 8
pub mod chunk;
8 9
pub mod diff;
…
13 unchanged lines
modified crates/oxy-core/src/repo/checkout.rs +14 −2
…
20 unchanged lines
21 21
        let target_state = self.load_state(heads)?;
22 22
        let heads = target_state.maximal(heads);
23 23
        let target = target_state.materialize();
24 +
        let attrs = Repo::state_attributes(&target);
24 25
        let current = self.walk_working_copy()?;
25 26
        let mut report = CheckoutReport::default();
26 27
…
17 unchanged lines
44 45
            match &f.content {
45 46
                FileContent::Dir => continue,
46 47
                FileContent::Text { bytes, .. } => {
47 -
                    let same = current.get(path).is_some_and(|c| c.kind == NodeKind::File && &c.content == bytes && c.exec == f.mode.exec);
48 +
                    let a = attrs.lookup(path);
49 +
                    let on_disk: std::borrow::Cow<[u8]> = if a.eol == Some(crate::attributes::Eol::Crlf) { crate::attributes::to_crlf(bytes).into() } else { bytes.as_slice().into() };
50 +
                    let same = current.get(path).is_some_and(|c| c.kind == NodeKind::File && c.content == *on_disk && c.exec == f.mode.exec);
48 51
                    if !same {
49 -
                        self.write_file(path, bytes, f.mode.exec)?;
52 +
                        self.write_file(path, &on_disk, f.mode.exec)?;
50 53
                        report.written += 1;
51 54
                    }
55 +
                    if a.lockable && !self.is_locked(path) {
56 +
                        let _ = set_readonly(&self.root.join(path), true);
57 +
                    }
52 58
                }
53 59
                FileContent::Opaque { blobs } => {
54 60
                    let Some(blob) = blobs.first() else { continue };
…
10 unchanged lines
65 71
                            self.write_file(path, &bytes, f.mode.exec)?;
66 72
                            report.written += 1;
67 73
                        }
74 +
                        if attrs.lookup(path).lockable && !self.is_locked(path) {
75 +
                            let _ = set_readonly(&self.root.join(path), true);
76 +
                        }
68 77
                    }
69 78
                    if blobs.len() > 1 {
70 79
                        for (i, b) in blobs.iter().enumerate().skip(1) {
…
58 unchanged lines
129 138
        if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink() || m.is_dir()) {
130 139
            remove_any(&full)?;
131 140
        }
141 +
        if fs::metadata(&full).is_ok_and(|m| m.permissions().readonly()) {
142 +
            set_readonly(&full, false)?;
143 +
        }
132 144
        fs::write(&full, bytes)?;
133 145
        set_executable(&full, exec)?;
134 146
        Ok(())
…
23 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +75 −0
…
481 unchanged lines
482 482
483 483
    // ------------------------------------------------------------ working copy
484 484
485 +
    /// Attributes from the `.oxyattributes` files in the working directory.
486 +
    pub fn disk_attributes(&self) -> crate::attributes::Attributes {
487 +
        let mut files: Vec<(String, Vec<u8>)> = Vec::new();
488 +
        if let Ok(b) = fs::read(self.root.join(".oxyattributes")) {
489 +
            files.push((".oxyattributes".into(), b));
490 +
        }
491 +
        let mut wb = ignore::WalkBuilder::new(&self.root);
492 +
        wb.hidden(false).git_ignore(false).git_global(false).git_exclude(false).require_git(false).follow_links(false).add_custom_ignore_filename(".oxyignore");
493 +
        wb.filter_entry(|e| e.file_name() != OXY_DIR);
494 +
        for entry in wb.build().flatten() {
495 +
            if entry.file_name() == ".oxyattributes" && entry.path() != self.root.join(".oxyattributes") {
496 +
                if let Ok(b) = fs::read(entry.path()) {
497 +
                    let rel: String = entry.path().strip_prefix(&self.root).unwrap().components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
498 +
                    files.push((rel, b));
499 +
                }
500 +
            }
501 +
        }
502 +
        crate::attributes::Attributes::from_files(files.iter().map(|(p, c)| (p.as_str(), c.as_slice())))
503 +
    }
504 +
505 +
    /// Attributes from a materialized state's `.oxyattributes` files.
506 +
    pub fn state_attributes(snapshot: &Snapshot) -> crate::attributes::Attributes {
507 +
        crate::attributes::Attributes::from_files(snapshot.files.iter().filter_map(|(p, f)| match &f.content {
508 +
            FileContent::Text { bytes, .. } if p == ".oxyattributes" || p.ends_with("/.oxyattributes") => Some((p.as_str(), bytes.as_slice())),
509 +
            _ => None,
510 +
        }))
511 +
    }
512 +
513 +
    /// Store a blob, raw when the path is `nocompress`.
514 +
    pub fn store_blob_for(&self, content: &[u8], nocompress: bool) -> Result<ObjectRef> {
515 +
        if !nocompress {
516 +
            return self.store_blob(content);
517 +
        }
518 +
        for c in crate::chunk::chunks(content) {
519 +
            self.store.put_chunk_nocompress(c)?;
520 +
        }
521 +
        Ok(self.store.put(ObjectType::Blob, &Self::blob_for(content).encode())?)
522 +
    }
523 +
524 +
    fn locks_dir(&self) -> PathBuf {
525 +
        self.oxy.join("locks")
526 +
    }
527 +
528 +
    /// Whether `path` is locked locally for editing (spec §18, advisory).
529 +
    pub fn is_locked(&self, path: &str) -> bool {
530 +
        self.locks_dir().join(path.replace('/', "%2F")).exists()
531 +
    }
532 +
533 +
    /// Take the advisory local lock on a `lockable` file and make it writable.
534 +
    pub fn lock_path(&self, path: &str) -> Result<()> {
535 +
        let attrs = self.disk_attributes();
536 +
        if !attrs.lookup(path).lockable {
537 +
            return fail(format!("{path} is not marked lockable in .oxyattributes"));
538 +
        }
539 +
        fs::create_dir_all(self.locks_dir())?;
540 +
        write_atomic(&self.locks_dir().join(path.replace('/', "%2F")), std::process::id().to_string().as_bytes())?;
541 +
        set_readonly(&self.root.join(path), false)?;
542 +
        Ok(())
543 +
    }
544 +
545 +
    /// Release the local lock and make the file read-only again.
546 +
    pub fn unlock_path(&self, path: &str) -> Result<()> {
547 +
        let _ = fs::remove_file(self.locks_dir().join(path.replace('/', "%2F")));
548 +
        if self.root.join(path).exists() {
549 +
            set_readonly(&self.root.join(path), true)?;
550 +
        }
551 +
        Ok(())
552 +
    }
553 +
485 554
    /// Whether a repository path is inside the sparse filter (spec §17).
486 555
    pub fn in_scope(&self, path: &str) -> bool {
487 556
        self.config.sparse_paths.is_empty() || self.config.sparse_paths.iter().any(|p| path == p || path.starts_with(&format!("{p}/")))
…
104 unchanged lines
592 661
    if m.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }
593 662
}
594 663
664 +
fn set_readonly(path: &Path, readonly: bool) -> std::io::Result<()> {
665 +
    let mut perms = fs::metadata(path)?.permissions();
666 +
    perms.set_readonly(readonly);
667 +
    fs::set_permissions(path, perms)
668 +
}
669 +
595 670
#[cfg(unix)]
596 671
fn set_executable(path: &Path, exec: bool) -> std::io::Result<()> {
597 672
    use std::os::unix::fs::PermissionsExt;
…
34 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +61 −29
…
66 unchanged lines
67 67
68 68
    /// Like `build_against`, for files given in memory (imports).
69 69
    pub(crate) fn build_from_entries(&self, base: &State, base_snapshot: &Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>, fs_entries: &BTreeMap<String, FsEntry>) -> Result<BuiltChange> {
70 -
        let mut b = ChangeBuilder::new(base, base_snapshot, reuse);
70 +
        // Attributes come from the files being committed, so a new
71 +
        // `.oxyattributes` applies to the same change that adds it.
72 +
        let attrs = crate::attributes::Attributes::from_files(
73 +
            fs_entries.iter().filter(|(p, _)| p.as_str() == ".oxyattributes" || p.ends_with("/.oxyattributes")).map(|(p, e)| (p.as_str(), e.content.as_slice())),
74 +
        );
75 +
        let mut b = ChangeBuilder::new(base, base_snapshot, reuse).with_attributes(attrs);
71 76
        for (path, fs_entry) in fs_entries {
72 77
            match base_snapshot.files.get(path) {
73 78
                None => b.add_path(self, path, fs_entry)?,
…
163 unchanged lines
237 242
    /// Changes that contributed to conflicts this working copy resolves.
238 243
    contributors: BTreeSet<ChangeHash>,
239 244
    resolves: bool,
245 +
    attrs: crate::attributes::Attributes,
240 246
}
241 247
242 248
impl<'a> ChangeBuilder<'a> {
…
5 unchanged lines
248 254
                dir_nodes.insert(p.clone(), f.node);
249 255
            }
250 256
        }
251 -
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse, contributors: BTreeSet::new(), resolves: false }
257 +
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse, contributors: BTreeSet::new(), resolves: false, attrs: crate::attributes::Attributes::default() }
252 258
    }
253 259
260 +
    fn with_attributes(mut self, attrs: crate::attributes::Attributes) -> Self {
261 +
        self.attrs = attrs;
262 +
        self
263 +
    }
264 +
265 +
    /// Whether a file is opaque: forced by `binary`, forced text by `text`
266 +
    /// only when it has no NUL bytes, else sniffed.
267 +
    fn opaque_for(&self, path: &str, e: &FsEntry) -> bool {
268 +
        if e.kind == NodeKind::Symlink {
269 +
            return true;
270 +
        }
271 +
        match self.attrs.lookup(path).binary {
272 +
            Some(true) => true,
273 +
            Some(false) => is_binary(&e.content),
274 +
            None => is_binary(&e.content),
275 +
        }
276 +
    }
277 +
278 +
    /// Text content as stored: `\r\n` normalized for `eol=` files.
279 +
    fn stored_text(&self, path: &str, content: &[u8]) -> Vec<u8> {
280 +
        if self.attrs.lookup(path).eol.is_some() { crate::attributes::normalize_eol(content) } else { content.to_vec() }
281 +
    }
282 +
254 283
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
255 284
        self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n)
256 285
    }
…
26 unchanged lines
283 312
        let (dir, name) = split_path(path);
284 313
        let parent = self.ensure_dir(dir);
285 314
        let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random);
286 -
        let opaque = e.kind == NodeKind::Symlink || is_binary(&e.content);
315 +
        let opaque = self.opaque_for(path, e);
316 +
        let nocompress = self.attrs.lookup(path).nocompress;
287 317
        self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() });
288 318
        if opaque {
289 -
            let blob = repo.store_blob(&e.content)?;
319 +
            let blob = repo.store_blob_for(&e.content, nocompress)?;
290 320
            self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
291 321
        } else if !e.content.is_empty() {
322 +
            let content = self.stored_text(path, &e.content);
292 323
            self.edits.push(Edit::Insert {
293 324
                node,
294 325
                after: crate::objects::AtomRef::Start,
295 326
                before: crate::objects::AtomRef::End,
296 -
                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
327 +
                lines: split_lines(&content).iter().map(|l| l.to_vec()).collect(),
297 328
            });
298 329
        }
299 330
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added });
…
22 unchanged lines
322 353
        let node = expected.node;
323 354
        let mode_atom = entry.mode_atom.map(op_ref);
324 355
        let mut status: Option<ChangeStatus> = None;
356 +
        let wants_opaque = self.opaque_for(path, e);
357 +
        let nocompress = self.attrs.lookup(path).nocompress;
358 +
        let disk_text = self.stored_text(path, &e.content);
325 359
        match &expected.content {
326 360
            FileContent::Dir => {}
327 361
            FileContent::Text { bytes, materialized } => {
328 362
                if materialized.is_conflicted() {
329 -
                    if bytes == &e.content {
363 +
                    if bytes == &disk_text {
330 364
                        return Ok(());
331 365
                    }
332 366
                    if has_conflict_markers(&e.content) {
…
5 unchanged lines
338 372
                    // Whatever the author kept from a region is re-inserted as
339 373
                    // new atoms, which leaves the graph totally ordered.
340 374
                    let regions = self.base.conflict_regions(&node);
341 -
                    let mut edits = text_edits(node, &regions.plain, &e.content);
375 +
                    let mut edits = text_edits(node, &regions.plain, &disk_text);
342 376
                    if !regions.region_atoms.is_empty() {
343 377
                        edits.insert(0, Edit::Delete { node, atoms: ranges(&regions.region_atoms) });
344 378
                    }
…
8 unchanged lines
353 387
                    self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Resolved });
354 388
                    return Ok(());
355 389
                }
356 -
                if bytes != &e.content {
357 -
                    if is_binary(&e.content) {
358 -
                        // Text → opaque (spec §7.5).
359 -
                        if let Some(old) = mode_atom {
360 -
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
361 -
                        }
362 -
                        let live: Vec<_> = repo_live_keys(self.base, &node);
363 -
                        if !live.is_empty() {
364 -
                            self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
365 -
                        }
366 -
                        let blob = repo.store_blob(&e.content)?;
367 -
                        self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
390 +
                if wants_opaque {
391 +
                    // Text → opaque (spec §7.5), by content or by attribute.
392 +
                    if let Some(old) = mode_atom {
393 +
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
394 +
                    }
395 +
                    let live: Vec<_> = repo_live_keys(self.base, &node);
396 +
                    if !live.is_empty() {
397 +
                        self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
398 +
                    }
399 +
                    let blob = repo.store_blob_for(&e.content, nocompress)?;
400 +
                    self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
401 +
                    status = Some(ChangeStatus::Modified);
402 +
                } else if bytes != &disk_text {
403 +
                    let old = self.base.live_lines(&node);
404 +
                    let edits = text_edits(node, &old, &disk_text);
405 +
                    if !edits.is_empty() {
406 +
                        self.edits.extend(edits);
368 407
                        status = Some(ChangeStatus::Modified);
369 -
                    } else {
370 -
                        let old = self.base.live_lines(&node);
371 -
                        let edits = text_edits(node, &old, &e.content);
372 -
                        if !edits.is_empty() {
373 -
                            self.edits.extend(edits);
374 -
                            status = Some(ChangeStatus::Modified);
375 -
                        }
376 408
                    }
377 409
                }
378 -
                let switched_to_opaque = status == Some(ChangeStatus::Modified) && is_binary(&e.content);
410 +
                let switched_to_opaque = wants_opaque;
379 411
                if !switched_to_opaque && e.exec != expected.mode.exec {
380 412
                    if let Some(old) = mode_atom {
381 413
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
…
11 unchanged lines
393 425
                }
394 426
                let current = blobs.first().copied();
395 427
                if current != Some(new_ref) {
396 -
                    if e.kind == NodeKind::File && !is_binary(&e.content) {
428 +
                    if e.kind == NodeKind::File && !wants_opaque {
397 429
                        // Opaque → text (spec §7.5).
398 430
                        if let Some(old) = mode_atom {
399 431
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
…
8 unchanged lines
408 440
                            });
409 441
                        }
410 442
                    } else {
411 -
                        let blob = repo.store_blob(&e.content)?;
443 +
                        let blob = repo.store_blob_for(&e.content, nocompress)?;
412 444
                        self.edits.push(Edit::Replace { node, old: current, new: Some(blob) });
413 445
                    }
414 446
                    status = Some(ChangeStatus::Modified);
…
187 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +36 −0
…
1437 unchanged lines
1438 1438
    fs::File::open(&lock_path).unwrap().set_modified(old).unwrap();
1439 1439
    assert!(repo.lock_snapshot().is_ok(), "stale lock taken over");
1440 1440
}
1441 +
1442 +
#[test]
1443 +
fn attributes_binary_eol_nocompress_lockable() {
1444 +
    hermetic();
1445 +
    let dir = tempfile::tempdir().unwrap();
1446 +
    let root = dir.path();
1447 +
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
1448 +
    write(root, ".oxyattributes", b"*.dat binary nocompress\n*.txt eol=crlf\nassets/** lockable\n");
1449 +
    write(root, "plain.dat", b"no nul bytes here\n");
1450 +
    write(root, "notes.txt", b"one\r\ntwo\r\n");
1451 +
    write(root, "assets/model.bin", b"\0model");
1452 +
    let h = repo.commit("attrs", &[]).unwrap();
1453 +
    let snap = repo.load_state(&[h]).unwrap().materialize();
1454 +
    assert!(snap.files["plain.dat"].mode.opaque, "binary attribute forces opaque without NUL bytes");
1455 +
    assert!(matches!(&snap.files["notes.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n"), "stored with LF");
1456 +
    assert!(!repo.snapshot("status").unwrap().changed, "CRLF on disk equals LF in the state");
1457 +
    // nocompress chunks pack raw.
1458 +
    let r = repo.gc(true).unwrap();
1459 +
    assert!(r.packed.objects > 0);
1460 +
    // Checkout writes CRLF for eol=crlf and makes lockable files read-only.
1461 +
    fs::remove_file(root.join("notes.txt")).unwrap();
1462 +
    repo.checkout(&[h], true, "co").unwrap();
1463 +
    assert_eq!(fs::read(root.join("notes.txt")).unwrap(), b"one\r\ntwo\r\n");
1464 +
    assert!(fs::metadata(root.join("assets/model.bin")).unwrap().permissions().readonly());
1465 +
    assert!(repo.lock_path("plain.dat").is_err(), "not lockable");
1466 +
    repo.lock_path("assets/model.bin").unwrap();
1467 +
    assert!(!fs::metadata(root.join("assets/model.bin")).unwrap().permissions().readonly());
1468 +
    assert!(repo.is_locked("assets/model.bin"));
1469 +
    write(root, "assets/model.bin", b"\0model2");
1470 +
    repo.commit("edit model", &[]).unwrap();
1471 +
    repo.unlock_path("assets/model.bin").unwrap();
1472 +
    assert!(fs::metadata(root.join("assets/model.bin")).unwrap().permissions().readonly());
1473 +
    // A later checkout can still overwrite a read-only lockable file.
1474 +
    repo.checkout(&[h], true, "co").unwrap();
1475 +
    assert_eq!(fs::read(root.join("assets/model.bin")).unwrap(), b"\0model");
1476 +
}
modified crates/oxy-core/src/store.rs +36 −2
…
326 unchanged lines
327 327
            let mut offset = PACK_MAGIC.len() as u64;
328 328
            for r in &refs {
329 329
                let (ty, body) = self.get(r)?;
330 -
                let compressed = zstd::bulk::compress(&body, ZSTD_LEVEL).map_err(|e| StoreError::Corrupt(*r, format!("zstd: {e}")))?;
331 -
                let (flags, stored) = if compressed.len() < body.len() { (FLAG_ZSTD, compressed) } else { (0, body.clone()) };
330 +
                let (flags, stored) = if self.is_nocompress(r) {
331 +
                    (0, body.clone())
332 +
                } else {
333 +
                    let compressed = zstd::bulk::compress(&body, ZSTD_LEVEL).map_err(|e| StoreError::Corrupt(*r, format!("zstd: {e}")))?;
334 +
                    if compressed.len() < body.len() { (FLAG_ZSTD, compressed) } else { (0, body.clone()) }
335 +
                };
332 336
                let header_len = 1 + 1 + 4 + 4 + 32;
333 337
                f.write_all(&[type_index(ty), flags])?;
334 338
                f.write_all(&(stored.len() as u32).to_le_bytes())?;
…
124 unchanged lines
459 463
        self.put(ObjectType::Chunk, bytes)
460 464
    }
461 465
466 +
    /// Store a chunk that packs will keep uncompressed (`nocompress`, spec §13.3).
467 +
    pub fn put_chunk_nocompress(&self, bytes: &[u8]) -> Result<ObjectRef> {
468 +
        let r = self.put(ObjectType::Chunk, bytes)?;
469 +
        let dir = self.dir.join("nocompress");
470 +
        fs::create_dir_all(&dir)?;
471 +
        let _ = fs::File::create(dir.join(r.to_hex()));
472 +
        Ok(r)
473 +
    }
474 +
475 +
    fn is_nocompress(&self, r: &ObjectRef) -> bool {
476 +
        self.dir.join("nocompress").join(r.to_hex()).exists()
477 +
    }
478 +
462 479
    pub fn put_state(&self, st: &StateObject) -> Result<ObjectRef> {
463 480
        self.put(ObjectType::State, &st.encode())
464 481
    }
…
172 unchanged lines
637 654
    }
638 655
639 656
    #[test]
657 +
    fn nocompress_chunks_pack_raw() {
658 +
        let dir = tempfile::tempdir().unwrap();
659 +
        let s = Store::open(dir.path()).unwrap();
660 +
        let body = vec![b'a'; 10_000];
661 +
        let raw = s.put_chunk_nocompress(&body).unwrap();
662 +
        let compressible = s.put(ObjectType::Chunk, &vec![b'b'; 10_000]).unwrap();
663 +
        s.pack_loose().unwrap();
664 +
        let packs = s.packs.borrow();
665 +
        let (i, e) = s.find_packed(&raw).unwrap();
666 +
        assert_eq!((e.flags, e.stored_len as usize), (0, 10_000));
667 +
        let (_, c) = s.find_packed(&compressible).unwrap();
668 +
        assert_eq!(c.flags, FLAG_ZSTD);
669 +
        assert!(c.stored_len < 10_000);
670 +
        assert_eq!(packs[i].read(&raw, e).unwrap().1, body);
671 +
    }
672 +
673 +
    #[test]
640 674
    fn many_packs_merge_into_one() {
641 675
        let dir = tempfile::tempdir().unwrap();
642 676
        let s = Store::open(dir.path()).unwrap();
…
74 unchanged lines
modified docs/spec.md +5 −3
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.6i |
5 +
| **Status** | Draft 0.6j |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
725 unchanged lines
734 734
| File | Purpose |
735 735
|---|---|
736 736
| `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. |
737 -
| `.oxyattributes` | Per-path attributes: `binary`, `eol=lf|crlf`, `nocompress`, `lockable`, `filter=<name>`. Gitattributes syntax. |
737 +
| `.oxyattributes` | Per-path attributes: `binary` (force opaque), `text` (text unless NUL bytes), `eol=lf|crlf` (store LF; check out as given), `nocompress` (chunks packed raw), `lockable` (read-only until `oxy lock`), `filter=<name>`. Gitattributes syntax: a pattern without `/` matches basenames below the file's directory, one with `/` is anchored to it; later rules and deeper files override. The attributes in force for a change are those in the files being committed. |
738 738
| `.oxy/config` | Repository-local settings, TOML. |
739 739
| `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `core.sign`, and `remote.<name>.token`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. |
740 740
…
101 unchanged lines
842 842
843 843
---
844 844
845 -
*Changelog.* Draft 0.6i (2026-09-27): secondary workspaces and their `.oxy` file (§12.2).
845 +
*Changelog.* Draft 0.6j (2026-09-27): `.oxyattributes` semantics (§19).
846 +
847 +
Draft 0.6i (2026-09-27): secondary workspaces and their `.oxy` file (§12.2).
846 848
847 849
Draft 0.6h (2026-09-27): tags in views, on the wire, and under policy (§5.5, §10.2, §16.3).
848 850
…
31 unchanged lines
modified src/cli/mod.rs +6 −0
…
107 unchanged lines
108 108
        #[command(subcommand)]
109 109
        cmd: OpCmd,
110 110
    },
111 +
    /// Take the advisory local lock on a lockable file, making it writable
112 +
    Lock { path: String },
113 +
    /// Release the local lock and make the file read-only again
114 +
    Unlock { path: String },
111 115
    /// Workspaces: extra checkout directories sharing this repository
112 116
    Workspace {
113 117
        #[command(subcommand)]
…
174 unchanged lines
288 292
        Cmd::Clone { url, dest, max_blob, sparse, depth } => sync::clone(&url, dest, max_blob.as_deref(), &sparse, depth),
289 293
        Cmd::Deepen { depth, remote } => sync::deepen(&remote, depth),
290 294
        Cmd::Sparse { paths } => workspace::sparse(&paths),
295 +
        Cmd::Lock { path } => workspace::lock(&path),
296 +
        Cmd::Unlock { path } => workspace::unlock(&path),
291 297
        Cmd::Workspace { cmd } => match cmd {
292 298
            WorkspaceCmd::Add { path, changes } => workspace::workspace_add(&path, &changes),
293 299
            WorkspaceCmd::List => workspace::workspace_list(),
…
32 unchanged lines
modified src/cli/workspace.rs +14 −0
…
393 unchanged lines
394 394
    println!("{} workspace {} at {}; its files were left in place.", ui::ok("Forgot"), ui::id(w.id.short(8)), ui::path(w.path.display()));
395 395
    Ok(0)
396 396
}
397 +
398 +
pub fn lock(path: &str) -> Result<i32> {
399 +
    let repo = open()?;
400 +
    repo.lock_path(path)?;
401 +
    println!("{} {} (advisory, local)", ui::ok("Locked"), ui::path(path));
402 +
    Ok(0)
403 +
}
404 +
405 +
pub fn unlock(path: &str) -> Result<i32> {
406 +
    let repo = open()?;
407 +
    repo.unlock_path(path)?;
408 +
    println!("{} {}", ui::ok("Unlocked"), ui::path(path));
409 +
    Ok(0)
410 +
}