OXYGENHUB

Spec review pass (Draft 0.7): oxy status --json, section 24 aligned with the CLI, reference implementation status table, capabilities and snapshot wording aligned with the implementation 50a046e439e9

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 00:21 UTC
Signature
✔ signed by Andy McCall
Hash
50a046e439e935ba6f7bd7fd745dcf2e566c526370df90a299e790a8e6b6bdfe
Depends on
135212de5370 20247a03a596 28fc23d7706a 30ea9c1e70fa 96f7610fa666 982864068a59 b7f102fcbb17 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
Depended on by
91334b7fa574 4afe288b7946 0299076f09a2 b689779e9e74 f6dccc4533c8 2c9ddac3c387 3604179679eb d4dbab97e948 83d61e803ccd 6a81dbab471d 1c2bd9abdd4f 62794ecc7f74 d82e44097323 7aa2dc9369e3 05e71ed6cdf1 75167672ebcd e02bf0cd0533 c6aa5bd9d1c8 728da0f92161 957fee30826e 4dee277c327a fbda43e7d8ed 86fe29fd62ea 9ac1fbe211b4 694805cca585
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
27 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified crates/oxy-core/src/repo/snapshot.rs +65 −0
…
179 unchanged lines
180 180
        Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed, resolves, contributors })
181 181
    }
182 182
183 +
    /// `oxy status --json` (spec §9.5): the changed paths and every conflict
184 +
    /// of the working copy's base with its kind, path, and contributing
185 +
    /// ChangeIds.
186 +
    pub fn status_json(&self, snap: &SnapshotResult) -> Result<String> {
187 +
        let heads = self.bookmark_heads()?;
188 +
        let base_snapshot = &snap.base_snapshot;
189 +
        let by_node = base_snapshot.tree.by_node();
190 +
        let path_of = |n: &NodeId| by_node.get(n).map(|e| e.path.clone());
191 +
        let id_of = |h: &ChangeHash| snap.base.get(h).map(|m| m.change_id.to_hex()).unwrap_or_else(|| h.to_hex());
192 +
        let paths = json_array(snap.statuses.iter().map(|s| format!("{{\"path\":{},\"status\":{}}}", json_str(&s.path), json_str(&format!("{:?}", s.status)))));
193 +
        let mut conflicts: Vec<String> = Vec::new();
194 +
        for (p, f) in &base_snapshot.files {
195 +
            if !f.is_conflicted() {
196 +
                continue;
197 +
            }
198 +
            let (kind, changes) = match &f.content {
199 +
                FileContent::Text { .. } => ("text", snap.base.conflict_regions(&f.node).contributors),
200 +
                FileContent::Opaque { blobs } => ("replace", snap.base.blob_contributors(&f.node, blobs)),
201 +
                FileContent::Dir => continue,
202 +
            };
203 +
            let resolved = match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
204 +
                Some(ChangeStatus::Resolved) => "true",
205 +
                Some(ChangeStatus::ConflictEdited) => "\"markers\"",
206 +
                _ => "false",
207 +
            };
208 +
            conflicts.push(format!(
209 +
                "{{\"kind\":{},\"path\":{},\"node\":{},\"changes\":{},\"resolved\":{resolved}}}",
210 +
                json_str(kind),
211 +
                json_str(p),
212 +
                json_str(&f.node.to_hex()),
213 +
                json_array(changes.iter().map(|h| json_str(&id_of(h))))
214 +
            ));
215 +
        }
216 +
        for c in &base_snapshot.tree.conflicts {
217 +
            let (kind, nodes): (&str, Vec<NodeId>) = match c {
218 +
                crate::tree::TreeConflict::Name { nodes, .. } => ("name", nodes.clone()),
219 +
                crate::tree::TreeConflict::MultiPlacement { node, .. } => ("multi-placement", vec![*node]),
220 +
                crate::tree::TreeConflict::Orphan { node, .. } => ("orphan", vec![*node]),
221 +
                crate::tree::TreeConflict::Cycle { node } => ("cycle", vec![*node]),
222 +
                crate::tree::TreeConflict::Mode { node, .. } => ("mode", vec![*node]),
223 +
            };
224 +
            let detail = match c {
225 +
                crate::tree::TreeConflict::Name { name, parent, .. } => {
226 +
                    let dir = if *parent == NodeId::ROOT { String::new() } else { path_of(parent).unwrap_or_default() };
227 +
                    format!(",\"path\":{}", json_str(&if dir.is_empty() { name.clone() } else { format!("{dir}/{name}") }))
228 +
                }
229 +
                _ => nodes.first().and_then(path_of).map(|p| format!(",\"path\":{}", json_str(&p))).unwrap_or_default(),
230 +
            };
231 +
            conflicts.push(format!(
232 +
                "{{\"kind\":{},\"nodes\":{}{detail}}}",
233 +
                json_str(kind),
234 +
                json_array(nodes.iter().map(|n| json_str(&n.to_hex())))
235 +
            ));
236 +
        }
237 +
        Ok(format!(
238 +
            "{{\"bookmark\":{},\"working_copy\":{},\"heads\":{},\"clean\":{},\"conflicted\":{},\"paths\":{paths},\"conflicts\":{}}}",
239 +
            json_str(&self.config.bookmark),
240 +
            json_str(&snap.wc.change_id.to_hex()),
241 +
            heads.len(),
242 +
            snap.statuses.is_empty(),
243 +
            base_snapshot.is_conflicted(),
244 +
            json_array(conflicts)
245 +
        ))
246 +
    }
247 +
183 248
    /// Turn the working-copy change into a normal change on the bookmark (spec §12.3).
184 249
    pub fn commit(&self, message: &str, extra_deps: &[ChangeHash]) -> Result<ChangeHash> {
185 250
        let snap = self.snapshot("oxy commit")?;
…
628 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +35 −0
…
1981 unchanged lines
1982 1982
    assert!(!repo.snapshot("status").unwrap().changed);
1983 1983
}
1984 1984
1985 +
#[test]
1986 +
fn status_json_lists_conflicts_with_contributors() {
1987 +
    hermetic();
1988 +
    let dir = tempfile::tempdir().unwrap();
1989 +
    let root = dir.path();
1990 +
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
1991 +
    write(root, "a.txt", b"one\n");
1992 +
    let h1 = repo.commit("base", &[]).unwrap();
1993 +
    write(root, "a.txt", b"one\nleft\n");
1994 +
    let ha = repo.commit("left", &[]).unwrap();
1995 +
    repo.checkout(&[h1], true, "checkout").unwrap();
1996 +
    write(root, "a.txt", b"one\nright\n");
1997 +
    let hb = repo.commit("right", &[]).unwrap();
1998 +
    repo.checkout(&[ha, hb], true, "checkout").unwrap();
1999 +
    let snap = repo.snapshot("status").unwrap();
2000 +
    assert!(snap.base_snapshot.is_conflicted());
2001 +
    let json = repo.status_json(&snap).unwrap();
2002 +
    let ida = repo.store().get_change(&ha).unwrap().change_id.to_hex();
2003 +
    let idb = repo.store().get_change(&hb).unwrap().change_id.to_hex();
2004 +
    assert!(json.contains("\"conflicted\":true"), "{json}");
2005 +
    assert!(json.contains("\"kind\":\"text\",\"path\":\"a.txt\""), "{json}");
2006 +
    assert!(json.contains(&ida) && json.contains(&idb), "{json}");
2007 +
    assert!(json.contains("\"resolved\":false"), "{json}");
2008 +
    assert!(json.starts_with('{') && json.ends_with('}'));
2009 +
    // Resolve and the report says so; a clean copy has no conflicts.
2010 +
    write(root, "a.txt", b"one\nleft\nright\n");
2011 +
    let snap = repo.snapshot("status").unwrap();
2012 +
    let json = repo.status_json(&snap).unwrap();
2013 +
    assert!(json.contains("\"resolved\":true"), "{json}");
2014 +
    repo.commit("resolve", &[]).unwrap();
2015 +
    let snap = repo.snapshot("status").unwrap();
2016 +
    let json = repo.status_json(&snap).unwrap();
2017 +
    assert!(json.contains("\"clean\":true,\"conflicted\":false,\"paths\":[],\"conflicts\":[]"), "{json}");
2018 +
}
2019 +
modified crates/oxy-core/src/state.rs +13 −0
…
363 unchanged lines
364 364
    /// For resolving a conflicted text node (spec §9.6): the live atoms that
365 365
    /// are not part of any conflict region in order, every live atom that is
366 366
    /// part of a region, and every change that contributed to a region.
367 +
    /// The changes that introduced the live blobs of an opaque node, for a
368 +
    /// replace conflict (spec §9.3).
369 +
    pub fn blob_contributors(&self, node: &NodeId, blobs: &[ObjectRef]) -> Vec<ChangeHash> {
370 +
        let mut out: Vec<ChangeHash> = Vec::new();
371 +
        for (h, _, new) in self.replaces.get(node).map(|v| v.as_slice()).unwrap_or(&[]) {
372 +
            if new.is_some_and(|b| blobs.contains(&b)) && !out.contains(h) {
373 +
                out.push(*h);
374 +
            }
375 +
        }
376 +
        out.sort();
377 +
        out
378 +
    }
379 +
367 380
    pub fn conflict_regions(&self, node: &NodeId) -> ConflictRegions {
368 381
        let mut out = ConflictRegions::default();
369 382
        let Some(g) = self.files.get(node) else { return out };
…
212 unchanged lines
modified docs/guide.md +2 −1
…
58 unchanged lines
59 59
oxy show 658990aa          # one change by ChangeId prefix, #hash prefix, or @
60 60
```
61 61
62 -
`status` lists each path with a mark: `✚ added`, `✎ modified`, `✖ removed`,
62 +
`status --json` prints the same for tools, with every conflict's kind, path and
63 +
contributing ChangeIds. On a terminal, `status` lists each path with a mark: `✚ added`, `✎ modified`, `✖ removed`,
63 64
`➜ renamed`, `⚙ mode`. Renames are detected: a removed file and an added
64 65
file of the same kind whose contents are at least half alike are recorded
65 66
as a move of the same file, so identity survives a rename.
…
277 unchanged lines
modified docs/plan.md +2 −1
…
33 unchanged lines
34 34
- [x] Performance follow-up from the 1 GiB benchmark: commit takes 5.6 s on tmpfs but 63 s on the SSD, and clone 49 s, because every object is written with its own fsync (13,715 chunks). Write committed and fetched objects straight into a pack with one fsync, or batch loose writes. (Done: store write batches; small batches stay loose, large ones spill to one pack. SSD: commit 63 s → 8.8 s, clone 49 s → 15.9 s; spec 0.6r.)
35 35
- [x] Performance follow-up: `oxy status` re-hashes a clean 1 GiB working copy (2.4 s). Record size and mtime in the manifest and skip hashing files whose stat is unchanged (spec allows a stat cache). (Done: manifest records blob refs for opaque files; unchanged files are not read; two-second racy window; status on a clean 1 GiB copy 2.4 s → 4 ms; spec 0.6s.)
36 36
- [x] Documentation: `docs/guide.md` (user guide by task) and `docs/protocol.md` (for other implementers, derived from spec §16 and the wire encodings). (Done; every command and flag in the guide was checked against `--help`.)
37 -
- [ ] Spec review pass: resolve or re-word every "MAY ... until" allowance introduced during implementation, and close what can be closed in §27.
37 +
- [x] Spec review pass: resolve or re-word every "MAY ... until" allowance introduced during implementation, and close what can be closed in §27. (Done as Draft 0.7: no "MAY ... until" wording remained; `oxy status --json` implemented to meet §9.5; §12.4, §16.1 and §24 aligned with the implementation; new §26.1 status table; §27 records workspace conflicts and server locks as open.)
38 38
39 39
## Log
40 40
41 41
- 2026-09-26 23:01 plan created.
42 +
- 2026-09-27 10:00 iteration 18: spec review pass (Draft 0.7) and oxy status --json. 113 tests.
42 43
- 2026-09-27 09:35 iteration 17: user guide and protocol document. 112 tests.
43 44
- 2026-09-27 09:10 iteration 16: manifest stat cache (status 2.4 s → 4 ms on 1 GiB). 112 tests.
44 45
- 2026-09-27 08:40 iteration 15: batched object writes (commit 8.8 s, clone 15.9 s for 1 GiB on SSD). 111 tests.
…
14 unchanged lines
modified docs/spec.md +47 −20
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.6s |
5 +
| **Status** | Draft 0.7 |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
351 unchanged lines
360 360
361 361
The short identifiers are ChangeId prefixes. Delete/insert conflicts render the deleted context as a `-------` section. Tree conflicts are materialized by writing each candidate with a suffix `.oxy-conflict-<n>` and are listed by `oxy status`. Replace conflicts materialize as `<name>.oxy-conflict-<n>` files.
362 362
363 -
`oxy status` MUST list every conflict, MUST exit with status 2 when any conflict exists in the working-copy state, MUST exit with status 3 when only workspace conflicts (§9.7) exist, and MUST support `--json` output enumerating conflicts by node, kind, and contributing ChangeIds.
363 +
`oxy status` MUST list every conflict, MUST exit with status 2 when any conflict exists in the working-copy state, MUST exit with status 3 when only workspace conflicts (§9.7) exist, and MUST support `--json` output enumerating conflicts by node, kind, and contributing ChangeIds. The JSON object has `bookmark`, `working_copy` (ChangeId), `heads`, `clean`, `conflicted`, `paths` (`[{path, status}]`) and `conflicts`: for a text or replace conflict `{kind, path, node, changes: [ChangeId], resolved: true | false | "markers"}`, for a tree conflict `{kind: name | multi-placement | orphan | cycle | mode, nodes, path?}`.
364 364
365 365
### 9.6 Resolution
366 366
…
98 unchanged lines
465 465
466 466
### 12.4 Snapshot performance
467 467
468 -
Implementations MUST support a file-system monitor (Watchman or a native inotify/FSEvents/ReadDirectoryChangesW watcher) and MUST fall back to a stat-based crawl with a persistent cache keyed by `(path, size, mtime, inode, ctime)`. Snapshot latency targets are in §25.
468 +
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.
469 469
470 470
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. On the next snapshot a file whose size and mtime match is not read: an opaque file contributes its recorded blob, a text file keeps its recorded hash. 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.
471 471
…
198 unchanged lines
670 670
671 671
| Endpoint | Request | Response |
672 672
|---|---|---|
673 -
| `GET capabilities` | — | `{ versions: [1], transports, features: ["lazy", "checkpoint", "seal", "lock"], max_frame }` |
673 +
| `GET capabilities` | — | `{ versions: [1], features }`; feature names are `seal`, and, when offered, `lock`; `max_frame` and `transports` are reserved for framed transports |
674 674
| `GET refs` | `{ patterns }` | `[ { name, state: ObjectRef, heads, attestation: Signed<Attest> } ]` plus the current `Signed<Policy>` |
675 675
| `POST negotiate` | `{ have: [ChangeHash], want: [ChangeHash], filter }` | stream of `ChangeHash` the client lacks, in dependency order |
676 676
| `POST fetch` | `{ changes: [ChangeHash], include_blobs: bool, filter }` | stream of `Signed<Change>`, then every `Signed<Attest kind=seal>` covering them, then referenced `blob`/`chunk` objects |
…
114 unchanged lines
791 791
Commands mirror Jujutsu where semantics match, so that muscle memory transfers.
792 792
793 793
```
794 -
oxy init [--no-policy]              oxy clone <url> [--sparse PATH..] [--depth N] [--max-blob SIZE]
795 -
oxy status [--json]                 oxy diff [-r CHANGE] [PATH..]
796 -
oxy commit [-m MSG] [--depends-on CHANGE].. [--sign]
797 -
                                    oxy new [PARENT..]           oxy edit CHANGE
798 -
oxy split                           oxy squash                   oxy abandon CHANGE
799 -
oxy rebase -s CHANGE -d DEST        oxy resolve [PATH..]
800 -
oxy log [-r REVSET]                 oxy show CHANGE
801 -
oxy bookmark create|set|delete|list oxy tag create NAME -r CHANGE
802 -
oxy fetch [REMOTE]                  oxy push [REMOTE] [-b BOOKMARK]
803 -
oxy undo                            oxy redo                     oxy op log
804 -
oxy lock PATH                       oxy unlock PATH
794 +
oxy init [PATH]                     oxy clone URL [--sparse PATH].. [--depth N] [--max-blob SIZE]
795 +
oxy status [--json]                 oxy diff                     oxy prompt [--format F]
796 +
oxy commit -m MSG [--depends-on CHANGE]..
797 +
oxy log [-n N] [-v]                 oxy show CHANGE
798 +
oxy describe [CHANGE] -m MSG        oxy amend [-m MSG] [--into CHANGE]
799 +
oxy split CHANGE [PATH]..           oxy abandon CHANGE           oxy resolve
800 +
oxy checkout [CHANGE].. [--discard] oxy undo                     oxy op log
801 +
oxy tag create NAME [CHANGE].. [-m MSG]   oxy tag list
802 +
oxy workspace add|list|forget       oxy sparse [PATH]..          oxy deepen [N]
803 +
oxy fetch [REMOTE]                  oxy pull [REMOTE]            oxy push [REMOTE] [--force]
804 +
oxy remote add|remove|list          oxy serve                    oxy lock|unlock PATH
805 805
oxy identity create|show|export|rotate|renew|revoke|delegate
806 -
oxy policy init|show|grant|revoke|root|sign
807 -
oxy remote add|trust|list           oxy gc
806 +
oxy policy init|show|grant|revoke|root|sign                      oxy verify [CHANGE]
808 807
oxy git import|export|fetch|push   (fetch and push go through a bare mirror under .oxy/git/)
808 +
oxy checkpoint                      oxy gc [--now]               oxy config [--user] KEY VALUE
809 809
```
810 810
811 -
Change arguments accept a ChangeId prefix, a ChangeHash prefix (with `#`), a bookmark, `@` for the working-copy change, and `@-` for its parent. Ambiguous prefixes are an error.
811 +
Change arguments accept a ChangeId prefix, a ChangeHash prefix (with `#`), a bookmark or tag name, and `@` for the working-copy change. Ambiguous prefixes are an error.
812 812
813 +
Reserved for a later version, with these names: `oxy new`, `oxy edit`, `oxy squash`, `oxy rebase` (commuting changes need no rebase; the command would re-express a change against different dependencies), `oxy redo`, revsets (`log -r`), `oxy bookmark create|set|delete|list` (one bookmark per workspace today), and `oxy remote trust` (with the Noise transport, §15).
814 +
813 815
## 25. Performance targets
814 816
815 817
Measured on the Full profile, warm cache, NVMe storage, 8-core x86-64, with a 20 GiB repository of 2 million files and 500 000 changes.
…
25 unchanged lines
841 843
- Adversarial chunking: inputs with no gear boundaries and inputs with boundaries at every byte.
842 844
- Git round trip on a corpus of public repositories.
843 845
846 +
### 26.1 Reference implementation status
847 +
848 +
The reference implementation (`crates/oxy-core`, `crates/oxy-server`, the `oxy` binary) as of this draft:
849 +
850 +
| Area | Status |
851 +
|---|---|
852 +
| Objects, hashing, deterministic CBOR (§5) | Implemented; format 2 repositories, migration from format 1 |
853 +
| Change graph, commutation, conflicts and resolution (§6–§9) | Implemented, including order, delete/insert, tree, replace, and mode conflicts; workspace conflicts (§9.7) and exit status 3 are not detected |
854 +
| Rewrites (§8.4) | Implemented: describe, amend (also into older changes), split, abandon; orphaned references re-anchored |
855 +
| Operation log, undo (§12.1) | Implemented; `redo` reserved |
856 +
| Snapshots and the stat cache (§12.3, §12.4) | Implemented; no file-system monitor yet |
857 +
| Storage: loose objects, packs, GC, checkpoints, batching (§11, §13, §21) | Implemented; pack indexes are read fully rather than mapped |
858 +
| Identity, composite signatures, seals, attestations, policy, lifecycle (§14) | Implemented; `lock` attestations reserved |
859 +
| HTTPS transport and endpoints (§16) | Implemented, HTTP only; Noise and ZCT (§15) reserved; negotiation is exact (no Merkle buckets); `refs` takes no `patterns` |
860 +
| Partial, sparse, shallow (§17) | Implemented |
861 +
| Locks (§18) | Local advisory locks only; server locks and the `lock` endpoint reserved |
862 +
| Configuration, attributes, hooks (§19, §20) | Implemented (`.oxyattributes`: binary, text, eol, nocompress, lockable) |
863 +
| Thin profile and proxy (§22) | Reserved |
864 +
| Git interoperability (§23) | Implemented: import, export, fetch, push, rename detection |
865 +
| Conformance suite (§26) | Partial: encoding, signature, permutation, conflict, rewrite, protocol, chunking and Git tests exist as unit and integration tests in the crates; a standalone vector suite is not published yet |
866 +
844 867
## 27. Open questions
845 868
846 869
1. **Encryption at rest.** Per-repository XChaCha20-Poly1305 with a key hierarchy is reserved for format version 2; object headers already leave room. Convergent encryption is rejected because it leaks equality.
847 870
2. **Word- or character-granularity atoms** for prose and minified files. Line atoms are fixed for v1.
848 -
3. **Set reconciliation** at the scale of tens of millions of changes may need an IBLT-based negotiator; the Merkle bucket scheme in §16.2 is the v1 answer.
871 +
3. **Set reconciliation** at the scale of tens of millions of changes may need an IBLT-based negotiator; the Merkle bucket scheme in §16.2 is the v1 answer, and the reference implementation's exact negotiation is sufficient below that scale.
872 +
4. **Workspace conflicts (§9.7)** on case-insensitive file systems are specified but not yet detected by the reference implementation.
873 +
5. **Server-side locks (§18)** need the `lock` endpoint and attestation to be exercised by an implementation before their wording is final.
849 874
850 875
---
851 876
852 -
*Changelog.* Draft 0.6s (2026-09-27): the manifest as a stat cache with a racy-stat window (§12.4).
877 +
*Changelog.* Draft 0.7 (2026-09-27): review pass. `oxy status --json` shape (§9.5); stat crawl required and file-system monitor recommended (§12.4); capabilities aligned with the implementation (§16.1); §24 lists the implemented command line and names the reserved commands; new §26.1 reference implementation status; §27 records the open workspace-conflict and lock items.
878 +
879 +
Draft 0.6s (2026-09-27): the manifest as a stat cache with a racy-stat window (§12.4).
853 880
854 881
Draft 0.6r (2026-09-27): batched object writes (§13.4).
855 882
…
51 unchanged lines
modified src/cli/mod.rs +6 −2
…
48 unchanged lines
49 49
    /// Show or set the sparse filter (paths to materialize); re-checks out the bookmark
50 50
    Sparse { paths: Vec<String> },
51 51
    /// Show the working copy: changed paths and conflicts
52 -
    Status,
52 +
    Status {
53 +
        /// Machine-readable output: paths, and every conflict with its kind and contributing changes
54 +
        #[arg(long)]
55 +
        json: bool,
56 +
    },
53 57
    /// One-line summary for shell prompts; fast and records nothing
54 58
    Prompt {
55 59
        /// Placeholders: %b bookmark, %c change id, %h heads, %d dirty (*), %x conflict (!), %A/%D ahead/behind (+n/-n), %a/%B raw counts
…
324 unchanged lines
380 384
    ui::init(cli.plain);
381 385
    match cli.cmd {
382 386
        Cmd::Init { path } => workspace::init(path),
383 -
        Cmd::Status => workspace::status(),
387 +
        Cmd::Status { json } => workspace::status(json),
384 388
        Cmd::Prompt { format } => workspace::prompt(&format),
385 389
        Cmd::Commit { message, depends_on } => workspace::commit(&message, &depends_on),
386 390
        Cmd::Log { limit, verbose } => workspace::log(limit, verbose),
…
68 unchanged lines
modified src/cli/workspace.rs +5 −1
…
28 unchanged lines
29 29
    }
30 30
}
31 31
32 -
pub fn status() -> Result<i32> {
32 +
pub fn status(json: bool) -> Result<i32> {
33 33
    let repo = open()?;
34 34
    let sp = ui::spinner("Snapshotting the working copy…");
35 35
    let snap = repo.snapshot("oxy status")?;
36 36
    let heads = repo.bookmark_heads()?;
37 37
    sp.clear();
38 +
    if json {
39 +
        println!("{}", repo.status_json(&snap)?);
40 +
        return Ok(if snap.base_snapshot.is_conflicted() { 2 } else { 0 });
41 +
    }
38 42
    println!(
39 43
        "{} {}{}working copy {}{}{}",
40 44
        ui::dim("On"),
…
442 unchanged lines