Status with work in progress from a status cache: ahead of Git on the Linux tree (refs #41) 3604179679eb
The manifest check proved only a clean working copy, so with uncommitted work every status listed and snapshotted the whole tree again. A snapshot now writes a status cache (workspaces/<ws>/status) keyed by the working-copy change and the manifest's checksum, and rewrites the manifest when an edited file's stat moved, so it describes the files as they are. status_by_manifest answers from the cache when every file still matches the manifest; test builds check the answer against a snapshot. On the Linux tree with MAINTAINERS edited: 0.16 s (from 0.44 s; Git 0.20 s), 120 to 155 MB (from 330 MB). The racy-window and listing checks are shared with the clean check.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-01 18:40 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
3604179679eb1325790f3f0a41d2124c5f87e40612837a80ef0101cbea7992cf- Depends on
- 135212de5370 1966f5db15ee 1d360c91a289 50a046e439e9 638fb21420d7 75167672ebcd 76740263089c 86e1824f2d0b 8fd78eb52ad2 94084087a876 982864068a59 98a1620a3840 992467d31d26 9ac1fbe211b4 c07406bcc237 cd19603782bf d3cd7ef95da6 d4dbab97e948 da868d5d2646 eaf3e9068180 f9466021286a fce44ae9ad29
- Depended on by
- 232aabb5452d f6dccc4533c8 80677b7a3513 236f35c0baba 0389c3e82f6c e96a430c78f3 e226a6cc2f71 1ec3f008e96a 77445673174b
- 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.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
- 20 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Status with work in progress ahead of Git (#41).** With uncommitted work, `oxy status` answers from a status cache while every file is as the last snapshot left it: one edited file on the Linux tree is 0.16 s (from 0.44 s; Git 0.20 s), in half the memory. |
|
| 6 | + | ||
| 5 | 7 | - **Quieter notes (#55).** Notes start with a dimmed `›` instead of `ℹ`, which many terminal fonts draw as a boxed or emoji-style "i". A spinner's final line no longer shows two ticks or runs into the next line. |
|
| 6 | 8 | ||
| 7 | 9 | ## 0.2.0 (2026-10-01) |
|
| … | 52 unchanged lines |
modified crates/oxy-core/src/repo/prompt.rs +174 −23
| … | 258 unchanged lines |
||
| 259 | 259 | )) |
|
| 260 | 260 | } |
|
| 261 | 261 | ||
| 262 | + | /// The status cache: a magic number, the working-copy change, the |
|
| 263 | + | /// manifest checksum, then each status line (a status byte and a path). |
|
| 264 | + | const STATUS_MAGIC: &[u8; 8] = b"oxystat1"; |
|
| 265 | + | ||
| 266 | + | fn status_byte(s: ChangeStatus) -> u8 { |
|
| 267 | + | match s { |
|
| 268 | + | ChangeStatus::Added => 0, |
|
| 269 | + | ChangeStatus::Modified => 1, |
|
| 270 | + | ChangeStatus::Removed => 2, |
|
| 271 | + | ChangeStatus::Renamed => 3, |
|
| 272 | + | ChangeStatus::ModeChanged => 4, |
|
| 273 | + | ChangeStatus::ConflictEdited => 5, |
|
| 274 | + | ChangeStatus::Resolved => 6, |
|
| 275 | + | } |
|
| 276 | + | } |
|
| 277 | + | ||
| 278 | + | fn encode_status_cache(wc: &ChangeHash, stamp: &[u8; 32], statuses: &[PathStatus]) -> Vec<u8> { |
|
| 279 | + | let mut out = Vec::with_capacity(80 + statuses.len() * 40); |
|
| 280 | + | out.extend_from_slice(STATUS_MAGIC); |
|
| 281 | + | out.extend_from_slice(&wc.0); |
|
| 282 | + | out.extend_from_slice(stamp); |
|
| 283 | + | out.extend_from_slice(&(statuses.len() as u32).to_le_bytes()); |
|
| 284 | + | for s in statuses { |
|
| 285 | + | out.push(status_byte(s.status)); |
|
| 286 | + | out.extend_from_slice(&(s.path.len() as u32).to_le_bytes()); |
|
| 287 | + | out.extend_from_slice(s.path.as_bytes()); |
|
| 288 | + | } |
|
| 289 | + | out |
|
| 290 | + | } |
|
| 291 | + | ||
| 292 | + | fn decode_status_cache(b: &[u8]) -> Option<(ChangeHash, [u8; 32], Vec<PathStatus>)> { |
|
| 293 | + | let rest = b.strip_prefix(STATUS_MAGIC)?; |
|
| 294 | + | let wc = ObjectRef(rest.get(..32)?.try_into().ok()?); |
|
| 295 | + | let stamp: [u8; 32] = rest.get(32..64)?.try_into().ok()?; |
|
| 296 | + | let n = u32::from_le_bytes(rest.get(64..68)?.try_into().ok()?) as usize; |
|
| 297 | + | let mut at = 68; |
|
| 298 | + | let mut out = Vec::with_capacity(n.min(1 << 16)); |
|
| 299 | + | for _ in 0..n { |
|
| 300 | + | let status = match *rest.get(at)? { |
|
| 301 | + | 0 => ChangeStatus::Added, |
|
| 302 | + | 1 => ChangeStatus::Modified, |
|
| 303 | + | 2 => ChangeStatus::Removed, |
|
| 304 | + | 3 => ChangeStatus::Renamed, |
|
| 305 | + | 4 => ChangeStatus::ModeChanged, |
|
| 306 | + | 5 => ChangeStatus::ConflictEdited, |
|
| 307 | + | 6 => ChangeStatus::Resolved, |
|
| 308 | + | _ => return None, |
|
| 309 | + | }; |
|
| 310 | + | let len = u32::from_le_bytes(rest.get(at + 1..at + 5)?.try_into().ok()?) as usize; |
|
| 311 | + | let path = std::str::from_utf8(rest.get(at + 5..at + 5 + len)?) |
|
| 312 | + | .ok()? |
|
| 313 | + | .to_string(); |
|
| 314 | + | out.push(PathStatus { path, status }); |
|
| 315 | + | at += 5 + len; |
|
| 316 | + | } |
|
| 317 | + | (at == rest.len()).then_some((wc, stamp, out)) |
|
| 318 | + | } |
|
| 319 | + | ||
| 262 | 320 | impl Repo { |
|
| 263 | 321 | pub(crate) fn manifest_path(&self) -> PathBuf { |
|
| 264 | 322 | self.oxy |
|
| … | 166 unchanged lines |
||
| 431 | 489 | if conflicted || uncommitted || self.root.join(".oxymodules").exists() { |
|
| 432 | 490 | return not("conflicted, uncommitted or subrepos"); |
|
| 433 | 491 | } |
|
| 434 | - | // Entries in the racy window are hashed, in parallel (a checkout |
|
| 435 | - | // just wrote them all). |
|
| 492 | + | if let Some(rel) = self.racy_change(&m) { |
|
| 493 | + | return not(&format!("{rel} changed within the racy window")); |
|
| 494 | + | } |
|
| 495 | + | if self.disk_differs(&m, true)? { |
|
| 496 | + | return not("a file differs"); |
|
| 497 | + | } |
|
| 498 | + | Ok(true) |
|
| 499 | + | } |
|
| 500 | + | ||
| 501 | + | /// A manifest entry written within the racy window whose file no |
|
| 502 | + | /// longer matches it. Such entries are hashed, in parallel (a checkout |
|
| 503 | + | /// just wrote them all). |
|
| 504 | + | fn racy_change(&self, m: &Manifest) -> Option<String> { |
|
| 436 | 505 | use rayon::prelude::*; |
|
| 437 | 506 | let racy: Vec<(&String, &ManifestEntry)> = m |
|
| 438 | 507 | .entries |
|
| … | 1 unchanged lines |
||
| 440 | 509 | .filter(|(_, e)| e.mtime_ns + 2_000_000_000 >= m.written_ns) |
|
| 441 | 510 | .collect(); |
|
| 442 | 511 | let root = &self.root; |
|
| 443 | - | let changed = racy.par_iter().find_any(|(rel, e)| { |
|
| 444 | - | let path = root.join(rel.as_str()); |
|
| 445 | - | let same = match fs::symlink_metadata(&path) { |
|
| 446 | - | Ok(md) if md.file_type().is_symlink() => fs::read_link(&path).is_ok_and(|t| { |
|
| 447 | - | *blake3::hash(t.to_string_lossy().as_bytes()).as_bytes() == e.hash |
|
| 448 | - | }), |
|
| 449 | - | Ok(md) if md.is_file() => { |
|
| 450 | - | md.len() == e.size |
|
| 451 | - | && fs::read(&path).is_ok_and(|b| *blake3::hash(&b).as_bytes() == e.hash) |
|
| 452 | - | } |
|
| 453 | - | _ => false, |
|
| 454 | - | }; |
|
| 455 | - | !same |
|
| 456 | - | }); |
|
| 457 | - | if let Some((rel, _)) = changed { |
|
| 458 | - | return not(&format!("{rel} changed within the racy window")); |
|
| 512 | + | racy.par_iter() |
|
| 513 | + | .find_any(|(rel, e)| { |
|
| 514 | + | let path = root.join(rel.as_str()); |
|
| 515 | + | let same = match fs::symlink_metadata(&path) { |
|
| 516 | + | Ok(md) if md.file_type().is_symlink() => fs::read_link(&path).is_ok_and(|t| { |
|
| 517 | + | *blake3::hash(t.to_string_lossy().as_bytes()).as_bytes() == e.hash |
|
| 518 | + | }), |
|
| 519 | + | Ok(md) if md.is_file() => { |
|
| 520 | + | md.len() == e.size |
|
| 521 | + | && fs::read(&path).is_ok_and(|b| *blake3::hash(&b).as_bytes() == e.hash) |
|
| 522 | + | } |
|
| 523 | + | _ => false, |
|
| 524 | + | }; |
|
| 525 | + | !same |
|
| 526 | + | }) |
|
| 527 | + | .map(|(rel, _)| rel.to_string()) |
|
| 528 | + | } |
|
| 529 | + | ||
| 530 | + | /// The status lines of the last snapshot, when the working copy is as |
|
| 531 | + | /// that snapshot left it: the cache it wrote names the working-copy |
|
| 532 | + | /// change and the manifest it recorded, and every file still matches |
|
| 533 | + | /// that manifest. `None` when a snapshot must decide (no cache, a file |
|
| 534 | + | /// changed, conflicts or subrepos). An empty list is a clean working |
|
| 535 | + | /// copy. |
|
| 536 | + | pub fn status_by_manifest(&self) -> Result<Option<Vec<PathStatus>>> { |
|
| 537 | + | // A listing left by an earlier check describes the files as they |
|
| 538 | + | // were then; this check makes its own. |
|
| 539 | + | self.listing_cache.take(); |
|
| 540 | + | let Some((conflicted, uncommitted, m)) = self.read_manifest()? else { |
|
| 541 | + | return Ok(None); |
|
| 542 | + | }; |
|
| 543 | + | if !uncommitted { |
|
| 544 | + | return Ok(self.clean_by_manifest()?.then(Vec::new)); |
|
| 459 | 545 | } |
|
| 460 | - | if self.quick_dirty_keeping(true)? != Some(false) { |
|
| 461 | - | return not("a file differs"); |
|
| 546 | + | let _t = crate::timing::phase("status: by the status cache"); |
|
| 547 | + | if conflicted || self.root.join(".oxymodules").exists() { |
|
| 548 | + | return Ok(None); |
|
| 549 | + | } |
|
| 550 | + | let Some((wc, stamp, statuses)) = fs::read(self.status_cache_path()) |
|
| 551 | + | .ok() |
|
| 552 | + | .and_then(|b| decode_status_cache(&b)) |
|
| 553 | + | else { |
|
| 554 | + | return Ok(None); |
|
| 555 | + | }; |
|
| 556 | + | let now = self.view()?.workspaces.get(&self.workspace).copied(); |
|
| 557 | + | if now != Some(wc) || self.manifest_stamp() != Some(stamp) { |
|
| 558 | + | return Ok(None); |
|
| 559 | + | } |
|
| 560 | + | if self.racy_change(&m).is_some() || self.disk_differs(&m, true)? { |
|
| 561 | + | return Ok(None); |
|
| 562 | + | } |
|
| 563 | + | // Test builds check the cache against a snapshot of the files. |
|
| 564 | + | #[cfg(test)] |
|
| 565 | + | { |
|
| 566 | + | let snap = self.snapshot_status("status cache check")?; |
|
| 567 | + | let key = |v: &[PathStatus]| { |
|
| 568 | + | let mut k: Vec<(String, u8)> = v |
|
| 569 | + | .iter() |
|
| 570 | + | .map(|p| (p.path.clone(), status_byte(p.status))) |
|
| 571 | + | .collect(); |
|
| 572 | + | k.sort(); |
|
| 573 | + | k |
|
| 574 | + | }; |
|
| 575 | + | assert_eq!( |
|
| 576 | + | key(&statuses), |
|
| 577 | + | key(&snap.statuses), |
|
| 578 | + | "status cache disagrees with a snapshot" |
|
| 579 | + | ); |
|
| 462 | 580 | } |
|
| 463 | - | Ok(true) |
|
| 581 | + | Ok(Some(statuses)) |
|
| 582 | + | } |
|
| 583 | + | ||
| 584 | + | fn status_cache_path(&self) -> PathBuf { |
|
| 585 | + | self.manifest_path().with_file_name("status") |
|
| 586 | + | } |
|
| 587 | + | ||
| 588 | + | /// The manifest file's checksum, which names its content. |
|
| 589 | + | fn manifest_stamp(&self) -> Option<[u8; 32]> { |
|
| 590 | + | use std::io::Read; |
|
| 591 | + | let mut head = [0u8; MANIFEST_SUM.end]; |
|
| 592 | + | fs::File::open(self.manifest_path()) |
|
| 593 | + | .and_then(|mut f| f.read_exact(&mut head)) |
|
| 594 | + | .ok()?; |
|
| 595 | + | head[MANIFEST_SUM].try_into().ok() |
|
| 464 | 596 | } |
|
| 465 | 597 | ||
| 598 | + | /// Record `statuses` as the status of working-copy change `wc` against |
|
| 599 | + | /// the manifest as it is now; with no uncommitted work there is nothing |
|
| 600 | + | /// to record, and the cache goes. |
|
| 601 | + | pub(crate) fn write_status_cache(&self, wc: Option<&ChangeHash>, statuses: &[PathStatus]) { |
|
| 602 | + | let path = self.status_cache_path(); |
|
| 603 | + | let (Some(wc), Some(stamp)) = (wc, self.manifest_stamp()) else { |
|
| 604 | + | let _ = fs::remove_file(&path); |
|
| 605 | + | return; |
|
| 606 | + | }; |
|
| 607 | + | let _ = write_cache(&path, &encode_status_cache(wc, &stamp, statuses)); |
|
| 608 | + | } |
|
| 609 | + | ||
| 466 | 610 | /// Whether the working copy differs from the last manifest. Without a |
|
| 467 | 611 | /// manifest the answer is `None`. |
|
| 468 | 612 | pub fn quick_dirty(&self) -> Result<Option<bool>> { |
|
| … | 9 unchanged lines |
||
| 478 | 622 | if uncommitted { |
|
| 479 | 623 | return Ok(Some(true)); |
|
| 480 | 624 | } |
|
| 625 | + | Ok(Some(self.disk_differs(&manifest, keep)?)) |
|
| 626 | + | } |
|
| 627 | + | ||
| 628 | + | /// Whether the working copy differs from `manifest`: a file added, |
|
| 629 | + | /// gone or changed. With `keep`, the listing made is left for the next |
|
| 630 | + | /// walk of the working copy when it does. |
|
| 631 | + | fn disk_differs(&self, manifest: &Manifest, keep: bool) -> Result<bool> { |
|
| 481 | 632 | use rayon::prelude::*; |
|
| 482 | 633 | let sparse = &self.config.sparse_paths; |
|
| 483 | 634 | let in_scope = |p: &str| { |
|
| … | 8 unchanged lines |
||
| 492 | 643 | // base's, and a prompt's answer is the same either way. |
|
| 493 | 644 | let known = |p: &str| manifest.entries.contains_key(p); |
|
| 494 | 645 | let Ok(listing) = super::walk::list_knowing(&self.root, &in_scope, &known) else { |
|
| 495 | - | return Ok(Some(true)); |
|
| 646 | + | return Ok(true); |
|
| 496 | 647 | }; |
|
| 497 | 648 | let differs = || -> Result<bool> { |
|
| 498 | 649 | let links_differ = listing.symlinks.iter().any(|(rel, target)| { |
|
| … | 71 unchanged lines |
||
| 570 | 721 | if dirty && keep { |
|
| 571 | 722 | *self.listing_cache.borrow_mut() = Some(listing); |
|
| 572 | 723 | } |
|
| 573 | - | Ok(Some(dirty)) |
|
| 724 | + | Ok(dirty) |
|
| 574 | 725 | } |
|
| 575 | 726 | ||
| 576 | 727 | /// Prompt information without recording anything. |
|
| … | 74 unchanged lines |
modified crates/oxy-core/src/repo/snapshot.rs +21 −0
| … | 479 unchanged lines |
||
| 480 | 480 | .blobs |
|
| 481 | 481 | .iter() |
|
| 482 | 482 | .any(|(p, b)| m.entries.get(p).and_then(|e| e.blob) != Some(*b)) |
|
| 483 | + | // With uncommitted work, the status cache below holds |
|
| 484 | + | // only while the manifest describes the edited files as |
|
| 485 | + | // they are now. |
|
| 486 | + | || (uncommitted && statuses.iter().any(|st| { |
|
| 487 | + | let Some(e) = fs_entries.get(&st.path) else { |
|
| 488 | + | return m.entries.contains_key(&st.path); |
|
| 489 | + | }; |
|
| 490 | + | let Some(me) = m.entries.get(&st.path) else { |
|
| 491 | + | return true; |
|
| 492 | + | }; |
|
| 493 | + | let hash = e |
|
| 494 | + | .content_hash |
|
| 495 | + | .unwrap_or_else(|| *blake3::hash(&e.content).as_bytes()); |
|
| 496 | + | let stat = e.stat.or_else(|| { |
|
| 497 | + | fs::symlink_metadata(self.root.join(&st.path)) |
|
| 498 | + | .ok() |
|
| 499 | + | .map(|md| (md.len(), super::prompt::mtime_ns(&md))) |
|
| 500 | + | }); |
|
| 501 | + | me.hash != hash || stat != Some((me.size, me.mtime_ns)) |
|
| 502 | + | })) |
|
| 483 | 503 | } |
|
| 484 | 504 | }; |
|
| 485 | 505 | if worth { |
|
| 486 | 506 | self.write_manifest_entries(conflicted, uncommitted, &fs_entries, &built.blobs)?; |
|
| 487 | 507 | } |
|
| 508 | + | self.write_status_cache((uncommitted && !conflicted).then_some(&new_hash), &statuses); |
|
| 488 | 509 | let attributes = crate::attributes::Attributes::from_files( |
|
| 489 | 510 | fs_entries |
|
| 490 | 511 | .iter() |
|
| … | 2105 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +54 −0
| … | 5900 unchanged lines |
||
| 5901 | 5901 | let _ = first; |
|
| 5902 | 5902 | } |
|
| 5903 | 5903 | ||
| 5904 | + | /// oxygen#41: with uncommitted work, a status after a snapshot is answered |
|
| 5905 | + | /// from the status cache while every file is as the snapshot left it, and |
|
| 5906 | + | /// from a snapshot again as soon as one is not. |
|
| 5907 | + | #[test] |
|
| 5908 | + | fn status_with_uncommitted_work_comes_from_the_cache_until_a_file_changes() { |
|
| 5909 | + | hermetic(); |
|
| 5910 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 5911 | + | let root = dir.path(); |
|
| 5912 | + | let repo = Repo::init(root).unwrap(); |
|
| 5913 | + | write(root, "a.txt", b"a\n"); |
|
| 5914 | + | write(root, "b.txt", b"b\n"); |
|
| 5915 | + | repo.commit("base", &[]).unwrap(); |
|
| 5916 | + | assert_eq!(repo.status_by_manifest().unwrap(), Some(vec![]), "clean"); |
|
| 5917 | + | ||
| 5918 | + | write(root, "a.txt", b"a\nmore\n"); |
|
| 5919 | + | assert_eq!( |
|
| 5920 | + | repo.status_by_manifest().unwrap(), |
|
| 5921 | + | None, |
|
| 5922 | + | "no snapshot of the edit yet" |
|
| 5923 | + | ); |
|
| 5924 | + | let snap = repo.snapshot_status("oxy status").unwrap(); |
|
| 5925 | + | assert_eq!(snap.statuses.len(), 1); |
|
| 5926 | + | let cached = repo |
|
| 5927 | + | .status_by_manifest() |
|
| 5928 | + | .unwrap() |
|
| 5929 | + | .expect("answered from the cache"); |
|
| 5930 | + | assert_eq!(cached.len(), 1); |
|
| 5931 | + | assert_eq!( |
|
| 5932 | + | (cached[0].path.as_str(), cached[0].status), |
|
| 5933 | + | ("a.txt", ChangeStatus::Modified) |
|
| 5934 | + | ); |
|
| 5935 | + | ||
| 5936 | + | // Any further change sends status back to a snapshot. |
|
| 5937 | + | write(root, "a.txt", b"a\nmore\nand more\n"); |
|
| 5938 | + | assert_eq!(repo.status_by_manifest().unwrap(), None, "edited again"); |
|
| 5939 | + | repo.snapshot_status("oxy status").unwrap(); |
|
| 5940 | + | assert!(repo.status_by_manifest().unwrap().is_some()); |
|
| 5941 | + | write(root, "new.txt", b"n\n"); |
|
| 5942 | + | assert_eq!(repo.status_by_manifest().unwrap(), None, "a file added"); |
|
| 5943 | + | fs::remove_file(root.join("new.txt")).unwrap(); |
|
| 5944 | + | assert!( |
|
| 5945 | + | repo.status_by_manifest().unwrap().is_some(), |
|
| 5946 | + | "back as it was" |
|
| 5947 | + | ); |
|
| 5948 | + | fs::remove_file(root.join("b.txt")).unwrap(); |
|
| 5949 | + | assert_eq!(repo.status_by_manifest().unwrap(), None, "a file removed"); |
|
| 5950 | + | write(root, "b.txt", b"b\n"); |
|
| 5951 | + | ||
| 5952 | + | // A commit leaves nothing uncommitted: clean again, no cache needed. |
|
| 5953 | + | repo.snapshot_status("oxy status").unwrap(); |
|
| 5954 | + | repo.commit("more", &[]).unwrap(); |
|
| 5955 | + | assert_eq!(repo.status_by_manifest().unwrap(), Some(vec![])); |
|
| 5956 | + | } |
|
| 5957 | + | ||
| 5904 | 5958 | /// oxygen#35: a history-only state keeps no inserted text. It reads a |
|
| 5905 | 5959 | /// stored insert's text blob only when it builds that file's graph, and |
|
| 5906 | 5960 | /// builds the same graph a full load does. |
|
| … | 708 unchanged lines |
modified docs/benchmarks.md +20 −0
| … | 749 unchanged lines |
||
| 750 | 750 | file added once and never edited straight from its text blob, keep sizes |
|
| 751 | 751 | in the render cache so a stat does not render, serve FUSE on several |
|
| 752 | 752 | threads, and read the state index lazily for a mount under 100 ms. |
|
| 753 | + | ||
| 754 | + | ### Status with work in progress (2026-10-01, oxygen#41) |
|
| 755 | + | ||
| 756 | + | `MAINTAINERS` edited on the Linux tree on Earth (load average 2.2, one |
|
| 757 | + | Flow job running), oxy and Git alternating, three runs each: |
|
| 758 | + | ||
| 759 | + | | | before | after | Git | |
|
| 760 | + | |---|---:|---:|---:| |
|
| 761 | + | | status, one file edited (after a first status) | 0.42 to 0.46 s, 330 MB | **0.16 to 0.17 s**, 120 to 155 MB | 0.20 to 0.21 s | |
|
| 762 | + | | status, clean | 0.18 to 0.20 s | **0.15 to 0.16 s** | 0.20 to 0.24 s | |
|
| 763 | + | | diff, one file | 0.43 s | 0.40 to 0.41 s | 0.08 to 0.09 s | |
|
| 764 | + | ||
| 765 | + | Before, the manifest only proved a clean working copy. With uncommitted |
|
| 766 | + | work every status listed and snapshotted the tree again, loaded the state |
|
| 767 | + | index and rendered the base (listing 164 ms, index 60 ms, base 33 ms). Now |
|
| 768 | + | a snapshot leaves a status cache keyed by the working-copy change and the |
|
| 769 | + | manifest's checksum, and keeps the manifest current for the edited files. |
|
| 770 | + | A later status that finds every file as the manifest says answers from |
|
| 771 | + | the cache. Test builds check that answer against a snapshot. Diff still |
|
| 772 | + | renders the base; it is next. |
modified src/cli/workspace.rs +30 −23
| … | 90 unchanged lines |
||
| 91 | 91 | ); |
|
| 92 | 92 | } |
|
| 93 | 93 | ||
| 94 | + | /// The working copy's changed paths, or that it is clean. |
|
| 95 | + | fn print_statuses(statuses: &[oxy_core::repo::PathStatus]) { |
|
| 96 | + | if statuses.is_empty() { |
|
| 97 | + | println!( |
|
| 98 | + | " {} {}", |
|
| 99 | + | ui::ok(ui::glyph("✔", "*")), |
|
| 100 | + | ui::dim("nothing to commit, working copy clean") |
|
| 101 | + | ); |
|
| 102 | + | return; |
|
| 103 | + | } |
|
| 104 | + | println!(); |
|
| 105 | + | for s in statuses { |
|
| 106 | + | println!( |
|
| 107 | + | " {} {}", |
|
| 108 | + | ui::status_mark(status_tag(s.status)), |
|
| 109 | + | ui::path(&s.path) |
|
| 110 | + | ); |
|
| 111 | + | } |
|
| 112 | + | } |
|
| 113 | + | ||
| 94 | 114 | pub fn status(json: bool) -> Result<i32> { |
|
| 95 | 115 | let repo = open()?; |
|
| 96 | - | // Clean by the manifest alone: no history to load (oxygen#35). |
|
| 97 | - | if !json && repo.clean_by_manifest()? { |
|
| 116 | + | // Answered by the manifest alone, with no history to load (oxygen#35, |
|
| 117 | + | // #41): a clean working copy, or one as the last snapshot left it. |
|
| 118 | + | let known = if json { |
|
| 119 | + | None |
|
| 120 | + | } else { |
|
| 121 | + | repo.status_by_manifest()? |
|
| 122 | + | }; |
|
| 123 | + | if let Some(statuses) = known { |
|
| 98 | 124 | let (_, wc) = repo.working_copy()?; |
|
| 99 | 125 | let heads = repo.bookmark_heads()?; |
|
| 100 | 126 | println!( |
|
| … | 5 unchanged lines |
||
| 106 | 132 | ui::sep(), |
|
| 107 | 133 | ui::dim(plural(heads.len(), "head")) |
|
| 108 | 134 | ); |
|
| 109 | - | println!( |
|
| 110 | - | " {} {}", |
|
| 111 | - | ui::ok(ui::glyph("✔", "*")), |
|
| 112 | - | ui::dim("nothing to commit, working copy clean") |
|
| 113 | - | ); |
|
| 135 | + | print_statuses(&statuses); |
|
| 114 | 136 | print_untracked_nested(&repo, &repo.untracked_nested()); |
|
| 115 | 137 | return Ok(0); |
|
| 116 | 138 | } |
|
| … | 22 unchanged lines |
||
| 139 | 161 | ui::sep(), |
|
| 140 | 162 | ui::dim(plural(heads.len(), "head")) |
|
| 141 | 163 | ); |
|
| 142 | - | if snap.statuses.is_empty() { |
|
| 143 | - | println!( |
|
| 144 | - | " {} {}", |
|
| 145 | - | ui::ok(ui::glyph("✔", "*")), |
|
| 146 | - | ui::dim("nothing to commit, working copy clean") |
|
| 147 | - | ); |
|
| 148 | - | } else { |
|
| 149 | - | println!(); |
|
| 150 | - | for s in &snap.statuses { |
|
| 151 | - | println!( |
|
| 152 | - | " {} {}", |
|
| 153 | - | ui::status_mark(status_tag(s.status)), |
|
| 154 | - | ui::path(&s.path) |
|
| 155 | - | ); |
|
| 156 | - | } |
|
| 157 | - | } |
|
| 164 | + | print_statuses(&snap.statuses); |
|
| 158 | 165 | print_untracked_nested(&repo, &snap.untracked_nested); |
|
| 159 | 166 | if !snap.base_snapshot.is_conflicted() { |
|
| 160 | 167 | return Ok(0); |
|
| … | 2199 unchanged lines |