Checkout: files added once are written from their text, new files written in parallel (refs #41) 091fef80755d
- A file whose history is one insert of its whole text (added once, never edited, no checkpoint base) is written from that text without building its graph; test builds render the graph as well and compare. - Each directory of a batch is checked and made once; files are compared on every core, new files written on every core, and files that exist rewritten one at a time (on ext4, rewriting in parallel was six times slower). - fetch has phases under OXY_TIMINGS. Local clone of the Linux tree: 16.8 to 17.3 s (from 32 s; Git 17.0 s).
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-30 23:27 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
091fef80755d422062f2abeaebd66da497997257dcd4c3955ca0cac9eba178be- Depends on
- 1c2bd9abdd4f 57d8dc05c9d4 598009a75f6c 83e0677b07fe 982864068a59 b3c5a59a875a c5ce0367eddf cc7a00565161 ff0af1ee299d
- Depended on by
- 2f0b4b0651ea c397a8c36a9f f3811eb20bea 5552e69c7325 11e2b98a3a39
- 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
- 15 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified crates/oxy-core/src/repo/checkout.rs +158 −13
| … | 233 unchanged lines |
||
| 234 | 234 | for batch in batches { |
|
| 235 | 235 | use rayon::prelude::*; |
|
| 236 | 236 | let nodes: Vec<NodeId> = batch.iter().map(|(_, n, _)| *n).collect(); |
|
| 237 | + | // A file added once and never edited is its text: no graph. |
|
| 238 | + | let single: Vec<Option<crate::objects::TextRef>> = |
|
| 239 | + | nodes.iter().map(|n| target.single_text(n)).collect(); |
|
| 240 | + | let graphs: Vec<NodeId> = nodes |
|
| 241 | + | .iter() |
|
| 242 | + | .zip(&single) |
|
| 243 | + | .filter(|(_, s)| s.is_none()) |
|
| 244 | + | .map(|(n, _)| *n) |
|
| 245 | + | .collect(); |
|
| 246 | + | let mut texts = { |
|
| 247 | + | let _t = crate::timing::phase("checkout: read single texts"); |
|
| 248 | + | let wanted: Vec<crate::objects::TextRef> = |
|
| 249 | + | single.iter().flatten().copied().collect(); |
|
| 250 | + | self.single_texts(&wanted)?.into_iter() |
|
| 251 | + | }; |
|
| 237 | 252 | { |
|
| 238 | 253 | let _t = crate::timing::phase("checkout: build graphs"); |
|
| 239 | - | self.load_graphs_of(&mut target, &nodes)?; |
|
| 254 | + | self.load_graphs_of(&mut target, &graphs)?; |
|
| 240 | 255 | } |
|
| 241 | 256 | let rendered: Vec<(Vec<u8>, bool)> = { |
|
| 242 | 257 | let _t = crate::timing::phase("checkout: render"); |
|
| 243 | - | nodes |
|
| 258 | + | let mut out: Vec<Option<(Vec<u8>, bool)>> = single |
|
| 259 | + | .iter() |
|
| 260 | + | .map(|s| s.as_ref().and_then(|_| texts.next().map(|t| (t, false)))) |
|
| 261 | + | .collect(); |
|
| 262 | + | let from_graphs: Vec<(Vec<u8>, bool)> = graphs |
|
| 244 | 263 | .par_iter() |
|
| 245 | 264 | .map(|n| { |
|
| 246 | 265 | let (bytes, m) = target.render_file(n); |
|
| 247 | 266 | (bytes, m.is_conflicted()) |
|
| 248 | 267 | }) |
|
| 268 | + | .collect(); |
|
| 269 | + | let mut from_graphs = from_graphs.into_iter(); |
|
| 270 | + | for o in out.iter_mut().filter(|o| o.is_none()) { |
|
| 271 | + | *o = from_graphs.next(); |
|
| 272 | + | } |
|
| 273 | + | out.into_iter() |
|
| 274 | + | .map(|o| o.expect("every file rendered")) |
|
| 249 | 275 | .collect() |
|
| 250 | 276 | }; |
|
| 277 | + | #[cfg(test)] |
|
| 278 | + | { |
|
| 279 | + | let singles: Vec<NodeId> = nodes |
|
| 280 | + | .iter() |
|
| 281 | + | .zip(&single) |
|
| 282 | + | .filter(|(_, s)| s.is_some()) |
|
| 283 | + | .map(|(n, _)| *n) |
|
| 284 | + | .collect(); |
|
| 285 | + | self.load_graphs_of(&mut target, &singles)?; |
|
| 286 | + | for (n, (bytes, _)) in nodes.iter().zip(&rendered) { |
|
| 287 | + | assert_eq!( |
|
| 288 | + | *bytes, |
|
| 289 | + | target.render_file(n).0, |
|
| 290 | + | "a single text is its file's rendering" |
|
| 291 | + | ); |
|
| 292 | + | } |
|
| 293 | + | } |
|
| 251 | 294 | target.drop_graphs(&nodes); |
|
| 252 | 295 | let _t = crate::timing::phase("checkout: write"); |
|
| 253 | 296 | if done == 0 && attr_count > 0 { |
|
| … | 5 unchanged lines |
||
| 259 | 302 | ); |
|
| 260 | 303 | } |
|
| 261 | 304 | done += batch.len(); |
|
| 262 | - | for ((path, node, mode), (bytes, conflicted)) in batch.iter().zip(rendered) { |
|
| 263 | - | let a = attrs.lookup(path); |
|
| 264 | - | let crlf = a.eol == Some(crate::attributes::Eol::Crlf); |
|
| 265 | - | let on_disk: Vec<u8> = if crlf { |
|
| 266 | - | crate::attributes::to_crlf(&bytes) |
|
| 267 | - | } else { |
|
| 268 | - | bytes |
|
| 269 | - | }; |
|
| 270 | - | if !self.same_on_disk(&self.root.join(path), &on_disk, mode.exec) { |
|
| 271 | - | self.write_file(path, &on_disk, mode.exec)?; |
|
| 272 | - | report.written += 1; |
|
| 305 | + | let on_disk: Vec<Vec<u8>> = batch |
|
| 306 | + | .iter() |
|
| 307 | + | .zip(rendered.iter()) |
|
| 308 | + | .map(|((path, _, _), (bytes, _))| { |
|
| 309 | + | if attrs.lookup(path).eol == Some(crate::attributes::Eol::Crlf) { |
|
| 310 | + | crate::attributes::to_crlf(bytes) |
|
| 311 | + | } else { |
|
| 312 | + | bytes.clone() |
|
| 313 | + | } |
|
| 314 | + | }) |
|
| 315 | + | .collect(); |
|
| 316 | + | // Each directory checked (no symlink on the way) and made once, |
|
| 317 | + | // then the files compared and written on every core. |
|
| 318 | + | let mut dirs: BTreeSet<&str> = BTreeSet::new(); |
|
| 319 | + | for (path, _, _) in batch { |
|
| 320 | + | if let Some((dir, _)) = path.rsplit_once('/') { |
|
| 321 | + | dirs.insert(dir); |
|
| 322 | + | } |
|
| 323 | + | } |
|
| 324 | + | for dir in dirs { |
|
| 325 | + | let full = self.safe_path(dir)?; |
|
| 326 | + | if !full.is_dir() { |
|
| 327 | + | if fs::symlink_metadata(&full).is_ok() { |
|
| 328 | + | remove_any(&full)?; |
|
| 329 | + | } |
|
| 330 | + | fs::create_dir_all(&full)?; |
|
| 273 | 331 | } |
|
| 332 | + | } |
|
| 333 | + | for (path, _, _) in batch { |
|
| 334 | + | check_path_name(path)?; |
|
| 335 | + | } |
|
| 336 | + | let (root, exec_bits) = (&self.root, self.platform.exec_bits); |
|
| 337 | + | // Compared on every core; new files written on every core too, |
|
| 338 | + | // but files that exist rewritten one at a time: on ext4, |
|
| 339 | + | // rewriting in parallel was six times slower (each truncation |
|
| 340 | + | // starts writing back the old blocks). |
|
| 341 | + | let plans: Vec<Disk> = batch |
|
| 342 | + | .par_iter() |
|
| 343 | + | .zip(on_disk.par_iter()) |
|
| 344 | + | .map(|((path, _, mode), bytes)| { |
|
| 345 | + | compare_on_disk(&root.join(path), bytes, mode.exec, exec_bits) |
|
| 346 | + | }) |
|
| 347 | + | .collect(); |
|
| 348 | + | let fresh: Vec<usize> = (0..batch.len()) |
|
| 349 | + | .filter(|&i| plans[i] == Disk::Absent) |
|
| 350 | + | .collect(); |
|
| 351 | + | fresh |
|
| 352 | + | .par_iter() |
|
| 353 | + | .map(|&i| write_file_at(&root.join(&batch[i].0), &on_disk[i], batch[i].2.exec)) |
|
| 354 | + | .collect::<std::io::Result<()>>()?; |
|
| 355 | + | for (i, plan) in plans.iter().enumerate() { |
|
| 356 | + | if *plan == Disk::Differs { |
|
| 357 | + | write_file_at(&root.join(&batch[i].0), &on_disk[i], batch[i].2.exec)?; |
|
| 358 | + | } |
|
| 359 | + | } |
|
| 360 | + | report.written += plans.iter().filter(|p| **p != Disk::Same).count(); |
|
| 361 | + | for (((path, node, mode), (_, conflicted)), on_disk) in |
|
| 362 | + | batch.iter().zip(rendered).zip(on_disk) |
|
| 363 | + | { |
|
| 364 | + | let a = attrs.lookup(path); |
|
| 274 | 365 | report.conflicted |= conflicted; |
|
| 275 | 366 | entries.insert( |
|
| 276 | 367 | path.clone(), |
|
| … | 137 unchanged lines |
||
| 414 | 505 | ChangeStatus::ModeChanged => "mode changed", |
|
| 415 | 506 | ChangeStatus::ConflictEdited => "conflict edited", |
|
| 416 | 507 | ChangeStatus::Resolved => "resolved", |
|
| 508 | + | } |
|
| 509 | + | } |
|
| 510 | + | ||
| 511 | + | /// Refuse a path that could escape the workspace (spec §7.7): an empty, |
|
| 512 | + | /// `.` or `..` component, a backslash, or a first component `.oxy`. |
|
| 513 | + | fn check_path_name(rel: &str) -> Result<()> { |
|
| 514 | + | for (i, comp) in rel.split('/').enumerate() { |
|
| 515 | + | if comp.is_empty() || comp == "." || comp == ".." || comp.contains('\\') { |
|
| 516 | + | return fail(format!("refusing unsafe path {rel:?}")); |
|
| 517 | + | } |
|
| 518 | + | if i == 0 && comp == OXY_DIR { |
|
| 519 | + | return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}")); |
|
| 520 | + | } |
|
| 521 | + | } |
|
| 522 | + | Ok(()) |
|
| 523 | + | } |
|
| 524 | + | ||
| 525 | + | /// A file on disk against what a checkout would write there. |
|
| 526 | + | #[derive(Clone, Copy, PartialEq, Eq)] |
|
| 527 | + | enum Disk { |
|
| 528 | + | Same, |
|
| 529 | + | Differs, |
|
| 530 | + | Absent, |
|
| 531 | + | } |
|
| 532 | + | ||
| 533 | + | /// Whether the file at `full` already holds `bytes` with the right |
|
| 534 | + | /// executable bit. Touches no repository state, so it runs in parallel. |
|
| 535 | + | fn compare_on_disk(full: &Path, bytes: &[u8], exec: bool, exec_bits: bool) -> Disk { |
|
| 536 | + | match fs::symlink_metadata(full) { |
|
| 537 | + | Ok(m) |
|
| 538 | + | if m.is_file() |
|
| 539 | + | && m.len() == bytes.len() as u64 |
|
| 540 | + | && (!exec_bits || is_executable(&m) == exec) |
|
| 541 | + | && fs::read(full).is_ok_and(|b| b == bytes) => |
|
| 542 | + | { |
|
| 543 | + | Disk::Same |
|
| 544 | + | } |
|
| 545 | + | Ok(_) => Disk::Differs, |
|
| 546 | + | Err(_) => Disk::Absent, |
|
| 547 | + | } |
|
| 548 | + | } |
|
| 549 | + | ||
| 550 | + | /// Write a file whose directory exists, replacing whatever is there. |
|
| 551 | + | fn write_file_at(full: &Path, bytes: &[u8], exec: bool) -> std::io::Result<()> { |
|
| 552 | + | match fs::symlink_metadata(full) { |
|
| 553 | + | Ok(m) if m.is_file() => { |
|
| 554 | + | if m.permissions().readonly() { |
|
| 555 | + | set_readonly(full, false)?; |
|
| 556 | + | } |
|
| 557 | + | } |
|
| 558 | + | Ok(_) => remove_any(full)?, |
|
| 559 | + | Err(_) => {} |
|
| 417 | 560 | } |
|
| 561 | + | fs::write(full, bytes)?; |
|
| 562 | + | set_executable(full, exec) |
|
| 418 | 563 | } |
modified crates/oxy-core/src/repo/mod.rs +34 −0
| … | 862 unchanged lines |
||
| 863 | 863 | .collect()) |
|
| 864 | 864 | } |
|
| 865 | 865 | ||
| 866 | + | /// The contents of texts [`State::single_text`] gave: blobs read in |
|
| 867 | + | /// one go, inline ones from their changes. |
|
| 868 | + | pub(crate) fn single_texts(&self, texts: &[crate::objects::TextRef]) -> Result<Vec<Vec<u8>>> { |
|
| 869 | + | use crate::objects::TextRef; |
|
| 870 | + | let refs: Vec<ObjectRef> = texts |
|
| 871 | + | .iter() |
|
| 872 | + | .filter_map(|t| match t { |
|
| 873 | + | TextRef::Blob(r, _, _) => Some(*r), |
|
| 874 | + | TextRef::Inline { .. } => None, |
|
| 875 | + | }) |
|
| 876 | + | .collect(); |
|
| 877 | + | let mut blobs = self.read_blobs(&refs)?.into_iter(); |
|
| 878 | + | let mut out = Vec::with_capacity(texts.len()); |
|
| 879 | + | for t in texts { |
|
| 880 | + | let lines = match t { |
|
| 881 | + | TextRef::Blob(_, count, form) => { |
|
| 882 | + | let content = blobs |
|
| 883 | + | .next() |
|
| 884 | + | .ok_or_else(|| RepoError::Msg("text blobs: wrong count".into()))?; |
|
| 885 | + | crate::objects::text_lines(&content, *count, *form)? |
|
| 886 | + | } |
|
| 887 | + | TextRef::Inline { change, start, end } => { |
|
| 888 | + | let bytes = self.change_bytes(change)?; |
|
| 889 | + | let range = bytes.get(*start as usize..*end as usize).ok_or_else(|| { |
|
| 890 | + | RepoError::Msg("an inline insert lies outside its change".into()) |
|
| 891 | + | })?; |
|
| 892 | + | crate::objects::inline_lines(range)? |
|
| 893 | + | } |
|
| 894 | + | }; |
|
| 895 | + | out.push(lines.bytes().to_vec()); |
|
| 896 | + | } |
|
| 897 | + | Ok(out) |
|
| 898 | + | } |
|
| 899 | + | ||
| 866 | 900 | /// Build every graph a history-only state still lacks, making it full. |
|
| 867 | 901 | pub fn load_all_graphs(&self, state: &mut State) -> Result<()> { |
|
| 868 | 902 | Ok(state.load_all_graphs( |
|
| … | 882 unchanged lines |
modified crates/oxy-core/src/repo/sync.rs +12 −3
| … | 1148 unchanged lines |
||
| 1149 | 1149 | let mut report = Vec::new(); |
|
| 1150 | 1150 | let max_blob = self.config.max_blob; |
|
| 1151 | 1151 | for r in resp.refs { |
|
| 1152 | - | let needed = remote.negotiate(&have, &r.heads)?; |
|
| 1153 | - | let bundle = remote.fetch(&needed, max_blob)?; |
|
| 1152 | + | let needed = { |
|
| 1153 | + | let _t = crate::timing::phase("fetch: negotiate"); |
|
| 1154 | + | remote.negotiate(&have, &r.heads)? |
|
| 1155 | + | }; |
|
| 1156 | + | let bundle = { |
|
| 1157 | + | let _t = crate::timing::phase("fetch: bundle"); |
|
| 1158 | + | remote.fetch(&needed, max_blob)? |
|
| 1159 | + | }; |
|
| 1154 | 1160 | // Changes are not individually re-verified here: the bookmark's |
|
| 1155 | 1161 | // attestation (checked below) binds its whole state, and the |
|
| 1156 | 1162 | // receiver of the push verified the added changes at that time. |
|
| 1157 | - | let imported = self.import_bundle(&bundle, false)?; |
|
| 1163 | + | let imported = { |
|
| 1164 | + | let _t = crate::timing::phase("fetch: import"); |
|
| 1165 | + | self.import_bundle(&bundle, false)? |
|
| 1166 | + | }; |
|
| 1158 | 1167 | if max_blob.is_some() { |
|
| 1159 | 1168 | self.register_missing_blobs(&imported, remote_name)?; |
|
| 1160 | 1169 | } |
|
| … | 579 unchanged lines |
modified crates/oxy-core/src/state.rs +27 −0
| … | 468 unchanged lines |
||
| 469 | 469 | Ok(()) |
|
| 470 | 470 | } |
|
| 471 | 471 | ||
| 472 | + | /// The one text a file's history is, when it is a single insert of |
|
| 473 | + | /// the whole file into nothing (a file added once and never edited, |
|
| 474 | + | /// with no checkpoint base): its rendering is that text, so it needs |
|
| 475 | + | /// no graph. `None` otherwise, or when the graph is built. |
|
| 476 | + | pub fn single_text(&self, node: &NodeId) -> Option<TextRef> { |
|
| 477 | + | if self.files.contains_key(node) { |
|
| 478 | + | return None; |
|
| 479 | + | } |
|
| 480 | + | let d = self.deferred.as_ref()?.get(node)?.body(); |
|
| 481 | + | let [(_, edits)] = d.edits.as_slice() else { |
|
| 482 | + | return None; |
|
| 483 | + | }; |
|
| 484 | + | let [Edit::Insert { |
|
| 485 | + | after: AtomRef::Start, |
|
| 486 | + | before: AtomRef::End, |
|
| 487 | + | lines, |
|
| 488 | + | .. |
|
| 489 | + | }] = edits.as_slice() |
|
| 490 | + | else { |
|
| 491 | + | return None; |
|
| 492 | + | }; |
|
| 493 | + | match (&d.base, d.texts.as_slice()) { |
|
| 494 | + | (None, [t]) if lines.is_empty() => Some(*t), |
|
| 495 | + | _ => None, |
|
| 496 | + | } |
|
| 497 | + | } |
|
| 498 | + | ||
| 472 | 499 | /// A key for the graph of `node` in a history-only state that has not |
|
| 473 | 500 | /// built it: equal keys mean equal graphs, so equal renderings (a |
|
| 474 | 501 | /// rendering depends only on the graph and on the changes' own |
|
| … | 1489 unchanged lines |