OXYGENHUB

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 +
}