Manifest as a flat binary file, hashes and graph keys as bytes (refs #41) 1966f5db15ee
The workspace manifest is now read in one pass with no per-line parse, and content hashes and graph keys travel as 32-byte arrays rather than hex strings through the walk, snapshot and checkout. On the Linux tree the manifest shrinks from 19 MB to 12.9 MB and clean status takes 0.24 s, level with Git. An older text manifest reads as none and is rewritten.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-30 21:13 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
1966f5db15ee42be2e5abc0301434dfe5d7371acd55e53664c8b4a02fbaf1f86- Depends on
- 0554aaa7ddef 135212de5370 40a40bb7b798 75167672ebcd 76740263089c 8457a6cc6984 86e1824f2d0b 982864068a59 992467d31d26 b7f102fcbb17 c5ce0367eddf cc7a00565161 d0990c2bff74 d3cd7ef95da6 da868d5d2646 dfb23a3a8cfa e7ddd552b619
- Depended on by
- 94cdbc576ce1 e96a430c78f3 1ec3f008e96a 3604179679eb d4dbab97e948 f9466021286a
- 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.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
- 45 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified crates/oxy-core/src/repo/checkout.rs +6 −6
| … | 102 unchanged lines |
||
| 103 | 103 | // The manifest after the checkout, built from what is known. |
|
| 104 | 104 | let plan = crate::timing::phase("checkout: plan"); |
|
| 105 | 105 | let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new(); |
|
| 106 | - | let hex = |b: &[u8]| blake3::hash(b).to_hex().to_string(); |
|
| 106 | + | let hex = |b: &[u8]| *blake3::hash(b).as_bytes(); |
|
| 107 | 107 | // Text files to render, attribute files first (the others need them). |
|
| 108 | 108 | let mut texts: Vec<(String, NodeId, Mode)> = Vec::new(); |
|
| 109 | 109 | for (path, e) in tview.entries.iter() { |
|
| … | 12 unchanged lines |
||
| 122 | 122 | // The manifest proves the file on disk equal to the rendering |
|
| 123 | 123 | // of a graph whose key is the target's: nothing to read, |
|
| 124 | 124 | // render or write. |
|
| 125 | - | let key = target.graph_key(&e.node).map(|k| ObjectRef(k).to_hex()); |
|
| 125 | + | let key = target.graph_key(&e.node); |
|
| 126 | 126 | let proven = |m: &&prompt::ManifestEntry| { |
|
| 127 | 127 | !is_attributes(path) && key.is_some() && m.graph == key |
|
| 128 | 128 | }; |
|
| … | 2 unchanged lines |
||
| 131 | 131 | entries.insert( |
|
| 132 | 132 | path.clone(), |
|
| 133 | 133 | FsEntry { |
|
| 134 | - | content_hash: Some(m.hash.clone()), |
|
| 135 | - | graph_key: m.graph.clone(), |
|
| 134 | + | content_hash: Some(m.hash), |
|
| 135 | + | graph_key: m.graph, |
|
| 136 | 136 | unchanged: true, |
|
| 137 | 137 | stat: Some((m.size, m.mtime_ns)), |
|
| 138 | 138 | ..FsEntry::new(NodeKind::File, e.mode.exec, Vec::new()) |
|
| … | 33 unchanged lines |
||
| 172 | 172 | entries.insert( |
|
| 173 | 173 | path.clone(), |
|
| 174 | 174 | FsEntry { |
|
| 175 | - | content_hash: Some(m.hash.clone()), |
|
| 175 | + | content_hash: Some(m.hash), |
|
| 176 | 176 | blob: (e.kind == NodeKind::File).then_some(blob), |
|
| 177 | 177 | stat: Some((m.size, m.mtime_ns)), |
|
| 178 | 178 | ..FsEntry::new(e.kind, e.mode.exec, Vec::new()) |
|
| … | 103 unchanged lines |
||
| 282 | 282 | FsEntry { |
|
| 283 | 283 | content_hash: Some(hex(&on_disk)), |
|
| 284 | 284 | graph_key: (!conflicted && a.eol.is_none()) |
|
| 285 | - | .then(|| keys.get(node).map(|k| ObjectRef(*k).to_hex())) |
|
| 285 | + | .then(|| keys.get(node).copied()) |
|
| 286 | 286 | .flatten(), |
|
| 287 | 287 | ..FsEntry::new(NodeKind::File, mode.exec, Vec::new()) |
|
| 288 | 288 | }, |
|
| … | 169 unchanged lines |
modified crates/oxy-core/src/repo/mod.rs +10 −13
| … | 131 unchanged lines |
||
| 132 | 132 | } |
|
| 133 | 133 | ||
| 134 | 134 | /// A text file's base graph key, by path, as a walk asks for it. |
|
| 135 | - | pub(crate) type KeyOf<'a> = dyn Fn(&str) -> Option<String> + Sync + 'a; |
|
| 135 | + | pub(crate) type KeyOf<'a> = dyn Fn(&str) -> Option<[u8; 32]> + Sync + 'a; |
|
| 136 | 136 | ||
| 137 | 137 | /// A file as found on disk or in an imported tree. |
|
| 138 | 138 | pub(crate) struct FsEntry { |
|
| … | 8 unchanged lines |
||
| 147 | 147 | pub blob: Option<ObjectRef>, |
|
| 148 | 148 | /// BLAKE3 of the content, carried over from the manifest when the stat |
|
| 149 | 149 | /// is unchanged, so the manifest can be rewritten without rehashing. |
|
| 150 | - | pub content_hash: Option<String>, |
|
| 150 | + | pub content_hash: Option<[u8; 32]>, |
|
| 151 | 151 | /// A text file known equal to the base's rendering without reading it: |
|
| 152 | 152 | /// its stat matches the manifest and the manifest recorded it equal to |
|
| 153 | 153 | /// a base file graph with the same key (spec §12.3). `content` is empty. |
|
| 154 | 154 | pub unchanged: bool, |
|
| 155 | 155 | /// The base graph key this file equals, for the next manifest. |
|
| 156 | - | pub graph_key: Option<String>, |
|
| 156 | + | pub graph_key: Option<[u8; 32]>, |
|
| 157 | 157 | /// Size and mtime already known to be current (a checkout leaving the |
|
| 158 | 158 | /// file as it was), so the manifest need not stat it again. |
|
| 159 | 159 | pub stat: Option<(u64, u128)>, |
|
| … | 1066 unchanged lines |
||
| 1226 | 1226 | stat_cached(rel, meta, cache).is_some_and(|e| e.graph.is_some()) |
|
| 1227 | 1227 | }) |
|
| 1228 | 1228 | .and_then(|k| k(rel)); |
|
| 1229 | - | Ok(( |
|
| 1230 | - | rel.clone(), |
|
| 1231 | - | read_file_entry(rel, path, meta, cache, key.as_deref())?, |
|
| 1232 | - | )) |
|
| 1229 | + | Ok((rel.clone(), read_file_entry(rel, path, meta, cache, key)?)) |
|
| 1233 | 1230 | }) |
|
| 1234 | 1231 | .collect::<Result<Vec<_>>>()?; |
|
| 1235 | 1232 | for (rel, mut e) in read { |
|
| … | 417 unchanged lines |
||
| 1653 | 1650 | path: &Path, |
|
| 1654 | 1651 | meta: &fs::Metadata, |
|
| 1655 | 1652 | cache: Option<&prompt::Manifest>, |
|
| 1656 | - | key: Option<&str>, |
|
| 1653 | + | key: Option<[u8; 32]>, |
|
| 1657 | 1654 | ) -> Result<FsEntry> { |
|
| 1658 | 1655 | let exec = is_executable(meta); |
|
| 1659 | 1656 | let stat = Some((meta.len(), prompt::mtime_ns(meta))); |
|
| 1660 | 1657 | let mut e = match stat_cached(rel, meta, cache) { |
|
| 1661 | 1658 | Some(e) if e.blob.is_some() => FsEntry { |
|
| 1662 | 1659 | blob: e.blob, |
|
| 1663 | - | content_hash: Some(e.hash.clone()), |
|
| 1660 | + | content_hash: Some(e.hash), |
|
| 1664 | 1661 | ..FsEntry::new(NodeKind::File, exec, vec![]) |
|
| 1665 | 1662 | }, |
|
| 1666 | - | Some(e) if key.is_some() && e.graph.as_deref() == key => FsEntry { |
|
| 1667 | - | content_hash: Some(e.hash.clone()), |
|
| 1663 | + | Some(e) if key.is_some() && e.graph == key => FsEntry { |
|
| 1664 | + | content_hash: Some(e.hash), |
|
| 1668 | 1665 | unchanged: true, |
|
| 1669 | - | graph_key: e.graph.clone(), |
|
| 1666 | + | graph_key: e.graph, |
|
| 1670 | 1667 | ..FsEntry::new(NodeKind::File, exec, vec![]) |
|
| 1671 | 1668 | }, |
|
| 1672 | 1669 | Some(e) => FsEntry { |
|
| 1673 | - | content_hash: Some(e.hash.clone()), |
|
| 1670 | + | content_hash: Some(e.hash), |
|
| 1674 | 1671 | ..FsEntry::new(NodeKind::File, exec, fs::read(path)?) |
|
| 1675 | 1672 | }, |
|
| 1676 | 1673 | None => FsEntry::new(NodeKind::File, exec, fs::read(path)?), |
|
| … | 6 unchanged lines |
modified crates/oxy-core/src/repo/prompt.rs +121 −123
| … | 130 unchanged lines |
||
| 131 | 131 | pub(crate) struct ManifestEntry { |
|
| 132 | 132 | pub size: u64, |
|
| 133 | 133 | pub mtime_ns: u128, |
|
| 134 | - | pub hash: String, |
|
| 134 | + | pub hash: [u8; 32], |
|
| 135 | 135 | pub blob: Option<ObjectRef>, |
|
| 136 | 136 | /// The graph key (`State::graph_key`) of the base file this text file |
|
| 137 | 137 | /// was found equal to, rendered, byte for byte. |
|
| 138 | - | pub graph: Option<String>, |
|
| 138 | + | pub graph: Option<[u8; 32]>, |
|
| 139 | 139 | /// Whether the file was executable: a mode change alters neither size |
|
| 140 | 140 | /// nor time. |
|
| 141 | 141 | pub exec: bool, |
|
| … | 24 unchanged lines |
||
| 166 | 166 | /// A format with glyphs: icon, bookmark, change id, state, ahead/behind. |
|
| 167 | 167 | pub const GLYPH_PROMPT_FORMAT: &str = "%i %b %c %s%U%O"; |
|
| 168 | 168 | ||
| 169 | - | /// One manifest line per file: size, mtime in nanoseconds, hash, blob ref |
|
| 170 | - | /// or `-`, graph key or `-`, `x` when executable or `-`, path. |
|
| 171 | - | fn manifest_line( |
|
| 172 | - | path: &str, |
|
| 173 | - | size: u64, |
|
| 174 | - | mtime_ns: u128, |
|
| 175 | - | hash: &str, |
|
| 176 | - | blob: Option<ObjectRef>, |
|
| 177 | - | graph: Option<&str>, |
|
| 178 | - | exec: bool, |
|
| 179 | - | ) -> String { |
|
| 180 | - | format!( |
|
| 181 | - | "{size}\t{mtime_ns}\t{hash}\t{}\t{}\t{}\t{path}\n", |
|
| 182 | - | blob.map(|b| b.to_hex()).unwrap_or_else(|| "-".into()), |
|
| 183 | - | graph.unwrap_or("-"), |
|
| 184 | - | if exec { "x" } else { "-" } |
|
| 185 | - | ) |
|
| 169 | + | /// The manifest file (oxygen#41): a flat binary layout read in one pass. |
|
| 170 | + | /// A magic number; the conflicted and uncommitted flags; when it was |
|
| 171 | + | /// written, at a fixed offset so an otherwise equal manifest can be |
|
| 172 | + | /// recognised; then each entry in path order: path, size, mtime in |
|
| 173 | + | /// nanoseconds, BLAKE3 of the content, the stored blob of an opaque file, |
|
| 174 | + | /// the graph key it was found equal to, and whether it is executable. A |
|
| 175 | + | /// file in any other layout (an older text manifest) reads as no manifest, |
|
| 176 | + | /// and the next snapshot writes it again. |
|
| 177 | + | const MANIFEST_MAGIC: &[u8; 8] = b"oxyman1\0"; |
|
| 178 | + | /// Where the written time sits: after the magic and the two flags. |
|
| 179 | + | const MANIFEST_WRITTEN: std::ops::Range<usize> = 10..26; |
|
| 180 | + | ||
| 181 | + | fn encode_manifest( |
|
| 182 | + | conflicted: bool, |
|
| 183 | + | uncommitted: bool, |
|
| 184 | + | written_ns: u128, |
|
| 185 | + | entries: &mut dyn Iterator<Item = (&str, ManifestEntry)>, |
|
| 186 | + | ) -> Vec<u8> { |
|
| 187 | + | let mut w = crate::flat::Writer::default(); |
|
| 188 | + | w.fixed(MANIFEST_MAGIC); |
|
| 189 | + | w.u8(conflicted as u8); |
|
| 190 | + | w.u8(uncommitted as u8); |
|
| 191 | + | w.fixed(&written_ns.to_le_bytes()); |
|
| 192 | + | for (path, e) in entries { |
|
| 193 | + | w.bytes(path.as_bytes()); |
|
| 194 | + | w.u64(e.size); |
|
| 195 | + | w.fixed(&e.mtime_ns.to_le_bytes()); |
|
| 196 | + | w.fixed(&e.hash); |
|
| 197 | + | w.opt_object(&e.blob); |
|
| 198 | + | match e.graph { |
|
| 199 | + | None => w.u8(0), |
|
| 200 | + | Some(k) => { |
|
| 201 | + | w.u8(1); |
|
| 202 | + | w.fixed(&k); |
|
| 203 | + | } |
|
| 204 | + | } |
|
| 205 | + | w.u8(e.exec as u8); |
|
| 206 | + | } |
|
| 207 | + | w.0 |
|
| 186 | 208 | } |
|
| 187 | 209 | ||
| 210 | + | fn decode_manifest(buf: &[u8]) -> Option<(bool, bool, Manifest)> { |
|
| 211 | + | let mut r = crate::flat::Reader::new(buf); |
|
| 212 | + | if &r.fixed::<8>().ok()? != MANIFEST_MAGIC { |
|
| 213 | + | return None; |
|
| 214 | + | } |
|
| 215 | + | let conflicted = r.u8().ok()? == 1; |
|
| 216 | + | let uncommitted = r.u8().ok()? == 1; |
|
| 217 | + | let written_ns = u128::from_le_bytes(r.fixed().ok()?); |
|
| 218 | + | let mut entries = Vec::new(); |
|
| 219 | + | while !r.at_end() { |
|
| 220 | + | let path = std::str::from_utf8(r.bytes().ok()?).ok()?.to_string(); |
|
| 221 | + | let e = ManifestEntry { |
|
| 222 | + | size: r.u64().ok()?, |
|
| 223 | + | mtime_ns: u128::from_le_bytes(r.fixed().ok()?), |
|
| 224 | + | hash: r.fixed().ok()?, |
|
| 225 | + | blob: r.opt_object().ok()?, |
|
| 226 | + | graph: match r.u8().ok()? { |
|
| 227 | + | 0 => None, |
|
| 228 | + | _ => Some(r.fixed().ok()?), |
|
| 229 | + | }, |
|
| 230 | + | exec: r.u8().ok()? == 1, |
|
| 231 | + | }; |
|
| 232 | + | entries.push((path, e)); |
|
| 233 | + | } |
|
| 234 | + | // Written in path order, so this builds the map without searching. |
|
| 235 | + | let entries = entries.into_iter().collect(); |
|
| 236 | + | Some(( |
|
| 237 | + | conflicted, |
|
| 238 | + | uncommitted, |
|
| 239 | + | Manifest { |
|
| 240 | + | written_ns, |
|
| 241 | + | entries, |
|
| 242 | + | }, |
|
| 243 | + | )) |
|
| 244 | + | } |
|
| 245 | + | ||
| 188 | 246 | impl Repo { |
|
| 189 | 247 | pub(crate) fn manifest_path(&self) -> PathBuf { |
|
| 190 | 248 | self.oxy |
|
| … | 25 unchanged lines |
||
| 216 | 274 | .duration_since(UNIX_EPOCH) |
|
| 217 | 275 | .map(|d| d.as_nanos()) |
|
| 218 | 276 | .unwrap_or(0); |
|
| 219 | - | let head = format!( |
|
| 220 | - | "# conflicted={}\n# uncommitted={}\n", |
|
| 221 | - | conflicted as u8, uncommitted as u8 |
|
| 222 | - | ); |
|
| 223 | - | let mut body = String::new(); |
|
| 224 | 277 | let mut newest_ns: u128 = 0; |
|
| 225 | - | for (path, e) in entries { |
|
| 226 | - | let full = self.root.join(path); |
|
| 278 | + | let mut rows = entries.iter().map(|(path, e)| { |
|
| 227 | 279 | let (size, mtime_ns) = match e.stat { |
|
| 228 | 280 | Some(known) => known, |
|
| 229 | - | None => match fs::symlink_metadata(&full) { |
|
| 281 | + | None => match fs::symlink_metadata(self.root.join(path)) { |
|
| 230 | 282 | Ok(m) => (m.len(), mtime_ns(&m)), |
|
| 231 | 283 | Err(_) => (e.content.len() as u64, 0), |
|
| 232 | 284 | }, |
|
| 233 | 285 | }; |
|
| 234 | - | let hash = match &e.content_hash { |
|
| 235 | - | Some(h) => h.clone(), |
|
| 236 | - | None => blake3::hash(&e.content).to_hex().to_string(), |
|
| 237 | - | }; |
|
| 238 | - | let blob = blobs.get(path).copied().or(e.blob); |
|
| 239 | 286 | newest_ns = newest_ns.max(mtime_ns); |
|
| 240 | - | body.push_str(&manifest_line( |
|
| 241 | - | path, |
|
| 287 | + | let entry = ManifestEntry { |
|
| 242 | 288 | size, |
|
| 243 | 289 | mtime_ns, |
|
| 244 | - | &hash, |
|
| 245 | - | blob, |
|
| 246 | - | e.graph_key.as_deref(), |
|
| 247 | - | e.exec, |
|
| 248 | - | )); |
|
| 249 | - | } |
|
| 290 | + | hash: e |
|
| 291 | + | .content_hash |
|
| 292 | + | .unwrap_or_else(|| *blake3::hash(&e.content).as_bytes()), |
|
| 293 | + | blob: blobs.get(path).copied().or(e.blob), |
|
| 294 | + | graph: e.graph_key, |
|
| 295 | + | exec: e.exec, |
|
| 296 | + | }; |
|
| 297 | + | (path.as_str(), entry) |
|
| 298 | + | }); |
|
| 299 | + | let new = encode_manifest(conflicted, uncommitted, written, &mut rows); |
|
| 300 | + | drop(rows); |
|
| 250 | 301 | let p = self.manifest_path(); |
|
| 251 | 302 | // Leave an unchanged manifest alone when its stamp already trusts |
|
| 252 | 303 | // every entry (none is within the racy window): rewriting it would |
|
| 253 | 304 | // change nothing but the file, which editors watching the tree |
|
| 254 | 305 | // (and subrepos inside it) take as a change. |
|
| 255 | - | if let Ok(old) = fs::read_to_string(&p) { |
|
| 256 | - | let old_written: Option<u128> = old.lines().find_map(|l| { |
|
| 257 | - | l.strip_prefix("# written=") |
|
| 258 | - | .and_then(|v| v.trim().parse().ok()) |
|
| 259 | - | }); |
|
| 260 | - | let old_rest: String = old |
|
| 261 | - | .lines() |
|
| 262 | - | .filter(|l| !l.starts_with("# written=")) |
|
| 263 | - | .map(|l| format!("{l}\n")) |
|
| 264 | - | .collect(); |
|
| 265 | - | if let Some(w) = old_written { |
|
| 266 | - | if old_rest == format!("{head}{body}") && newest_ns + 2_000_000_000 < w { |
|
| 306 | + | if let Ok(old) = fs::read(&p) { |
|
| 307 | + | let w = MANIFEST_WRITTEN; |
|
| 308 | + | if old.len() == new.len() |
|
| 309 | + | && old[..w.start] == new[..w.start] |
|
| 310 | + | && old[w.end..] == new[w.end..] |
|
| 311 | + | { |
|
| 312 | + | let old_written = u128::from_le_bytes(old[w.clone()].try_into().unwrap()); |
|
| 313 | + | if newest_ns + 2_000_000_000 < old_written { |
|
| 267 | 314 | return Ok(()); |
|
| 268 | 315 | } |
|
| 269 | 316 | } |
|
| 270 | 317 | } |
|
| 271 | - | let text = format!("{head}# written={written}\n{body}"); |
|
| 272 | 318 | fs::create_dir_all(p.parent().unwrap())?; |
|
| 273 | - | write_atomic(&p, text.as_bytes()) |
|
| 319 | + | write_atomic(&p, &new) |
|
| 274 | 320 | } |
|
| 275 | 321 | ||
| 276 | 322 | /// Change the header flags, and set the graph key of each path in |
|
| … | 3 unchanged lines |
||
| 280 | 326 | &self, |
|
| 281 | 327 | conflicted: bool, |
|
| 282 | 328 | uncommitted: bool, |
|
| 283 | - | keys: &HashMap<String, String>, |
|
| 329 | + | keys: &HashMap<String, [u8; 32]>, |
|
| 284 | 330 | ) -> Result<()> { |
|
| 285 | 331 | self.manifest_cache.take(); |
|
| 286 | 332 | self.listing_cache.take(); |
|
| 287 | 333 | let p = self.manifest_path(); |
|
| 288 | - | let text = match fs::read_to_string(&p) { |
|
| 289 | - | Ok(t) => t, |
|
| 290 | - | Err(_) => return self.write_manifest(conflicted, uncommitted), |
|
| 334 | + | let Some((_, _, m)) = fs::read(&p).ok().and_then(|b| decode_manifest(&b)) else { |
|
| 335 | + | return self.write_manifest(conflicted, uncommitted); |
|
| 291 | 336 | }; |
|
| 292 | - | let mut out = String::with_capacity(text.len()); |
|
| 293 | - | for line in text.lines() { |
|
| 294 | - | if line.starts_with("# conflicted=") { |
|
| 295 | - | out.push_str(&format!("# conflicted={}\n", conflicted as u8)); |
|
| 296 | - | } else if line.starts_with("# uncommitted=") { |
|
| 297 | - | out.push_str(&format!("# uncommitted={}\n", uncommitted as u8)); |
|
| 298 | - | } else { |
|
| 299 | - | let f: Vec<&str> = line.splitn(7, '\t').collect(); |
|
| 300 | - | match (f.len() == 7).then(|| keys.get(f[6])).flatten() { |
|
| 301 | - | Some(k) => out.push_str(&format!( |
|
| 302 | - | "{}\t{}\t{}\t{}\t{k}\t{}\t{}\n", |
|
| 303 | - | f[0], f[1], f[2], f[3], f[5], f[6] |
|
| 304 | - | )), |
|
| 305 | - | None => { |
|
| 306 | - | out.push_str(line); |
|
| 307 | - | out.push('\n'); |
|
| 308 | - | } |
|
| 309 | - | } |
|
| 337 | + | let mut rows = m.entries.iter().map(|(path, e)| { |
|
| 338 | + | let mut e = e.clone(); |
|
| 339 | + | if let Some(k) = keys.get(path) { |
|
| 340 | + | e.graph = Some(*k); |
|
| 310 | 341 | } |
|
| 311 | - | } |
|
| 312 | - | write_atomic(&p, out.as_bytes()) |
|
| 342 | + | (path.as_str(), e) |
|
| 343 | + | }); |
|
| 344 | + | write_atomic( |
|
| 345 | + | &p, |
|
| 346 | + | &encode_manifest(conflicted, uncommitted, m.written_ns, &mut rows), |
|
| 347 | + | ) |
|
| 313 | 348 | } |
|
| 314 | 349 | ||
| 315 | 350 | /// Forget the manifest after an operation that changed the working-copy |
|
| … | 34 unchanged lines |
||
| 350 | 385 | } |
|
| 351 | 386 | ||
| 352 | 387 | fn parse_manifest(&self, path: &std::path::Path) -> Result<Option<(bool, bool, Manifest)>> { |
|
| 353 | - | let text = match fs::read_to_string(path) { |
|
| 354 | - | Ok(t) => t, |
|
| 388 | + | let buf = match fs::read(path) { |
|
| 389 | + | Ok(b) => b, |
|
| 355 | 390 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), |
|
| 356 | 391 | Err(e) => return Err(e.into()), |
|
| 357 | 392 | }; |
|
| 358 | - | let mut out = Manifest::default(); |
|
| 359 | - | let mut conflicted = false; |
|
| 360 | - | let mut uncommitted = false; |
|
| 361 | - | for line in text.lines() { |
|
| 362 | - | if let Some(v) = line.strip_prefix("# conflicted=") { |
|
| 363 | - | conflicted = v.trim() == "1"; |
|
| 364 | - | continue; |
|
| 365 | - | } |
|
| 366 | - | if let Some(v) = line.strip_prefix("# uncommitted=") { |
|
| 367 | - | uncommitted = v.trim() == "1"; |
|
| 368 | - | continue; |
|
| 369 | - | } |
|
| 370 | - | if let Some(v) = line.strip_prefix("# written=") { |
|
| 371 | - | out.written_ns = v.trim().parse().unwrap_or(u128::MAX); |
|
| 372 | - | continue; |
|
| 373 | - | } |
|
| 374 | - | // A line of another shape (an older manifest) is no entry: the |
|
| 375 | - | // file is then read, and the next snapshot rewrites the line. |
|
| 376 | - | let f: Vec<&str> = line.splitn(7, '\t').collect(); |
|
| 377 | - | if f.len() == 7 { |
|
| 378 | - | out.entries.insert( |
|
| 379 | - | f[6].to_string(), |
|
| 380 | - | ManifestEntry { |
|
| 381 | - | size: f[0].parse().unwrap_or(0), |
|
| 382 | - | mtime_ns: f[1].parse().unwrap_or(0), |
|
| 383 | - | hash: f[2].to_string(), |
|
| 384 | - | blob: ObjectRef::from_hex(f[3]), |
|
| 385 | - | graph: (f[4] != "-").then(|| f[4].to_string()), |
|
| 386 | - | exec: f[5] == "x", |
|
| 387 | - | }, |
|
| 388 | - | ); |
|
| 389 | - | } |
|
| 390 | - | } |
|
| 391 | - | Ok(Some((conflicted, uncommitted, out))) |
|
| 393 | + | Ok(decode_manifest(&buf)) |
|
| 392 | 394 | } |
|
| 393 | 395 | ||
| 394 | 396 | /// Whether the working copy is certainly clean, known from the manifest |
|
| … | 18 unchanged lines |
||
| 413 | 415 | let path = self.root.join(rel); |
|
| 414 | 416 | let same = match fs::symlink_metadata(&path) { |
|
| 415 | 417 | Ok(md) if md.file_type().is_symlink() => fs::read_link(&path).is_ok_and(|t| { |
|
| 416 | - | blake3::hash(t.to_string_lossy().as_bytes()) |
|
| 417 | - | .to_hex() |
|
| 418 | - | .as_str() |
|
| 419 | - | == e.hash |
|
| 418 | + | *blake3::hash(t.to_string_lossy().as_bytes()).as_bytes() == e.hash |
|
| 420 | 419 | }), |
|
| 421 | 420 | Ok(md) if md.is_file() => { |
|
| 422 | 421 | md.len() == e.size |
|
| 423 | - | && fs::read(&path) |
|
| 424 | - | .is_ok_and(|b| blake3::hash(&b).to_hex().as_str() == e.hash) |
|
| 422 | + | && fs::read(&path).is_ok_and(|b| *blake3::hash(&b).as_bytes() == e.hash) |
|
| 425 | 423 | } |
|
| 426 | 424 | _ => false, |
|
| 427 | 425 | }; |
|
| … | 36 unchanged lines |
||
| 464 | 462 | manifest |
|
| 465 | 463 | .entries |
|
| 466 | 464 | .get(rel) |
|
| 467 | - | .is_none_or(|me| blake3::hash(target).to_hex().as_str() != me.hash) |
|
| 465 | + | .is_none_or(|me| *blake3::hash(target).as_bytes() != me.hash) |
|
| 468 | 466 | }); |
|
| 469 | 467 | if links_differ { |
|
| 470 | 468 | return Ok(true); |
|
| … | 6 unchanged lines |
||
| 477 | 475 | || super::is_executable(m) != me.exec |
|
| 478 | 476 | || (mtime_ns(m) != me.mtime_ns |
|
| 479 | 477 | && !fs::read(path) |
|
| 480 | - | .is_ok_and(|b| blake3::hash(&b).to_hex().as_str() == me.hash)) |
|
| 478 | + | .is_ok_and(|b| *blake3::hash(&b).as_bytes() == me.hash)) |
|
| 481 | 479 | }) |
|
| 482 | 480 | }); |
|
| 483 | 481 | if files_differ { |
|
| … | 16 unchanged lines |
||
| 500 | 498 | if m.len() != me.size |
|
| 501 | 499 | || super::is_executable(&m) != me.exec |
|
| 502 | 500 | || (mtime_ns(&m) != me.mtime_ns |
|
| 503 | - | && blake3::hash(&fs::read(&path)?).to_hex().as_str() != me.hash) |
|
| 501 | + | && *blake3::hash(&fs::read(&path)?).as_bytes() != me.hash) |
|
| 504 | 502 | { |
|
| 505 | 503 | return Ok(true); |
|
| 506 | 504 | } |
|
| … | 85 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +6 −9
| … | 332 unchanged lines |
||
| 333 | 333 | t |
|
| 334 | 334 | }; |
|
| 335 | 335 | // A text file's graph key, worked out when the walk asks for it. |
|
| 336 | - | let key_of = |p: &str| -> Option<String> { |
|
| 336 | + | let key_of = |p: &str| -> Option<[u8; 32]> { |
|
| 337 | 337 | let e = view.entries.get(p)?; |
|
| 338 | 338 | if e.kind != NodeKind::File || e.mode.opaque { |
|
| 339 | 339 | return None; |
|
| 340 | 340 | } |
|
| 341 | - | Some(crate::hash::ObjectRef(base.graph_key(&e.node)?).to_hex()) |
|
| 341 | + | base.graph_key(&e.node) |
|
| 342 | 342 | }; |
|
| 343 | 343 | let mut fs_entries = { |
|
| 344 | 344 | let _t = crate::timing::phase("snapshot: read working copy"); |
|
| … | 1 unchanged lines |
||
| 346 | 346 | }; |
|
| 347 | 347 | // The keys of the files read, before building graphs drops them: a |
|
| 348 | 348 | // file found equal to its base rendering keeps its key. |
|
| 349 | - | let keys: HashMap<String, String> = fs_entries |
|
| 349 | + | let keys: HashMap<String, [u8; 32]> = fs_entries |
|
| 350 | 350 | .iter() |
|
| 351 | 351 | .filter(|(_, e)| !e.unchanged && e.kind == NodeKind::File) |
|
| 352 | 352 | .filter_map(|(p, _)| Some((p.clone(), key_of(p)?))) |
|
| … | 45 unchanged lines |
||
| 398 | 398 | bytes, |
|
| 399 | 399 | materialized, |
|
| 400 | 400 | }) if !materialized.is_conflicted() && *bytes == *e.content => { |
|
| 401 | - | keys.get(p).cloned() |
|
| 401 | + | keys.get(p).copied() |
|
| 402 | 402 | } |
|
| 403 | 403 | _ => None, |
|
| 404 | 404 | }; |
|
| … | 195 unchanged lines |
||
| 600 | 600 | /// The graph key of each text file of the state at `heads`, by path, |
|
| 601 | 601 | /// from a history-only load that builds no graph. Files with an `eol` |
|
| 602 | 602 | /// attribute are left out: their bytes on disk are not the rendering. |
|
| 603 | - | fn committed_graph_keys(&self, st: &State) -> HashMap<String, String> { |
|
| 603 | + | fn committed_graph_keys(&self, st: &State) -> HashMap<String, [u8; 32]> { |
|
| 604 | 604 | let _t = crate::timing::phase("commit: graph keys"); |
|
| 605 | 605 | let attrs = self.disk_attributes(); |
|
| 606 | 606 | st.tree() |
|
| … | 3 unchanged lines |
||
| 610 | 610 | .filter(|(p, e)| { |
|
| 611 | 611 | e.kind == NodeKind::File && !e.mode.opaque && attrs.lookup(p).eol.is_none() |
|
| 612 | 612 | }) |
|
| 613 | - | .filter_map(|(p, e)| { |
|
| 614 | - | let k = st.graph_key(&e.node)?; |
|
| 615 | - | Some((p.clone(), crate::hash::ObjectRef(k).to_hex())) |
|
| 616 | - | }) |
|
| 613 | + | .filter_map(|(p, e)| Some((p.clone(), st.graph_key(&e.node)?))) |
|
| 617 | 614 | .collect() |
|
| 618 | 615 | } |
|
| 619 | 616 | ||
| … | 1934 unchanged lines |