Answer a clean status from the manifest alone da868d5d2646
When the last snapshot or commit left no uncommitted work and no conflict, no entry is inside the racy window, there are no subrepos, and every file matches its manifest entry with none added or removed, status says the working copy is clean without loading any history. Anything else takes a snapshot as before. The manifest now records the executable bit, since a mode change alters neither size nor time; an older manifest is simply not trusted until the next snapshot rewrites it. On the Linux tip a clean status now takes 0.72 s and 120 MB (Git: 0.22 s, 31 MB). refs #35
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 22:06 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
da868d5d26460cafe17d145651b213897fefb09becde1792a3df4d8aedc426c4- Depends on
- 135212de5370 1a3b62032ed9 1d360c91a289 75167672ebcd 7d9dab131937 86e1824f2d0b b7f102fcbb17 c6aa5bd9d1c8 cc7a00565161 fce44ae9ad29 ff0af1ee299d
- Depended on by
- b689779e9e74 f6dccc4533c8 0389c3e82f6c 3604179679eb d4dbab97e948 638fb21420d7 f9466021286a 1966f5db15ee 76740263089c a87c7b9d563a eaf3e9068180 d3cd7ef95da6
- 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
- 23 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/oxy-core/src/repo/prompt.rs +46 −11
| … | 135 unchanged lines |
||
| 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 | 138 | pub graph: Option<String>, |
|
| 139 | + | /// Whether the file was executable: a mode change alters neither size |
|
| 140 | + | /// nor time. |
|
| 141 | + | pub exec: bool, |
|
| 139 | 142 | } |
|
| 140 | 143 | ||
| 141 | 144 | #[derive(Clone, Debug, Default)] |
|
| … | 18 unchanged lines |
||
| 160 | 163 | pub const GLYPH_PROMPT_FORMAT: &str = "%i %b %c %s%U%O"; |
|
| 161 | 164 | ||
| 162 | 165 | /// One manifest line per file: size, mtime in nanoseconds, hash, blob ref |
|
| 163 | - | /// or `-`, graph key or `-`, path. |
|
| 166 | + | /// or `-`, graph key or `-`, `x` when executable or `-`, path. |
|
| 164 | 167 | fn manifest_line( |
|
| 165 | 168 | path: &str, |
|
| 166 | 169 | size: u64, |
|
| … | 1 unchanged lines |
||
| 168 | 171 | hash: &str, |
|
| 169 | 172 | blob: Option<ObjectRef>, |
|
| 170 | 173 | graph: Option<&str>, |
|
| 174 | + | exec: bool, |
|
| 171 | 175 | ) -> String { |
|
| 172 | 176 | format!( |
|
| 173 | - | "{size}\t{mtime_ns}\t{hash}\t{}\t{}\t{path}\n", |
|
| 177 | + | "{size}\t{mtime_ns}\t{hash}\t{}\t{}\t{}\t{path}\n", |
|
| 174 | 178 | blob.map(|b| b.to_hex()).unwrap_or_else(|| "-".into()), |
|
| 175 | - | graph.unwrap_or("-") |
|
| 179 | + | graph.unwrap_or("-"), |
|
| 180 | + | if exec { "x" } else { "-" } |
|
| 176 | 181 | ) |
|
| 177 | 182 | } |
|
| 178 | 183 | ||
| … | 51 unchanged lines |
||
| 230 | 235 | &hash, |
|
| 231 | 236 | blob, |
|
| 232 | 237 | e.graph_key.as_deref(), |
|
| 238 | + | e.exec, |
|
| 233 | 239 | )); |
|
| 234 | 240 | } |
|
| 235 | 241 | let p = self.manifest_path(); |
|
| … | 43 unchanged lines |
||
| 279 | 285 | } else if line.starts_with("# uncommitted=") { |
|
| 280 | 286 | out.push_str(&format!("# uncommitted={}\n", uncommitted as u8)); |
|
| 281 | 287 | } else { |
|
| 282 | - | let f: Vec<&str> = line.splitn(6, '\t').collect(); |
|
| 283 | - | match (f.len() == 6).then(|| keys.get(f[5])).flatten() { |
|
| 288 | + | let f: Vec<&str> = line.splitn(7, '\t').collect(); |
|
| 289 | + | match (f.len() == 7).then(|| keys.get(f[6])).flatten() { |
|
| 284 | 290 | Some(k) => out.push_str(&format!( |
|
| 285 | - | "{}\t{}\t{}\t{}\t{k}\t{}\n", |
|
| 286 | - | f[0], f[1], f[2], f[3], f[5] |
|
| 291 | + | "{}\t{}\t{}\t{}\t{k}\t{}\t{}\n", |
|
| 292 | + | f[0], f[1], f[2], f[3], f[5], f[6] |
|
| 287 | 293 | )), |
|
| 288 | 294 | None => { |
|
| 289 | 295 | out.push_str(line); |
|
| … | 34 unchanged lines |
||
| 324 | 330 | out.written_ns = v.trim().parse().unwrap_or(u128::MAX); |
|
| 325 | 331 | continue; |
|
| 326 | 332 | } |
|
| 327 | - | let f: Vec<&str> = line.splitn(6, '\t').collect(); |
|
| 328 | - | if f.len() == 6 { |
|
| 333 | + | // A line of another shape (an older manifest) is no entry: the |
|
| 334 | + | // file is then read, and the next snapshot rewrites the line. |
|
| 335 | + | let f: Vec<&str> = line.splitn(7, '\t').collect(); |
|
| 336 | + | if f.len() == 7 { |
|
| 329 | 337 | out.entries.insert( |
|
| 330 | - | f[5].to_string(), |
|
| 338 | + | f[6].to_string(), |
|
| 331 | 339 | ManifestEntry { |
|
| 332 | 340 | size: f[0].parse().unwrap_or(0), |
|
| 333 | 341 | mtime_ns: f[1].parse().unwrap_or(0), |
|
| 334 | 342 | hash: f[2].to_string(), |
|
| 335 | 343 | blob: ObjectRef::from_hex(f[3]), |
|
| 336 | 344 | graph: (f[4] != "-").then(|| f[4].to_string()), |
|
| 345 | + | exec: f[5] == "x", |
|
| 337 | 346 | }, |
|
| 338 | 347 | ); |
|
| 339 | 348 | } |
|
| … | 1 unchanged lines |
||
| 341 | 350 | Ok(Some((conflicted, uncommitted, out))) |
|
| 342 | 351 | } |
|
| 343 | 352 | ||
| 353 | + | /// Whether the working copy is certainly clean, known from the manifest |
|
| 354 | + | /// alone, without loading any history (spec §12.4): the last snapshot |
|
| 355 | + | /// or commit found no uncommitted work and no conflict, every entry is |
|
| 356 | + | /// older than the racy window (so an equal size and time mean equal |
|
| 357 | + | /// content), there are no subrepos (whose state the manifest does not |
|
| 358 | + | /// hold), and no file was added, removed or changed since. `false` |
|
| 359 | + | /// means only that a snapshot must decide. |
|
| 360 | + | pub fn clean_by_manifest(&self) -> Result<bool> { |
|
| 361 | + | let _t = crate::timing::phase("status: check the manifest"); |
|
| 362 | + | let Some((conflicted, uncommitted, m)) = self.read_manifest()? else { |
|
| 363 | + | return Ok(false); |
|
| 364 | + | }; |
|
| 365 | + | if conflicted || uncommitted || self.root.join(".oxymodules").exists() { |
|
| 366 | + | return Ok(false); |
|
| 367 | + | } |
|
| 368 | + | let racy = m |
|
| 369 | + | .entries |
|
| 370 | + | .values() |
|
| 371 | + | .any(|e| e.mtime_ns + 2_000_000_000 >= m.written_ns); |
|
| 372 | + | if racy { |
|
| 373 | + | return Ok(false); |
|
| 374 | + | } |
|
| 375 | + | Ok(self.quick_dirty()? == Some(false)) |
|
| 376 | + | } |
|
| 377 | + | ||
| 344 | 378 | /// Whether the working copy differs from the last manifest. Without a |
|
| 345 | 379 | /// manifest the answer is `None`. |
|
| 346 | 380 | pub fn quick_dirty(&self) -> Result<Option<bool>> { |
|
| … | 42 unchanged lines |
||
| 389 | 423 | } |
|
| 390 | 424 | continue; |
|
| 391 | 425 | } |
|
| 392 | - | if m.len() != me.size { |
|
| 426 | + | if m.len() != me.size || super::is_executable(&m) != me.exec { |
|
| 393 | 427 | return Ok(Some(true)); |
|
| 394 | 428 | } |
|
| 395 | 429 | if now_mtime != me.mtime_ns |
|
| … | 16 unchanged lines |
||
| 412 | 446 | continue; |
|
| 413 | 447 | } |
|
| 414 | 448 | if m.len() != me.size |
|
| 449 | + | || super::is_executable(&m) != me.exec |
|
| 415 | 450 | || (mtime_ns(&m) != me.mtime_ns |
|
| 416 | 451 | && blake3::hash(&fs::read(&path)?).to_hex().as_str() != me.hash) |
|
| 417 | 452 | { |
|
| … | 81 unchanged lines |
modified docs/benchmarks.md +11 −3
| … | 295 unchanged lines |
||
| 296 | 296 | | Clean `status` on the Linux tip | Time | Peak RSS | |
|
| 297 | 297 | |---|---:|---:| |
|
| 298 | 298 | | Before | killed at 14 GB | | |
|
| 299 | - | | After | 3.7 s | 768 MB | |
|
| 300 | - | | Git | 0.3 s | 31 MB | |
|
| 299 | + | | Lazy text and keys at commit | 3.7 s | 768 MB | |
|
| 300 | + | | And answered from the manifest | 0.72 s | 120 MB | |
|
| 301 | + | | Git | 0.22 s | 31 MB | |
|
| 302 | + | ||
| 303 | + | A clean status is now answered from the manifest alone, loading no |
|
| 304 | + | history. The manifest also records the executable bit, since a mode |
|
| 305 | + | change alters neither size nor time. Any change, a manifest older than |
|
| 306 | + | the racy window, or subrepos send it back to a full snapshot. |
|
| 301 | 307 | ||
| 302 | - | Still to do: status to the 0.4 s and 300 MB target; `fsck` is killed at |
|
| 308 | + | Still to do: status to the 0.4 s target (the walk and reading the |
|
| 309 | + | manifest); a status without a usable manifest still builds every graph |
|
| 310 | + | and is killed at 14 GB; `fsck` is killed at |
|
| 303 | 311 | 14 GB; the first commit takes 57 s and 10.2 GB. |
|
| 304 | 312 |
modified docs/spec.md +4 −2
| … | 498 unchanged lines |
||
| 499 | 499 | ||
| 500 | 500 | Implementations MUST support a stat-based crawl with a persistent cache keyed by at least `(path, size, mtime)` and SHOULD support a file-system monitor (Watchman or a native inotify/FSEvents/ReadDirectoryChangesW watcher) for the latency targets in §25. |
|
| 501 | 501 | ||
| 502 | - | The reference implementation's cache is the workspace **manifest** written after every snapshot, commit, and checkout: per file its size, mtime, content hash, and, for an opaque file, the ObjectRef of its stored blob. A text file found byte for byte equal to the base state's unconflicted rendering also records its **graph key**: a hash of the file's node, the checkpoint graph its graph was built from (a blob ref, an inline value, or none), and the set of later changes that edited it. Equal keys mean equal graphs and so equal renderings, since a rendering depends only on the graph and the changes' immutable metadata. On the next snapshot a file whose size and mtime match is not read: an opaque file contributes its recorded blob, and a text file whose recorded graph key equals the key of its base graph now is known equal to the base, so it is neither read nor rendered (a stat-clean file that differs from the base, such as an older uncommitted edit, is always read). Attributes decide how a file compares with the base, so `.oxyattributes` files are always read, and when any of them is added, removed, edited, or changed in the base, no text file is skipped. A file whose mtime lies within two seconds of the manifest's own write time is never trusted (the racy-stat window), so a modification that preserves size and lands in the same timestamp tick is still seen. An implementation MAY use a file-system monitor instead of, or in addition to, the manifest. |
|
| 502 | + | The reference implementation's cache is the workspace **manifest** written after every snapshot, commit, and checkout: per file its size, mtime, executable bit, content hash, and, for an opaque file, the ObjectRef of its stored blob. A text file found byte for byte equal to the base state's unconflicted rendering also records its **graph key**: a hash of the file's node, the checkpoint graph its graph was built from (a blob ref, an inline value, or none), and the set of later changes that edited it. Equal keys mean equal graphs and so equal renderings, since a rendering depends only on the graph and the changes' immutable metadata. On the next snapshot a file whose size and mtime match is not read: an opaque file contributes its recorded blob, and a text file whose recorded graph key equals the key of its base graph now is known equal to the base, so it is neither read nor rendered (a stat-clean file that differs from the base, such as an older uncommitted edit, is always read). Attributes decide how a file compares with the base, so `.oxyattributes` files are always read, and when any of them is added, removed, edited, or changed in the base, no text file is skipped. A file whose mtime lies within two seconds of the manifest's own write time is never trusted (the racy-stat window), so a modification that preserves size and lands in the same timestamp tick is still seen. An implementation MAY use a file-system monitor instead of, or in addition to, the manifest. |
|
| 503 | + | ||
| 504 | + | A commit records in the manifest the graph key of every text file of its new base (except files with an `eol` attribute, whose bytes on disk are not the rendering), from a history-only load that builds no graph, so the first snapshot after it reads and renders no unchanged file. A history-only load reads no stored insert's text blob (§7.4) until it builds that file's graph. `oxy status` answers from the manifest alone, loading no history, when the manifest records no uncommitted work and no conflict, every entry is older than the racy window, the repository has no subrepos, and every file matches its entry (size, mtime or content, executable bit) with none added or removed; anything else takes a snapshot. |
|
| 503 | 505 | ||
| 504 | 506 | ## 13. Storage engine |
|
| 505 | 507 | ||
| … | 468 unchanged lines |
||
| 974 | 976 | ||
| 975 | 977 | --- |
|
| 976 | 978 | ||
| 977 | - | *Changelog.* Draft 0.8r (2026-09-29): a parent checkout never moves a subrepo's bookmark backwards, and the pin is read from the nested working copy (§7.8); `bookmark create --upto/--only` (§10.1); the working-copy lock covers every command that writes the working copy; a workspace changed by two concurrent operations is an error (§12.1). Draft 0.8q (2026-09-29): change format version 2 stores large inserts as text blobs, so a change does not grow with the files it adds (§6.1, §7.4); no object may exceed 1 GiB (§13.2). Draft 0.8p (2026-09-29): Git import derives new NodeIds from the commit, making imports deterministic (§23). Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 979 | + | *Changelog.* Draft 0.8r (2026-09-29): graph keys at commit, lazy text in history-only loads, and a clean `status` from the manifest (§12.4); a parent checkout never moves a subrepo's bookmark backwards, and the pin is read from the nested working copy (§7.8); `bookmark create --upto/--only` (§10.1); the working-copy lock covers every command that writes the working copy; a workspace changed by two concurrent operations is an error (§12.1). Draft 0.8q (2026-09-29): change format version 2 stores large inserts as text blobs, so a change does not grow with the files it adds (§6.1, §7.4); no object may exceed 1 GiB (§13.2). Draft 0.8p (2026-09-29): Git import derives new NodeIds from the commit, making imports deterministic (§23). Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11). |
|
| 978 | 980 | ||
| 979 | 981 | Draft 0.8i (2026-09-28): portability rules for exec bits and path names (§7.7); found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and `oxy git verify` (§23). Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands. |
|
| 980 | 982 | ||
| … | 61 unchanged lines |
modified src/cli/workspace.rs +20 −0
| … | 57 unchanged lines |
||
| 58 | 58 | ||
| 59 | 59 | pub fn status(json: bool) -> Result<i32> { |
|
| 60 | 60 | let repo = open()?; |
|
| 61 | + | // Clean by the manifest alone: no history to load (oxygen#35). |
|
| 62 | + | if !json && repo.clean_by_manifest()? { |
|
| 63 | + | let (_, wc) = repo.working_copy()?; |
|
| 64 | + | let heads = repo.bookmark_heads()?; |
|
| 65 | + | println!( |
|
| 66 | + | "{} {}{}working copy {}{}{}", |
|
| 67 | + | ui::dim("On"), |
|
| 68 | + | ui::bookmark(&repo.config.bookmark), |
|
| 69 | + | ui::sep(), |
|
| 70 | + | ui::id(wc.change_id.short(8)), |
|
| 71 | + | ui::sep(), |
|
| 72 | + | ui::dim(plural(heads.len(), "head")) |
|
| 73 | + | ); |
|
| 74 | + | println!( |
|
| 75 | + | " {} {}", |
|
| 76 | + | ui::ok(ui::glyph("✔", "*")), |
|
| 77 | + | ui::dim("nothing to commit, working copy clean") |
|
| 78 | + | ); |
|
| 79 | + | return Ok(0); |
|
| 80 | + | } |
|
| 61 | 81 | let sp = ui::spinner("Snapshotting the working copy…"); |
|
| 62 | 82 | let snap = repo.snapshot_status("oxy status")?; |
|
| 63 | 83 | let heads = repo.bookmark_heads()?; |
|
| … | 1785 unchanged lines |
modified tests/cli_json.rs +52 −0
| … | 487 unchanged lines |
||
| 488 | 488 | assert!(out.contains("feat is up to date with origin"), "{out}"); |
|
| 489 | 489 | assert!(out.contains("main is 1 change behind origin/main"), "{out}"); |
|
| 490 | 490 | } |
|
| 491 | + | ||
| 492 | + | /// oxygen#35: a clean status is answered from the manifest, loading no |
|
| 493 | + | /// history; any change, a mode change included, falls back to a snapshot. |
|
| 494 | + | #[test] |
|
| 495 | + | fn a_clean_status_needs_no_history_and_misses_no_change() { |
|
| 496 | + | use std::os::unix::fs::PermissionsExt; |
|
| 497 | + | let tmp = tempfile::tempdir().unwrap(); |
|
| 498 | + | let home = tmp.path().join("home"); |
|
| 499 | + | std::fs::create_dir_all(&home).unwrap(); |
|
| 500 | + | let r = tmp.path().join("repo"); |
|
| 501 | + | assert_eq!(oxy(tmp.path(), &home, &["init", r.to_str().unwrap()]).0, 0); |
|
| 502 | + | let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30); |
|
| 503 | + | for f in ["a.txt", "b.txt"] { |
|
| 504 | + | write(&r, f, &format!("{f}\n")); |
|
| 505 | + | std::fs::File::options() |
|
| 506 | + | .write(true) |
|
| 507 | + | .open(r.join(f)) |
|
| 508 | + | .unwrap() |
|
| 509 | + | .set_modified(old) |
|
| 510 | + | .unwrap(); |
|
| 511 | + | } |
|
| 512 | + | assert_eq!(oxy(&r, &home, &["commit", "-m", "two"]).0, 0); |
|
| 513 | + | let status = |r: &Path| { |
|
| 514 | + | let out = Command::new(env!("CARGO_BIN_EXE_oxy")) |
|
| 515 | + | .args(["--plain", "status"]) |
|
| 516 | + | .current_dir(r) |
|
| 517 | + | .env("OXY_HOME", &home) |
|
| 518 | + | .env("NO_COLOR", "1") |
|
| 519 | + | .env("OXY_TIMINGS", "1") |
|
| 520 | + | .output() |
|
| 521 | + | .unwrap(); |
|
| 522 | + | ( |
|
| 523 | + | String::from_utf8_lossy(&out.stdout).to_string(), |
|
| 524 | + | String::from_utf8_lossy(&out.stderr).to_string(), |
|
| 525 | + | ) |
|
| 526 | + | }; |
|
| 527 | + | let (out, err) = status(&r); |
|
| 528 | + | assert!(out.contains("working copy clean"), "{out}"); |
|
| 529 | + | assert!(!err.contains("snapshot"), "no snapshot was needed: {err}"); |
|
| 530 | + | ||
| 531 | + | // A mode change alters neither size nor time. |
|
| 532 | + | std::fs::set_permissions(r.join("a.txt"), std::fs::Permissions::from_mode(0o755)).unwrap(); |
|
| 533 | + | let (out, err) = status(&r); |
|
| 534 | + | assert!(out.contains("a.txt"), "{out}"); |
|
| 535 | + | assert!(err.contains("snapshot"), "{err}"); |
|
| 536 | + | std::fs::set_permissions(r.join("a.txt"), std::fs::Permissions::from_mode(0o644)).unwrap(); |
|
| 537 | + | ||
| 538 | + | // A new file. |
|
| 539 | + | write(&r, "c.txt", "c\n"); |
|
| 540 | + | let (out, _) = status(&r); |
|
| 541 | + | assert!(out.contains("c.txt"), "{out}"); |
|
| 542 | + | } |