.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, ®ions.plain, &e.content); |
|
| 375 | + | let mut edits = text_edits(node, ®ions.plain, &disk_text); |
|
| 342 | 376 | if !regions.region_atoms.is_empty() { |
|
| 343 | 377 | edits.insert(0, Edit::Delete { node, atoms: ranges(®ions.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 | + | } |