OXYGENHUB

Machine interface for editors (spec 0.8b): oxy root, cat, version, hunks, discard by path or hunk; --json on log (with --path), show, diff, resolve, op log, bookmark list, remote list, fetch, pull, push, submodule status, bisect status, verify, policy show; commit --hunks; integration test over every JSON command c6aa5bd9d1c8

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 15:04 UTC
Signature
✔ signed by Andy McCall
Hash
c6aa5bd9d1c878baa11e43d6b1e10e95b848f6fce079fcf8b74fadba2b2930e0
Depends on
135212de5370 252e8cb23fbc 28fc23d7706a 4dee277c327a 50a046e439e9 614541c3cebd 694805cca585 728da0f92161 750251b21a96 7b9809e67c3d 83e0677b07fe 86fe29fd62ea 94084087a876 957fee30826e 96f7610fa666 982864068a59 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
Depended on by
232aabb5452d 284fe47d98b9 c397a8c36a9f 91334b7fa574 569802ebae0e 642da2636de2 64dc5197f9df 9bd98cfb4f7e 157d02e95586 b63fc7a77fc6 d36ba2f79f95 c2e1d09047db 0299076f09a2 4b9d06778581 3a6effc74835 74b4a3c62ea4 a39754d02c58 b689779e9e74 19303b4a06fb 170bb436e1d5 4afab467e35f bf3a67819b59 b0a0c9afae2d 7993d465675b 5cde7882d767 2c9ddac3c387 9aa399044d9d 8d80356371c5 e226a6cc2f71 77445673174b 98a1620a3840 e8c8b34d8348 2bf22305bc48 14edbaaf5291 d4dbab97e948 4cce912a0953 06d16dc2bc13 da868d5d2646 7d9dab131937 4fe7d6045860 6caacfff1d4f fc1a466a1e66 83d61e803ccd 8ab265dc4a51 e7ac6c2e84e4 6a81dbab471d 1c2bd9abdd4f 61e3bce7ea5b cc7a00565161 60a1223c894f bc489b6c7f69 d42369abf180 3e5772ccf9da 387335ff7f4d d82e44097323 05e71ed6cdf1 75167672ebcd e02bf0cd0533 1196b29b5fa9
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
85 atoms, 4 tree operations, 16 files

Browse the tree at this change

added src/cli/json.rs +24 −0
1 +
//! Tiny JSON building for `--json` outputs: one document on stdout,
2 +
//! nothing else, with string escaping shared with the hooks payloads.
3 +
4 +
pub use oxy_core::repo::{json_array as arr, json_str as s};
5 +
6 +
pub fn obj<I: IntoIterator<Item = (&'static str, String)>>(pairs: I) -> String {
7 +
    let inner: Vec<String> = pairs.into_iter().map(|(k, v)| format!("{}:{v}", s(k))).collect();
8 +
    format!("{{{}}}", inner.join(","))
9 +
}
10 +
pub fn b(v: bool) -> String {
11 +
    v.to_string()
12 +
}
13 +
pub fn n<T: std::fmt::Display>(v: T) -> String {
14 +
    v.to_string()
15 +
}
16 +
pub fn opt<T>(v: Option<T>, f: impl Fn(T) -> String) -> String {
17 +
    match v {
18 +
        Some(x) => f(x),
19 +
        None => "null".into(),
20 +
    }
21 +
}
22 +
pub fn hashes(v: &[oxy_core::hash::ChangeHash]) -> String {
23 +
    arr(v.iter().map(|h| s(&h.to_hex())))
24 +
}
added tests/cli_json.rs +118 −0
1 +
//! Every `--json` output of the CLI parses as JSON and carries the fields
2 +
//! the VS Code extension relies on (oxygen-vscode/docs/spec.md section 4).
3 +
4 +
use serde_json::Value;
5 +
use std::path::Path;
6 +
use std::process::Command;
7 +
8 +
fn oxy(dir: &Path, home: &Path, args: &[&str]) -> (i32, Vec<u8>, String) {
9 +
    let out = Command::new(env!("CARGO_BIN_EXE_oxy")).arg("--plain").args(args).current_dir(dir).env("OXY_HOME", home).env("NO_COLOR", "1").output().unwrap();
10 +
    (out.status.code().unwrap_or(-1), out.stdout, String::from_utf8_lossy(&out.stderr).to_string())
11 +
}
12 +
13 +
fn json(dir: &Path, home: &Path, args: &[&str]) -> Value {
14 +
    let (code, out, err) = oxy(dir, home, args);
15 +
    assert!(code == 0 || code == 2 || code == 1, "oxy {args:?} exited {code}: {err}");
16 +
    serde_json::from_slice(&out).unwrap_or_else(|e| panic!("oxy {args:?} is not JSON: {e}\n{}", String::from_utf8_lossy(&out)))
17 +
}
18 +
19 +
fn write(dir: &Path, rel: &str, content: &str) {
20 +
    let p = dir.join(rel);
21 +
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
22 +
    std::fs::write(p, content).unwrap();
23 +
}
24 +
25 +
#[test]
26 +
fn machine_interface_round_trips() {
27 +
    let tmp = tempfile::tempdir().unwrap();
28 +
    let home = tmp.path().join("home");
29 +
    std::fs::create_dir_all(&home).unwrap();
30 +
    let origin = tmp.path().join("origin");
31 +
    assert_eq!(oxy(tmp.path(), &home, &["init", origin.to_str().unwrap()]).0, 0);
32 +
    let repo = tmp.path().join("repo");
33 +
    assert_eq!(oxy(tmp.path(), &home, &["clone", origin.to_str().unwrap(), repo.to_str().unwrap()]).0, 0);
34 +
    let r = repo.as_path();
35 +
    assert_eq!(oxy(r, &home, &["identity", "create", "--name", "Ada", "--email", "[email protected]"]).0, 0);
36 +
    assert_eq!(oxy(r, &home, &["config", "--user", "core.sign", "change"]).0, 0);
37 +
38 +
    let v = json(r, &home, &["version", "--json"]);
39 +
    assert_eq!(v["protocol"], 1);
40 +
    let root = json(r, &home, &["root", "--json"]);
41 +
    assert_eq!(root["bookmark"], "main");
42 +
    assert!(root["root"].as_str().unwrap().ends_with("repo"));
43 +
44 +
    write(r, "a.txt", "1\n2\n3\n");
45 +
    write(r, "b.txt", "b\n");
46 +
    let st = json(r, &home, &["status", "--json"]);
47 +
    assert_eq!(st["paths"].as_array().unwrap().len(), 2);
48 +
    assert_eq!(oxy(r, &home, &["commit", "-m", "base"]).0, 0);
49 +
    write(r, "a.txt", "0\n1\n2\n3\n");
50 +
    write(r, "b.txt", "b\nbb\n");
51 +
    write(r, "c.txt", "c\n");
52 +
53 +
    // diff and hunks agree on the working copy.
54 +
    let d = json(r, &home, &["diff", "--json"]);
55 +
    let files = d["files"].as_array().unwrap();
56 +
    assert_eq!(files.len(), 3, "{d}");
57 +
    let a = files.iter().find(|f| f["path"] == "a.txt").unwrap();
58 +
    assert_eq!(a["kind"], "modified");
59 +
    assert_eq!(a["hunks"][0]["base_line"], 0);
60 +
    assert_eq!(a["hunks"][0]["added"][0], "0\n");
61 +
    let c = files.iter().find(|f| f["path"] == "c.txt").unwrap();
62 +
    assert_eq!(c["kind"], "added");
63 +
    let hunks = json(r, &home, &["hunks", "--json"]);
64 +
    let hunks = hunks.as_array().unwrap();
65 +
    assert!(hunks.len() >= 3, "{hunks:?}");
66 +
    let b_hunk = hunks.iter().find(|h| h["path"] == "b.txt").unwrap()["index"].as_u64().unwrap();
67 +
    // Commit only b's hunk; discard c by path; then discard a's remaining hunk.
68 +
    assert_eq!(oxy(r, &home, &["commit", "-m", "b only", "--hunks", &b_hunk.to_string()]).0, 0);
69 +
    assert_eq!(oxy(r, &home, &["discard", "c.txt"]).0, 0);
70 +
    assert!(!r.join("c.txt").exists());
71 +
    let hunks = json(r, &home, &["hunks", "--json"]);
72 +
    let a_hunk = hunks.as_array().unwrap().iter().find(|h| h["path"] == "a.txt").unwrap()["index"].as_u64().unwrap();
73 +
    assert_eq!(oxy(r, &home, &["discard", "--hunks", &a_hunk.to_string()]).0, 0);
74 +
    assert_eq!(std::fs::read_to_string(r.join("a.txt")).unwrap(), "1\n2\n3\n");
75 +
    let st = json(r, &home, &["status", "--json"]);
76 +
    assert_eq!(st["clean"], true, "{st}");
77 +
78 +
    // cat serves file content at states.
79 +
    let (code, out, _) = oxy(r, &home, &["cat", "b.txt"]);
80 +
    assert_eq!((code, out.as_slice()), (0, b"b\nbb\n".as_slice()));
81 +
    let (code, out, _) = oxy(r, &home, &["cat", "b.txt", "-r", "main"]);
82 +
    assert_eq!((code, out.as_slice()), (0, b"b\nbb\n".as_slice()));
83 +
84 +
    // log, show, verify, op log, bookmarks, remotes, sync.
85 +
    let log = json(r, &home, &["log", "--json"]);
86 +
    let changes = log["changes"].as_array().unwrap();
87 +
    assert_eq!(changes.len(), 3, "working copy plus two changes: {log}");
88 +
    let head = changes.iter().find(|c| c["message"] == "b only").unwrap();
89 +
    assert_eq!(head["signature"]["status"], "signed");
90 +
    assert_eq!(head["author"], "Ada");
91 +
    let hash = head["hash"].as_str().unwrap().to_string();
92 +
    let by_path = json(r, &home, &["log", "--json", "--path", "b.txt"]);
93 +
    assert_eq!(by_path["changes"].as_array().unwrap().len(), 2, "{by_path}");
94 +
    let show = json(r, &home, &["show", "--json", &format!("#{}", &hash[..12])]);
95 +
    assert_eq!(show["files"][0]["path"], "b.txt");
96 +
    let verify = json(r, &home, &["verify", "--json"]);
97 +
    assert!(verify.as_array().unwrap().iter().all(|v| v["status"] == "signed"));
98 +
    let ops = json(r, &home, &["op", "log", "--json"]);
99 +
    assert!(ops[0]["current"] == true && ops.as_array().unwrap().len() > 3);
100 +
    assert_eq!(oxy(r, &home, &["bookmark", "create", "feature"]).0, 0);
101 +
    let bms = json(r, &home, &["bookmark", "list", "--json"]);
102 +
    assert_eq!(bms.as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(), vec!["feature", "main"]);
103 +
    let remotes = json(r, &home, &["remote", "list", "--json"]);
104 +
    assert_eq!(remotes[0]["name"], "origin");
105 +
    let push = json(r, &home, &["push", "--json"]);
106 +
    assert_eq!(push[0]["ok"], true, "{push}");
107 +
    let fetch = json(r, &home, &["fetch", "--json"]);
108 +
    assert_eq!(fetch["remote"], "origin");
109 +
    let pull = json(r, &home, &["pull", "--json"]);
110 +
    assert_eq!(pull["changed"], false);
111 +
    assert_eq!(json(r, &home, &["resolve", "--json"])["conflicted"], false);
112 +
    assert!(json(r, &home, &["submodule", "status", "--json"]).as_array().unwrap().is_empty());
113 +
    assert!(json(r, &home, &["bisect", "status", "--json"]).is_null());
114 +
    assert!(json(r, &home, &["policy", "show", "--json"]).is_null());
115 +
    assert_eq!(oxy(r, &home, &["policy", "init"]).0, 0);
116 +
    let policy = json(r, &home, &["policy", "show", "--json"]);
117 +
    assert_eq!(policy["roots"][0]["name"], "Ada");
118 +
}
removed .gitignore +0 −0
…
1 unchanged lines
modified Cargo.lock +1 −0
…
1208 unchanged lines
1209 1209
 "libc",
1210 1210
 "oxy-core",
1211 1211
 "oxy-server",
1212 +
 "serde_json",
1212 1213
 "tempfile",
1213 1214
]
1214 1215
…
1157 unchanged lines
modified Cargo.toml +1 −0
…
29 unchanged lines
30 30
31 31
[dev-dependencies]
32 32
tempfile = "3"
33 +
serde_json = "1"
modified crates/oxy-core/src/repo/checkout.rs +2 −2
…
160 unchanged lines
161 161
        Ok(full)
162 162
    }
163 163
164 -
    fn write_file(&self, rel: &str, bytes: &[u8], exec: bool) -> Result<()> {
164 +
    pub(crate) fn write_file(&self, rel: &str, bytes: &[u8], exec: bool) -> Result<()> {
165 165
        let full = self.safe_path(rel)?;
166 166
        if let Some(parent) = full.parent() {
167 167
            fs::create_dir_all(parent)?;
…
9 unchanged lines
177 177
        Ok(())
178 178
    }
179 179
180 -
    fn write_symlink(&self, rel: &str, target: &[u8]) -> Result<()> {
180 +
    pub(crate) fn write_symlink(&self, rel: &str, target: &[u8]) -> Result<()> {
181 181
        let full = self.safe_path(rel)?;
182 182
        if let Some(parent) = full.parent() {
183 183
            fs::create_dir_all(parent)?;
…
17 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +1 −1
…
83 unchanged lines
84 84
pub use git::{GitExportReport, GitImportReport};
85 85
pub use identity::{Rotation, SignatureStatus};
86 86
pub use policy::PolicyUpdate;
87 -
pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, render_hunks, similarity, ChangeStatus, Hunk, PathStatus, Selection, SnapshotResult};
87 +
pub use snapshot::{added_node_paths, diff_files_of, has_conflict_markers, is_binary, referenced_changes, render_change, render_hunks, similarity, ChangeStatus, DiffHunk, FileDiff, Hunk, PathStatus, Selection, SnapshotResult};
88 88
pub use sync::{FetchReport, PullReport};
89 89
pub use tags::TagInfo;
90 90
pub use workspace::{SnapshotLock, WorkspaceInfo};
…
789 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +285 −0
…
582 unchanged lines
583 583
    out
584 584
}
585 585
586 +
/// One file's part of a diff, for `oxy diff --json`.
587 +
#[derive(Clone, Debug)]
588 +
pub struct FileDiff {
589 +
    pub path: String,
590 +
    /// `added`, `removed`, `modified`, `renamed`, `mode`, `replaced`.
591 +
    pub kind: String,
592 +
    pub hunks: Vec<DiffHunk>,
593 +
    /// For renames, the previous path.
594 +
    pub from: Option<String>,
595 +
}
596 +
597 +
/// Lines inserted after `base_line` (0 = before the first line) and lines
598 +
/// removed, with their base line numbers.
599 +
#[derive(Clone, Debug, Default)]
600 +
pub struct DiffHunk {
601 +
    pub base_line: u32,
602 +
    pub added: Vec<Vec<u8>>,
603 +
    pub removed: Vec<(u32, Vec<u8>)>,
604 +
}
605 +
606 +
impl Repo {
607 +
    /// The content of `path` in a state: `heads`, or the working copy's base.
608 +
    pub fn file_at(&self, path: &str, heads: Option<&[ChangeHash]>) -> Result<Vec<u8>> {
609 +
        let snapshot = match heads {
610 +
            Some(h) => self.load_state(h)?.materialize(),
611 +
            None => {
612 +
                let (_, wc) = self.working_copy()?;
613 +
                self.load_state(&wc.deps)?.materialize()
614 +
            }
615 +
        };
616 +
        let Some(f) = snapshot.files.get(path) else { return fail(format!("{path} is not in that state")) };
617 +
        match &f.content {
618 +
            FileContent::Dir => fail(format!("{path} is a directory")),
619 +
            FileContent::Text { bytes, .. } => Ok(bytes.clone()),
620 +
            FileContent::Opaque { blobs } => match blobs.first() {
621 +
                Some(b) => self.read_blob(b),
622 +
                None => Ok(vec![]),
623 +
            },
624 +
        }
625 +
    }
626 +
627 +
    /// Restore `paths` (files or directories) to the working copy's base:
628 +
    /// edits are undone, additions removed, removals put back. Returns the
629 +
    /// number of paths touched.
630 +
    pub fn discard(&self, paths: &[String]) -> Result<usize> {
631 +
        let snap = self.snapshot("oxy discard")?;
632 +
        let under = |p: &str| paths.iter().any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))));
633 +
        let attrs = Repo::state_attributes(&snap.base_snapshot);
634 +
        let mut n = 0;
635 +
        for s in &snap.statuses {
636 +
            if !under(&s.path) {
637 +
                continue;
638 +
            }
639 +
            match snap.base_snapshot.files.get(&s.path) {
640 +
                None => {
641 +
                    // Added (or the new name of a rename): remove it.
642 +
                    let full = self.root.join(&s.path);
643 +
                    if full.is_dir() {
644 +
                        let _ = fs::remove_dir_all(&full);
645 +
                    } else {
646 +
                        let _ = fs::remove_file(&full);
647 +
                    }
648 +
                    n += 1;
649 +
                }
650 +
                Some(f) => {
651 +
                    match &f.content {
652 +
                        FileContent::Dir => fs::create_dir_all(self.root.join(&s.path))?,
653 +
                        FileContent::Text { bytes, .. } => {
654 +
                            let on_disk: std::borrow::Cow<[u8]> = if attrs.lookup(&s.path).eol == Some(crate::attributes::Eol::Crlf) { crate::attributes::to_crlf(bytes).into() } else { bytes.as_slice().into() };
655 +
                            self.write_file(&s.path, &on_disk, f.mode.exec)?;
656 +
                        }
657 +
                        FileContent::Opaque { blobs } => {
658 +
                            let bytes = match blobs.first() {
659 +
                                Some(b) => self.read_blob(b)?,
660 +
                                None => vec![],
661 +
                            };
662 +
                            if f.kind == NodeKind::Symlink {
663 +
                                self.write_symlink(&s.path, &bytes)?;
664 +
                            } else if f.kind == NodeKind::File {
665 +
                                self.write_file(&s.path, &bytes, f.mode.exec)?;
666 +
                            }
667 +
                        }
668 +
                    }
669 +
                    n += 1;
670 +
                }
671 +
            }
672 +
        }
673 +
        // A rename's old name is not in the statuses; put it back too.
674 +
        for (p, f) in &snap.base_snapshot.files {
675 +
            if under(p) && !self.root.join(p).exists() && f.kind != NodeKind::Dir && f.kind != NodeKind::Subrepo && !snap.statuses.iter().any(|s| s.path == *p) {
676 +
                let bytes = match &f.content {
677 +
                    FileContent::Text { bytes, .. } => bytes.clone(),
678 +
                    FileContent::Opaque { blobs } => blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default(),
679 +
                    FileContent::Dir => continue,
680 +
                };
681 +
                if f.kind == NodeKind::Symlink { self.write_symlink(p, &bytes)? } else { self.write_file(p, &bytes, f.mode.exec)? }
682 +
                n += 1;
683 +
            }
684 +
        }
685 +
        self.drop_manifest();
686 +
        Ok(n)
687 +
    }
688 +
689 +
    /// Undo the working-copy hunks `indices` (as `hunks` numbers them) on
690 +
    /// disk, keeping the others: the files are rewritten from the base plus
691 +
    /// the remaining hunks.
692 +
    pub fn discard_hunks(&self, indices: &[usize]) -> Result<usize> {
693 +
        let (snap, hunks) = self.hunks()?;
694 +
        let keep: Vec<usize> = hunks.iter().map(|h| h.index).filter(|i| !indices.contains(i)).collect();
695 +
        let (ops, edits) = select_hunks(&snap.wc, &keep);
696 +
        let dropped_nodes: BTreeSet<NodeId> = hunks
697 +
            .iter()
698 +
            .filter(|h| indices.contains(&h.index))
699 +
            .map(|h| if h.index < snap.wc.tree_ops.len() { snap.wc.tree_ops[h.index].node() } else { snap.wc.edits[h.index - snap.wc.tree_ops.len()].node() })
700 +
            .collect();
701 +
        let mut partial = snap.wc.clone();
702 +
        partial.tree_ops = ops;
703 +
        partial.edits = edits;
704 +
        partial.normalize();
705 +
        let mut st = snap.base.clone();
706 +
        st.add_unchecked(partial);
707 +
        let target = st.materialize();
708 +
        let by_node_target = target.tree.by_node();
709 +
        let by_node_base = snap.base_snapshot.tree.by_node();
710 +
        let attrs = Repo::state_attributes(&target);
711 +
        let mut n = 0;
712 +
        for node in dropped_nodes {
713 +
            match by_node_target.get(&node) {
714 +
                Some(e) => {
715 +
                    let f = &target.files[&e.path];
716 +
                    match &f.content {
717 +
                        FileContent::Dir => {}
718 +
                        FileContent::Text { bytes, .. } => {
719 +
                            let on_disk: std::borrow::Cow<[u8]> = if attrs.lookup(&e.path).eol == Some(crate::attributes::Eol::Crlf) { crate::attributes::to_crlf(bytes).into() } else { bytes.as_slice().into() };
720 +
                            self.write_file(&e.path, &on_disk, f.mode.exec)?;
721 +
                        }
722 +
                        FileContent::Opaque { blobs } => {
723 +
                            let bytes = blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default();
724 +
                            if f.kind == NodeKind::Symlink { self.write_symlink(&e.path, &bytes)? } else { self.write_file(&e.path, &bytes, f.mode.exec)? }
725 +
                        }
726 +
                    }
727 +
                    // A discarded rename: the new name goes.
728 +
                    if let Some(old_path) = snap.statuses.iter().find(|s| s.status == ChangeStatus::Renamed && !target.files.contains_key(&s.path)).map(|s| s.path.clone()) {
729 +
                        let _ = fs::remove_file(self.root.join(old_path));
730 +
                    }
731 +
                    n += 1;
732 +
                }
733 +
                None => {
734 +
                    // The node exists in neither target nor base with a live path: an addition to remove.
735 +
                    if let Some(added) = snap.statuses.iter().find(|s| s.status == ChangeStatus::Added && !target.files.contains_key(&s.path) && !by_node_base.contains_key(&node)) {
736 +
                        let full = self.root.join(&added.path);
737 +
                        if full.is_dir() { let _ = fs::remove_dir_all(&full); } else { let _ = fs::remove_file(&full); }
738 +
                        n += 1;
739 +
                    }
740 +
                }
741 +
            }
742 +
        }
743 +
        self.drop_manifest();
744 +
        Ok(n)
745 +
    }
746 +
747 +
    /// The diff of the working copy (`None`) or of a change, per file with
748 +
    /// line-numbered hunks (spec: `oxy diff --json`).
749 +
    pub fn diff_files(&self, change: Option<&ChangeHash>) -> Result<Vec<FileDiff>> {
750 +
        let (c, base) = match change {
751 +
            Some(h) => {
752 +
                let c = self.store.get_change(h)?;
753 +
                let base = self.load_state(&c.deps)?;
754 +
                (c, base)
755 +
            }
756 +
            None => {
757 +
                let snap = self.snapshot("oxy diff")?;
758 +
                (snap.wc, snap.base)
759 +
            }
760 +
        };
761 +
        let base_snapshot = base.materialize();
762 +
        Ok(diff_files_of(&c, &base, &base_snapshot))
763 +
    }
764 +
765 +
    /// The log restricted to changes touching `path` (its node, so renames
766 +
    /// are followed), on the current bookmark.
767 +
    pub fn log_for_path(&self, path: &str) -> Result<Vec<LogEntry>> {
768 +
        let entries = self.log()?;
769 +
        let (_, wc) = self.working_copy()?;
770 +
        let base_snapshot = self.load_state(&wc.deps)?.materialize();
771 +
        let heads = self.bookmark_heads()?;
772 +
        let state_snapshot = self.load_state(&heads)?.materialize();
773 +
        let node = base_snapshot.files.get(path).or_else(|| state_snapshot.files.get(path)).map(|f| f.node);
774 +
        let Some(node) = node else { return fail(format!("{path} is not tracked")) };
775 +
        let mut out = Vec::new();
776 +
        for e in entries {
777 +
            let Ok(c) = self.store.get_change(&e.hash) else { continue };
778 +
            if c.tree_ops.iter().any(|o| o.node() == node) || c.edits.iter().any(|x| x.node() == node) {
779 +
                out.push(e);
780 +
            }
781 +
        }
782 +
        Ok(out)
783 +
    }
784 +
}
785 +
786 +
/// Per-file hunks of `change` against `base`.
787 +
pub fn diff_files_of(change: &Change, base: &State, base_snapshot: &Snapshot) -> Vec<FileDiff> {
788 +
    let by_node = base_snapshot.tree.by_node();
789 +
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
790 +
    for op in &change.tree_ops {
791 +
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
792 +
            new_names.insert(*node, (*parent, name.clone(), *kind));
793 +
        }
794 +
    }
795 +
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
796 +
    let mut files: BTreeMap<NodeId, FileDiff> = BTreeMap::new();
797 +
    let mut order: Vec<NodeId> = Vec::new();
798 +
    let mut entry = |files: &mut BTreeMap<NodeId, FileDiff>, n: NodeId, kind: &str| {
799 +
        if let std::collections::btree_map::Entry::Vacant(v) = files.entry(n) {
800 +
            order.push(n);
801 +
            v.insert(FileDiff { path: path_of(&n), kind: kind.to_string(), hunks: vec![], from: None });
802 +
        }
803 +
    };
804 +
    for op in &change.tree_ops {
805 +
        match op {
806 +
            TreeOp::AddNode { node, kind, .. } => {
807 +
                if *kind != NodeKind::Dir {
808 +
                    entry(&mut files, *node, "added");
809 +
                }
810 +
            }
811 +
            TreeOp::RemoveNode { node, .. } => {
812 +
                entry(&mut files, *node, "removed");
813 +
                if let Some(e) = by_node.get(node) {
814 +
                    files.get_mut(node).unwrap().path = e.path.clone();
815 +
                }
816 +
            }
817 +
            TreeOp::Move { node, parent, name, .. } => {
818 +
                entry(&mut files, *node, "renamed");
819 +
                let f = files.get_mut(node).unwrap();
820 +
                f.from = by_node.get(node).map(|e| e.path.clone());
821 +
                let dir = if parent.is_root() { String::new() } else { path_of_node(parent, &by_node, &new_names).unwrap_or_default() };
822 +
                f.path = if dir.is_empty() { name.clone() } else { format!("{dir}/{name}") };
823 +
            }
824 +
            TreeOp::SetMode { node, .. } => entry(&mut files, *node, "mode"),
825 +
        }
826 +
    }
827 +
    // Base line numbers per node, 1-based.
828 +
    let mut line_of: HashMap<NodeId, HashMap<(ChangeHash, u32), u32>> = HashMap::new();
829 +
    let mut lines_for = |n: &NodeId| -> HashMap<(ChangeHash, u32), u32> {
830 +
        line_of.entry(*n).or_insert_with(|| base.live_lines(n).into_iter().enumerate().map(|(i, (k, _))| (k, i as u32 + 1)).collect()).clone()
831 +
    };
832 +
    for e in &change.edits {
833 +
        let n = e.node();
834 +
        let is_new = new_names.contains_key(&n);
835 +
        entry(&mut files, n, if is_new { "added" } else { "modified" });
836 +
        let f = files.get_mut(&n).unwrap();
837 +
        match e {
838 +
            Edit::Insert { after, lines, .. } => {
839 +
                let map = lines_for(&n);
840 +
                let base_line = match after {
841 +
                    AtomRef::Start => 0,
842 +
                    AtomRef::End => map.len() as u32,
843 +
                    AtomRef::Atom { change, index } if change.is_zero() => f.hunks.last().map(|h| h.base_line).unwrap_or(0),
844 +
                    AtomRef::Atom { change, index } => map.get(&(*change, *index)).copied().unwrap_or(0),
845 +
                };
846 +
                f.hunks.push(DiffHunk { base_line, added: lines.clone(), removed: vec![] });
847 +
            }
848 +
            Edit::Delete { atoms, .. } => {
849 +
                let map = lines_for(&n);
850 +
                let mut removed = Vec::new();
851 +
                for r in atoms {
852 +
                    for i in r.from..r.to {
853 +
                        let line = map.get(&(r.change, i)).copied().unwrap_or(0);
854 +
                        let text = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"").to_vec();
855 +
                        removed.push((line, text));
856 +
                    }
857 +
                }
858 +
                let base_line = removed.first().map(|(l, _)| l.saturating_sub(1)).unwrap_or(0);
859 +
                f.hunks.push(DiffHunk { base_line, added: vec![], removed });
860 +
            }
861 +
            Edit::Replace { .. } => {
862 +
                if !is_new {
863 +
                    f.kind = "replaced".into();
864 +
                }
865 +
            }
866 +
        }
867 +
    }
868 +
    order.into_iter().filter_map(|n| files.remove(&n)).collect()
869 +
}
870 +
586 871
/// Builds the tree ops and edits of a working-copy change against a base.
587 872
struct ChangeBuilder<'a> {
588 873
    base: &'a State,
…
520 unchanged lines
modified docs/guide.md +9 −0
…
405 unchanged lines
406 406
Both run automatically: packing when loose objects pile up, checkpoints
407 407
every `core.checkpoint-interval` changes.
408 408
409 +
## 17a. Scripting and editors
410 +
411 +
Every reporting command takes `--json` (`status`, `log --path`, `show`,
412 +
`diff`, `hunks`, `blame`, `resolve`, `op log`, `bookmark list`, `remote list`,
413 +
`fetch`, `pull`, `push`, `submodule status`, `bisect status`, `verify`,
414 +
`policy show`, `root`, `version`). `oxy cat PATH -r CHANGE` prints a file as
415 +
it was, `oxy discard PATH..` restores files to the base, and `oxy hunks`
416 +
numbers the working copy's hunks for `commit --hunks` and `discard --hunks`.
417 +
409 418
## 18. If something looks wrong
410 419
411 420
- **"the remote has change … which you do not; pull first"**: someone
…
9 unchanged lines
modified docs/plan.md +7 −0
…
48 unchanged lines
49 49
- [x] Rebase (spec §8.5): `oxy rebase CHANGE --onto HEADS..` re-deriving edits against a new base; refused on conflicted files.
50 50
- [x] Guide, README and §26.1 updated for each; spec changelog per item.
51 51
52 +
## Phase 3 (2026-09-27): machine interface for the VS Code extension (oxygen-vscode/docs/spec.md section 4)
53 +
54 +
- [x] `oxy root`, `oxy cat PATH [-r CHANGE]`, `oxy version --json`, `oxy discard PATH.. | --hunks N,M`.
55 +
- [x] JSON outputs: `log --json [--path P] [--bookmark B]`, `show --json`, `diff --json [CHANGE]`, `hunks --json`, `commit --hunks`, `bookmark list --json`, `op log --json`, `resolve --json`, `submodule status --json`, `bisect status --json`, `verify --json`, `policy show --json`, `remote list --json`, `fetch|pull|push --json`.
56 +
- [x] An integration test that runs every JSON command against a scratch repository and parses each output.
57 +
- [x] Spec §24 and the guide mention the machine interface; `docs/protocol.md` gets a pointer.
58 +
52 59
## Log
53 60
54 61
- 2026-09-26 23:01 plan created.
…
19 unchanged lines
modified docs/protocol.md +7 −0
…
161 unchanged lines
162 162
atomically and record the attestation. Any failure rejects the whole push
163 163
with `ok = false` and a reason.
164 164
165 +
## 4.4 Editors and tools on the same machine
166 +
167 +
A tool that drives a local repository does not speak this protocol; it runs
168 +
`oxy` with `--json` (spec §24) and reads one JSON document per command. The
169 +
VS Code extension in `oxygen-vscode/docs/spec.md` §4 lists the commands it
170 +
relies on.
171 +
165 172
## 5. Minimal implementations
166 173
167 174
A **read-only client** needs: CBOR decode, BLAKE3, the change and state
…
14 unchanged lines
modified docs/spec.md +8 −2
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.8a |
5 +
| **Status** | Draft 0.8b |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
824 unchanged lines
833 833
oxy policy init|show|grant|revoke|root|sign                      oxy verify [CHANGE]
834 834
oxy git import|export|fetch|push   (fetch and push go through a bare mirror under .oxy/git/)
835 835
oxy checkpoint                      oxy gc [--now]               oxy config [--user] KEY VALUE
836 +
oxy root                            oxy cat PATH [-r CHANGE]     oxy version
837 +
oxy hunks                           oxy discard PATH.. | --hunks N,M
836 838
```
837 839
840 +
**Machine interface.** `status`, `log` (with `--path`), `show`, `diff`, `hunks`, `blame`, `resolve`, `op log`, `bookmark list`, `remote list`, `fetch`, `pull`, `push`, `submodule status`, `bisect status`, `verify`, `policy show`, `root` and `version` take `--json` and then print exactly one JSON document on stdout, with ids as hex strings and times in milliseconds since the epoch; errors go to stderr with a non-zero status. `commit --hunks N,M` and `discard --hunks N,M` take hunk numbers as `hunks` prints them. Editors and other tools MUST use these rather than reading `.oxy/`.
841 +
838 842
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.
839 843
840 844
Reserved for a later version, with these names: `oxy new`, `oxy edit`, `oxy redo`, revsets (`log -r`), and `oxy remote trust` (with the Noise transport, §15).
…
70 unchanged lines
911 915
912 916
---
913 917
914 -
*Changelog.* Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23).
918 +
*Changelog.* Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
919 +
920 +
Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23).
915 921
916 922
Draft 0.8 (2026-09-27): subrepositories with Oxygen and Git pins (§7.8, §23); squash and rebase defined as history edits (§8.5); multiple bookmarks per repository with a per-workspace current bookmark and `oxy merge` (§10.1); partial and interactive commits (§12.3); blame and bisect (§27); open questions moved to §28.
917 923
…
57 unchanged lines
modified src/cli/identity.rs +65 −4
…
1 unchanged lines
2 2
3 3
use super::format::{short_id, signature_line, when};
4 4
use super::open;
5 +
use super::json;
5 6
use super::ui;
6 7
use anyhow::Result;
7 8
use oxy_core::hash::ChangeHash;
…
1 unchanged lines
9 10
use oxy_core::hash::KeyId;
10 11
use oxy_core::objects::Hlc;
11 12
use oxy_core::protocol::SignedPolicy;
12 -
use oxy_core::repo::{self, IdentityScope, PolicyUpdate, Repo, Rotation};
13 +
use oxy_core::repo::{self, IdentityScope, PolicyUpdate, Repo, Rotation, SignatureStatus};
13 14
use std::path::{Path, PathBuf};
14 15
15 16
const DAY_MS: u64 = 24 * 60 * 60 * 1000;
…
114 unchanged lines
130 131
    Ok(0)
131 132
}
132 133
133 -
pub fn verify(change: Option<&str>) -> Result<i32> {
134 +
pub fn verify(change: Option<&str>, as_json: bool) -> Result<i32> {
134 135
    let repo = open()?;
135 136
    let hashes: Vec<ChangeHash> = match change {
136 137
        Some(c) => vec![repo.resolve_change(c)?],
137 138
        None => repo.log()?.into_iter().filter(|e| !e.is_working_copy).map(|e| e.hash).collect(),
138 139
    };
139 140
    let mut bad = 0;
141 +
    if as_json {
142 +
        let mut items = Vec::new();
143 +
        for h in &hashes {
144 +
            let Ok(c) = repo.store().get_change(h) else { continue };
145 +
            let (status, key, name, revoked, reason) = match repo.authenticate_change(h)? {
146 +
                SignatureStatus::Unsigned => ("unsigned", None, None, false, None),
147 +
                SignatureStatus::Valid { key, name, sealed, revoked, .. } => (if sealed { "sealed" } else { "signed" }, Some(key.to_hex()), Some(name), revoked, None),
148 +
                SignatureStatus::Invalid(why) => {
149 +
                    bad += 1;
150 +
                    ("invalid", None, None, false, Some(why))
151 +
                }
152 +
            };
153 +
            items.push(json::obj([
154 +
                ("hash", json::s(&h.to_hex())),
155 +
                ("change_id", json::s(&c.change_id.to_hex())),
156 +
                ("status", json::s(status)),
157 +
                ("key", json::opt(key.as_deref(), json::s)),
158 +
                ("name", json::opt(name.as_deref(), json::s)),
159 +
                ("revoked", json::b(revoked)),
160 +
                ("reason", json::opt(reason.as_deref(), json::s)),
161 +
            ]));
162 +
        }
163 +
        println!("{}", json::arr(items));
164 +
        return Ok(if bad > 0 { 1 } else { 0 });
165 +
    }
140 166
    for h in hashes {
141 167
        let Ok(c) = repo.store().get_change(&h) else {
142 168
            println!("{}  {}", ui::hash(format!("#{}", h.short(12))), ui::dim("not present (shallow)"));
…
148 unchanged lines
291 317
    Ok(0)
292 318
}
293 319
294 -
pub fn policy_show() -> Result<i32> {
320 +
pub fn policy_show(as_json: bool) -> Result<i32> {
295 321
    let repo = open()?;
296 322
    let Some((r, sp)) = repo.current_policy()? else {
297 -
        println!("No policy: this repository is unverified. Run `oxy policy init` to create one.");
323 +
        if as_json {
324 +
            println!("null");
325 +
        } else {
326 +
            println!("No policy: this repository is unverified. Run `oxy policy init` to create one.");
327 +
        }
298 328
        return Ok(0);
299 329
    };
300 330
    let p = &sp.policy;
331 +
    if as_json {
332 +
        let ident = |k: &oxy_core::hash::KeyId| {
333 +
            let (name, email) = p.identity(k).map(|i| (i.name.clone(), i.email.clone())).unwrap_or_default();
334 +
            json::obj([("key", json::s(&k.to_hex())), ("name", json::s(&name)), ("email", json::s(&email))])
335 +
        };
336 +
        println!(
337 +
            "{}",
338 +
            json::obj([
339 +
                ("hash", json::s(&r.to_hex())),
340 +
                ("serial", json::n(p.serial)),
341 +
                ("expires_ms", json::n(p.expires)),
342 +
                ("expired", json::b(p.is_expired(oxy_core::objects::Hlc::now().wall_ms))),
343 +
                ("root_threshold", json::n(p.root_threshold)),
344 +
                ("roots", json::arr(p.roots.iter().map(ident))),
345 +
                (
346 +
                    "rules",
347 +
                    json::arr(p.rules.iter().map(|rule| {
348 +
                        json::obj([
349 +
                            ("pattern", json::s(&rule.pattern)),
350 +
                            ("keys", json::arr(rule.keys.iter().map(ident))),
351 +
                            ("threshold", json::n(rule.threshold)),
352 +
                            ("require", json::s(rule.require.name())),
353 +
                            ("allow_conflicts", json::b(rule.allow_conflicts)),
354 +
                        ])
355 +
                    })),
356 +
                ),
357 +
                ("revoked", json::arr(p.revoked.iter().map(ident))),
358 +
            ])
359 +
        );
360 +
        return Ok(0);
361 +
    }
301 362
    let name = |k: &oxy_core::hash::KeyId| p.identity(k).map(|i| format!("{} <{}>", i.name, i.email)).unwrap_or_else(|| "(unknown identity)".into());
302 363
    let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
303 364
    println!("{} {}{}serial {}{}expires {}{}", ui::section("policy"), ui::hash(format!("#{}", r.short(12))), ui::sep(), p.serial, ui::sep(), when(p.expires), if expired { ui::err(format!("  {} EXPIRED", ui::glyph("✖", "x"))) } else { String::new() });
…
41 unchanged lines
modified src/cli/mod.rs +114 −27
1 1
//! Command-line definitions and dispatch.
2 2
3 3
mod format;
4 +
mod json;
4 5
mod identity;
5 6
mod sync;
6 7
pub mod ui;
…
71 unchanged lines
78 79
        /// Choose hunks one by one
79 80
        #[arg(short, long)]
80 81
        interactive: bool,
82 +
        /// Commit these hunks (as `oxy hunks` numbers them), comma separated
83 +
        #[arg(long, value_delimiter = ',')]
84 +
        hunks: Vec<usize>,
81 85
    },
82 86
    /// Fold CHANGE into one of its dependencies (keeps that change's id)
83 87
    Squash {
…
27 unchanged lines
111 115
        /// Show full messages, dependencies, and signatures
112 116
        #[arg(short, long)]
113 117
        verbose: bool,
118 +
        /// Only changes touching this path (renames followed)
119 +
        #[arg(long)]
120 +
        path: Option<String>,
121 +
        #[arg(long)]
122 +
        json: bool,
114 123
    },
115 124
    /// Show what the working copy changes against its base
116 -
    Diff,
125 +
    Diff {
126 +
        /// A change instead of the working copy
127 +
        change: Option<String>,
128 +
        #[arg(long)]
129 +
        json: bool,
130 +
    },
131 +
    /// Print a file as it is in a state (default: the working copy's base)
132 +
    Cat {
133 +
        path: String,
134 +
        /// A change, bookmark, or tag
135 +
        #[arg(short = 'r', long)]
136 +
        change: Option<String>,
137 +
    },
138 +
    /// Print the repository root, workspace, and bookmark
139 +
    Root {
140 +
        #[arg(long)]
141 +
        json: bool,
142 +
    },
143 +
    /// Print the version of oxy, its repository format, and protocol
144 +
    Version {
145 +
        #[arg(long)]
146 +
        json: bool,
147 +
    },
148 +
    /// Restore paths to the working copy's base, or undo chosen hunks
149 +
    Discard {
150 +
        paths: Vec<String>,
151 +
        /// Hunk indices as `oxy hunks` numbers them, comma separated
152 +
        #[arg(long, value_delimiter = ',')]
153 +
        hunks: Vec<usize>,
154 +
    },
155 +
    /// List the working copy's hunks, numbered as commit --hunks expects
156 +
    Hunks {
157 +
        #[arg(long)]
158 +
        json: bool,
159 +
    },
117 160
    /// Change the message of a head (default: the head the working copy sits on)
118 161
    Describe {
119 162
        #[arg(short, long)]
…
16 unchanged lines
136 179
    /// Drop a head from the bookmark; its content stays on disk as uncommitted work
137 180
    Abandon { change: String },
138 181
    /// Show one change (ChangeId prefix, #hash prefix, or @)
139 -
    Show { change: String },
182 +
    Show {
183 +
        change: String,
184 +
        #[arg(long)]
185 +
        json: bool,
186 +
    },
140 187
    /// Move the working copy to the state formed by CHANGE... (default: the bookmark heads)
141 188
    Checkout {
142 189
        changes: Vec<String>,
…
2 unchanged lines
145 192
        discard: bool,
146 193
    },
147 194
    /// Report which conflicts of the working copy's base are resolved by the current files
148 -
    Resolve,
195 +
    Resolve {
196 +
        #[arg(long)]
197 +
        json: bool,
198 +
    },
149 199
    /// Undo the last operation
150 200
    Undo,
151 201
    /// Write a checkpoint of the current bookmark's state now
…
42 unchanged lines
194 244
        cmd: RemoteCmd,
195 245
    },
196 246
    /// Fetch every bookmark of a remote into its tracking references
197 -
    Fetch { remote: Option<String> },
247 +
    Fetch {
248 +
        remote: Option<String>,
249 +
        #[arg(long)]
250 +
        json: bool,
251 +
    },
198 252
    /// Fetch, then union the remote bookmark into the local one (no merge change)
199 -
    Pull { remote: Option<String> },
253 +
    Pull {
254 +
        remote: Option<String>,
255 +
        #[arg(long)]
256 +
        json: bool,
257 +
    },
200 258
    /// Push the current bookmark to a remote
201 259
    Push {
202 260
        remote: Option<String>,
…
3 unchanged lines
206 264
        /// Push this bookmark instead of the workspace's current one
207 265
        #[arg(short = 'b', long)]
208 266
        bookmark: Option<String>,
267 +
        #[arg(long)]
268 +
        json: bool,
209 269
    },
210 270
    /// Serve repositories under ROOT over HTTPS (with --tls-cert/--tls-key) or plain HTTP
211 271
    Serve {
…
18 unchanged lines
230 290
        cmd: IdentityCmd,
231 291
    },
232 292
    /// Verify the signatures on a change (default: every change in the log)
233 -
    Verify { change: Option<String> },
293 +
    Verify {
294 +
        change: Option<String>,
295 +
        #[arg(long)]
296 +
        json: bool,
297 +
    },
234 298
    /// Git interoperability
235 299
    Git {
236 300
        #[command(subcommand)]
…
16 unchanged lines
253 317
254 318
#[derive(Subcommand)]
255 319
enum OpCmd {
256 -
    Log,
320 +
    Log {
321 +
        #[arg(long)]
322 +
        json: bool,
323 +
    },
257 324
}
258 325
259 326
#[derive(Subcommand)]
…
21 unchanged lines
281 348
        command: Vec<String>,
282 349
    },
283 350
    /// Where the search stands
284 -
    Status,
351 +
    Status {
352 +
        #[arg(long)]
353 +
        json: bool,
354 +
    },
285 355
    /// Restore the working copy and forget the search
286 356
    Reset,
287 357
}
…
7 unchanged lines
295 365
    /// Remove a bookmark; its changes stay
296 366
    Delete { name: String },
297 367
    /// List bookmarks with their heads and difference from origin
298 -
    List,
368 +
    List {
369 +
        #[arg(long)]
370 +
        json: bool,
371 +
    },
299 372
    /// Make NAME this workspace's bookmark and check it out
300 373
    Switch {
301 374
        name: String,
…
21 unchanged lines
323 396
        paths: Vec<String>,
324 397
    },
325 398
    /// List subrepos with their pins and on-disk state
326 -
    Status,
399 +
    Status {
400 +
        #[arg(long)]
401 +
        json: bool,
402 +
    },
327 403
    /// Unregister PATH; the directory stays, the node goes with the next commit
328 404
    Remove { path: String },
329 405
}
…
14 unchanged lines
344 420
enum RemoteCmd {
345 421
    Add { name: String, url: String },
346 422
    Remove { name: String },
347 -
    List,
423 +
    List {
424 +
        #[arg(long)]
425 +
        json: bool,
426 +
    },
348 427
}
349 428
350 429
#[derive(Subcommand)]
…
81 unchanged lines
432 511
    /// Create the initial policy with your identity as the only root
433 512
    Init,
434 513
    /// Show the current policy
435 -
    Show,
514 +
    Show {
515 +
        #[arg(long)]
516 +
        json: bool,
517 +
    },
436 518
    /// Allow an identity (from `oxy identity export`) to update bookmarks
437 519
    Grant {
438 520
        identity_file: PathBuf,
…
60 unchanged lines
499 581
        Cmd::Init { path } => workspace::init(path),
500 582
        Cmd::Status { json } => workspace::status(json),
501 583
        Cmd::Prompt { format, glyphs, color } => workspace::prompt(&format, glyphs, color.as_deref()),
502 -
        Cmd::Commit { message, depends_on, paths, interactive } => workspace::commit(&message, &depends_on, &paths, interactive),
584 +
        Cmd::Commit { message, depends_on, paths, interactive, hunks } => workspace::commit(&message, &depends_on, &paths, interactive, &hunks),
585 +
        Cmd::Cat { path, change } => workspace::cat(&path, change.as_deref()),
586 +
        Cmd::Root { json } => workspace::root(json),
587 +
        Cmd::Version { json } => workspace::version(json),
588 +
        Cmd::Discard { paths, hunks } => workspace::discard(&paths, &hunks),
589 +
        Cmd::Hunks { json } => workspace::hunks(json),
503 590
        Cmd::Squash { change, into } => workspace::squash(&change, &into),
504 591
        Cmd::Rebase { change, onto } => workspace::rebase(&change, &onto),
505 592
        Cmd::Blame { path, json } => workspace::blame(&path, json),
…
3 unchanged lines
509 596
            BisectCmd::Bad { changes } => workspace::bisect_mark(Some(false), &changes),
510 597
            BisectCmd::Skip => workspace::bisect_mark(None, &[]),
511 598
            BisectCmd::Run { command } => workspace::bisect_run(&command),
512 -
            BisectCmd::Status => workspace::bisect_status(),
599 +
            BisectCmd::Status { json } => workspace::bisect_status(json),
513 600
            BisectCmd::Reset => workspace::bisect_reset(),
514 601
        },
515 -
        Cmd::Log { limit, verbose } => workspace::log(limit, verbose),
516 -
        Cmd::Diff => workspace::diff(),
602 +
        Cmd::Log { limit, verbose, path, json } => workspace::log(limit, verbose, path.as_deref(), json),
603 +
        Cmd::Diff { change, json } => workspace::diff(change.as_deref(), json),
517 604
        Cmd::Describe { message, change } => workspace::describe(&message, change.as_deref()),
518 605
        Cmd::Amend { message, into } => workspace::amend(message.as_deref(), into.as_deref()),
519 606
        Cmd::Split { change, paths } => workspace::split(&change, &paths),
520 607
        Cmd::Abandon { change } => workspace::abandon(&change),
521 -
        Cmd::Show { change } => workspace::show(&change),
608 +
        Cmd::Show { change, json } => workspace::show(&change, json),
522 609
        Cmd::Checkout { changes, discard } => workspace::checkout(&changes, discard),
523 -
        Cmd::Resolve => workspace::resolve(),
610 +
        Cmd::Resolve { json } => workspace::resolve(json),
524 611
        Cmd::Undo => workspace::undo(),
525 612
        Cmd::Gc { now } => workspace::gc(now),
526 613
        Cmd::Checkpoint => workspace::checkpoint(),
527 -
        Cmd::Op { cmd: OpCmd::Log } => workspace::op_log(),
614 +
        Cmd::Op { cmd: OpCmd::Log { json } } => workspace::op_log(json),
528 615
        Cmd::Clone { url, dest, max_blob, sparse, depth } => sync::clone(&url, dest, max_blob.as_deref(), &sparse, depth),
529 616
        Cmd::Deepen { depth, remote } => sync::deepen(&remote, depth),
530 617
        Cmd::Sparse { paths } => workspace::sparse(&paths),
…
8 unchanged lines
539 626
            BookmarkCmd::Create { name, changes } => workspace::bookmark_create(&name, &changes),
540 627
            BookmarkCmd::Set { name, changes } => workspace::bookmark_set(&name, &changes),
541 628
            BookmarkCmd::Delete { name } => workspace::bookmark_delete(&name),
542 -
            BookmarkCmd::List => workspace::bookmark_list(),
629 +
            BookmarkCmd::List { json } => workspace::bookmark_list(json),
543 630
            BookmarkCmd::Switch { name, discard } => workspace::bookmark_switch(&name, discard),
544 631
        },
545 632
        Cmd::Merge { bookmark } => workspace::merge(&bookmark),
546 633
        Cmd::Submodule { cmd } => match cmd {
547 634
            SubmoduleCmd::Add { url, path, bookmark } => sync::submodule_add(&url, &path, bookmark.as_deref()),
548 635
            SubmoduleCmd::Update { init, paths } => sync::submodule_update(init, &paths),
549 -
            SubmoduleCmd::Status => sync::submodule_status(),
636 +
            SubmoduleCmd::Status { json } => sync::submodule_status(json),
550 637
            SubmoduleCmd::Remove { path } => sync::submodule_remove(&path),
551 638
        },
552 639
        Cmd::Tag { cmd } => match cmd {
…
3 unchanged lines
556 643
        Cmd::Remote { cmd } => match cmd {
557 644
            RemoteCmd::Add { name, url } => sync::remote_add(&name, &url),
558 645
            RemoteCmd::Remove { name } => sync::remote_remove(&name),
559 -
            RemoteCmd::List => sync::remote_list(),
646 +
            RemoteCmd::List { json } => sync::remote_list(json),
560 647
        },
561 -
        Cmd::Fetch { remote } => sync::fetch(remote.as_deref()),
562 -
        Cmd::Pull { remote } => sync::pull(remote.as_deref()),
563 -
        Cmd::Push { remote, force, bookmark } => sync::push(remote.as_deref(), force, bookmark.as_deref()),
648 +
        Cmd::Fetch { remote, json } => sync::fetch(remote.as_deref(), json),
649 +
        Cmd::Pull { remote, json } => sync::pull(remote.as_deref(), json),
650 +
        Cmd::Push { remote, force, bookmark, json } => sync::push(remote.as_deref(), force, bookmark.as_deref(), json),
564 651
        Cmd::Serve { root, bind, tokens, tls_cert, tls_key } => sync::serve(root, &bind, tokens, tls_cert.zip(tls_key)),
565 652
        Cmd::Identity { cmd } => match cmd {
566 653
            IdentityCmd::Create { name, email, local, expires } => identity::create(name, email, local, expires),
…
14 unchanged lines
581 668
        Cmd::Git { cmd: GitCmd::Import { git_dir, dest, branch } } => sync::git_import(&git_dir, &dest, &branch),
582 669
        Cmd::Policy { cmd } => match cmd {
583 670
            PolicyCmd::Init => identity::policy_init(),
584 -
            PolicyCmd::Show => identity::policy_show(),
671 +
            PolicyCmd::Show { json } => identity::policy_show(json),
585 672
            PolicyCmd::Grant { identity_file, pattern, out } => identity::policy_grant(&identity_file, pattern.as_deref(), out),
586 673
            PolicyCmd::Revoke { key, out } => identity::policy_revoke(&key, out),
587 674
            PolicyCmd::Root { cmd } => match cmd {
…
3 unchanged lines
591 678
            },
592 679
            PolicyCmd::Sign { proposal, out } => identity::policy_sign(&proposal, out),
593 680
        },
594 -
        Cmd::Verify { change } => identity::verify(change.as_deref()),
681 +
        Cmd::Verify { change, json } => identity::verify(change.as_deref(), json),
595 682
        Cmd::Config { key, value, user } => identity::config(&key, &value, user),
596 683
    }
597 684
}
modified src/cli/sync.rs +64 −5
…
1 unchanged lines
2 2
3 3
use super::format::plural;
4 4
use super::open;
5 +
use super::json;
5 6
use super::ui;
6 7
use anyhow::{Context, Result};
7 8
use oxy_core::repo::Repo;
…
31 unchanged lines
39 40
    Ok(0)
40 41
}
41 42
42 -
pub fn remote_list() -> Result<i32> {
43 +
pub fn remote_list(as_json: bool) -> Result<i32> {
43 44
    let repo = open()?;
44 45
    let view = repo.view()?;
46 +
    if as_json {
47 +
        let items = repo.config.remotes.iter().map(|(name, url)| {
48 +
            let bms = view.remotes.get(name).map(|b| {
49 +
                json::arr(b.iter().map(|(bm, st)| {
50 +
                    let heads = repo.store().get_state(st).map(|s| s.heads).unwrap_or_default();
51 +
                    json::obj([("name", json::s(bm)), ("heads", json::hashes(&heads))])
52 +
                }))
53 +
            });
54 +
            json::obj([("name", json::s(name)), ("url", json::s(url)), ("bookmarks", bms.unwrap_or_else(|| "[]".into()))])
55 +
        });
56 +
        println!("{}", json::arr(items));
57 +
        return Ok(0);
58 +
    }
45 59
    if repo.config.remotes.is_empty() {
46 60
        println!("{}", ui::dim("No remotes; add one with oxy remote add <name> <url>."));
47 61
    }
…
13 unchanged lines
61 75
    remote.unwrap_or("origin").to_string()
62 76
}
63 77
64 -
pub fn fetch(remote: Option<&str>) -> Result<i32> {
78 +
pub fn fetch(remote: Option<&str>, as_json: bool) -> Result<i32> {
65 79
    let repo = open()?;
66 80
    let remote = remote_name(remote);
67 81
    let sp = ui::spinner(&format!("Fetching from {remote}…"));
68 82
    let report = repo.fetch(&remote)?;
69 83
    sp.clear();
84 +
    if as_json {
85 +
        println!(
86 +
            "{}",
87 +
            json::obj([
88 +
                ("remote", json::s(&remote)),
89 +
                ("refs", json::arr(report.refs.iter().map(|(name, heads, n)| json::obj([("name", json::s(name)), ("heads", json::hashes(heads)), ("new", json::n(*n))])))),
90 +
                ("new_tags", json::n(report.new_tags)),
91 +
                ("unverified", json::b(report.unverified)),
92 +
            ])
93 +
        );
94 +
        return Ok(0);
95 +
    }
70 96
    if report.refs.is_empty() {
71 97
        println!("{} {}", ui::done("Fetched"), ui::dim(format!("nothing new from {remote}")));
72 98
    }
…
10 unchanged lines
83 109
    Ok(0)
84 110
}
85 111
86 -
pub fn pull(remote: Option<&str>) -> Result<i32> {
112 +
pub fn pull(remote: Option<&str>, as_json: bool) -> Result<i32> {
87 113
    let repo = open()?;
88 114
    let remote = remote_name(remote);
89 115
    let sp = ui::spinner(&format!("Pulling from {remote}…"));
90 116
    let r = repo.pull(&remote)?;
117 +
    if as_json {
118 +
        sp.clear();
119 +
        let conflicted = repo.snapshot("oxy pull")?.base_snapshot.is_conflicted();
120 +
        println!("{}", json::obj([("remote", json::s(&remote)), ("heads", json::hashes(&r.heads)), ("changed", json::b(r.changed)), ("checked_out", json::b(r.checked_out)), ("conflicted", json::b(conflicted))]));
121 +
        return Ok(0);
122 +
    }
91 123
    if !r.changed {
92 124
        sp.done(&format!("{} {}", ui::ok(ui::glyph("✔", "*")), ui::dim("Already up to date.")));
93 125
        return Ok(0);
…
12 unchanged lines
106 138
    Ok(0)
107 139
}
108 140
109 -
pub fn push(remote: Option<&str>, force: bool, bookmark: Option<&str>) -> Result<i32> {
141 +
pub fn push(remote: Option<&str>, force: bool, bookmark: Option<&str>, as_json: bool) -> Result<i32> {
110 142
    let repo = open()?;
111 143
    let remote = remote_name(remote);
112 144
    let sp = ui::spinner(&format!("Pushing to {remote}…"));
…
2 unchanged lines
115 147
        None => repo.push(&remote, force)?,
116 148
    };
117 149
    sp.clear();
150 +
    if as_json {
151 +
        println!("{}", json::arr(results.iter().map(|r| json::obj([("name", json::s(&r.name)), ("ok", json::b(r.ok)), ("reason", json::s(&r.reason))]))));
152 +
        return Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 });
153 +
    }
118 154
    let mut code = 0;
119 155
    for r in results {
120 156
        if r.ok {
…
152 unchanged lines
273 309
    Ok(code)
274 310
}
275 311
276 -
pub fn submodule_status() -> Result<i32> {
312 +
fn pin_json(p: &oxy_core::subrepo::Pin) -> String {
313 +
    match p {
314 +
        oxy_core::subrepo::Pin::Oxy { repo, heads, bookmark } => json::obj([("kind", json::s("oxy")), ("repo", json::s(&repo.iter().map(|b| format!("{b:02x}")).collect::<String>())), ("heads", json::hashes(heads)), ("bookmark", json::s(bookmark))]),
315 +
        oxy_core::subrepo::Pin::Git { sha, branch } => json::obj([("kind", json::s("git")), ("sha", json::s(sha)), ("branch", json::s(branch))]),
316 +
    }
317 +
}
318 +
319 +
pub fn submodule_status(as_json: bool) -> Result<i32> {
277 320
    let repo = open()?;
278 321
    let subs = repo.subrepos()?;
322 +
    if as_json {
323 +
        println!(
324 +
            "{}",
325 +
            json::arr(subs.iter().map(|s| {
326 +
                json::obj([
327 +
                    ("path", json::s(&s.path)),
328 +
                    ("pin", pin_json(&s.pin)),
329 +
                    ("actual", json::opt(s.actual.as_ref(), pin_json)),
330 +
                    ("at_pin", json::b(s.actual.as_ref() == Some(&s.pin))),
331 +
                    ("dirty", json::b(s.dirty)),
332 +
                    ("url", json::opt(s.url.as_deref(), json::s)),
333 +
                ])
334 +
            }))
335 +
        );
336 +
        return Ok(0);
337 +
    }
279 338
    if subs.is_empty() {
280 339
        println!("{}", ui::dim("No subrepos."));
281 340
    }
…
34 unchanged lines
modified src/cli/workspace.rs +216 −12
1 1
//! Commands that act on the local repository and working copy.
2 2
3 3
use super::format::{author_name, deps_line, deps_of, plural, short_id, short_meta_id, signature_line, signature_mark, subject_of, when};
4 +
use super::json;
5 +
use oxy_core::repo::SignatureStatus;
4 6
use super::open;
5 7
use super::ui;
6 8
use anyhow::Result;
…
70 unchanged lines
77 79
    Ok(2)
78 80
}
79 81
80 -
pub fn commit(message: &str, depends_on: &[String], paths: &[String], interactive: bool) -> Result<i32> {
82 +
pub fn commit(message: &str, depends_on: &[String], paths: &[String], interactive: bool, hunks: &[usize]) -> Result<i32> {
81 83
    let repo = open()?;
82 84
    let mut deps = Vec::new();
83 85
    for d in depends_on {
84 86
        deps.push(repo.resolve_change(d)?);
85 87
    }
86 -
    let selection = if interactive {
88 +
    let selection = if !hunks.is_empty() {
89 +
        oxy_core::repo::Selection::Hunks(hunks.to_vec())
90 +
    } else if interactive {
87 91
        let (_, hunks) = repo.hunks()?;
88 92
        if hunks.is_empty() {
89 93
            anyhow::bail!("nothing to commit: the working copy matches its base");
…
142 unchanged lines
232 236
    bisect_report(&repo, &step)
233 237
}
234 238
235 -
pub fn bisect_status() -> Result<i32> {
239 +
pub fn bisect_status(as_json: bool) -> Result<i32> {
236 240
    let repo = open()?;
241 +
    if as_json {
242 +
        use oxy_core::repo::BisectStep::*;
243 +
        let v = match repo.bisect_status()? {
244 +
            None => "null".to_string(),
245 +
            Some(NeedMarks { good, bad }) => json::obj([("state", json::s("need-marks")), ("good", json::n(good)), ("bad", json::n(bad))]),
246 +
            Some(Testing { heads, remaining, steps_left }) => json::obj([("state", json::s("testing")), ("heads", json::hashes(&heads)), ("remaining", json::n(remaining)), ("steps_left", json::n(steps_left))]),
247 +
            Some(Found { culprit }) => json::obj([("state", json::s("found")), ("culprit", json::s(&culprit.to_hex()))]),
248 +
            Some(Inconclusive { candidates }) => json::obj([("state", json::s("inconclusive")), ("candidates", json::hashes(&candidates))]),
249 +
        };
250 +
        println!("{v}");
251 +
        return Ok(0);
252 +
    }
237 253
    match repo.bisect_status()? {
238 254
        None => {
239 255
            println!("{}", ui::dim("No bisect in progress."));
…
13 unchanged lines
253 269
/// The log: one entry per change, newest first, joined by a rail. The
254 270
/// default view is the subject and a metadata line; `verbose` adds the full
255 271
/// message, dependencies, and the authenticating key.
256 -
pub fn log(limit: Option<usize>, verbose: bool) -> Result<i32> {
272 +
fn signature_json(repo: &Repo, h: &oxy_core::hash::ChangeHash) -> Result<String> {
273 +
    Ok(match repo.authenticate_change(h)? {
274 +
        SignatureStatus::Unsigned => json::obj([("status", json::s("unsigned"))]),
275 +
        SignatureStatus::Valid { key, name, email, sealed, revoked } => json::obj([
276 +
            ("status", json::s(if sealed { "sealed" } else { "signed" })),
277 +
            ("key", json::s(&key.to_hex())),
278 +
            ("name", json::s(&name)),
279 +
            ("email", json::s(&email)),
280 +
            ("revoked", json::b(revoked)),
281 +
        ]),
282 +
        SignatureStatus::Invalid(why) => json::obj([("status", json::s("invalid")), ("reason", json::s(&why))]),
283 +
    })
284 +
}
285 +
286 +
fn log_entry_json(repo: &Repo, e: &oxy_core::repo::LogEntry) -> Result<String> {
287 +
    let c = &e.change;
288 +
    Ok(json::obj([
289 +
        ("hash", json::s(&e.hash.to_hex())),
290 +
        ("change_id", json::s(&c.change_id.to_hex())),
291 +
        ("author", json::s(&author_name(repo, c))),
292 +
        ("author_key", json::s(&c.author.to_hex())),
293 +
        ("when_ms", json::n(c.hlc.wall_ms)),
294 +
        ("message", json::s(&c.message)),
295 +
        ("kind", json::s(&format!("{:?}", c.kind).to_lowercase())),
296 +
        ("deps", json::hashes(&c.deps)),
297 +
        ("head", json::b(e.is_head)),
298 +
        ("working_copy", json::b(e.is_working_copy)),
299 +
        ("signature", if e.is_working_copy { "null".into() } else { signature_json(repo, &e.hash)? }),
300 +
    ]))
301 +
}
302 +
303 +
pub fn log(limit: Option<usize>, verbose: bool, path: Option<&str>, as_json: bool) -> Result<i32> {
257 304
    let repo = open()?;
258 305
    let snap = repo.snapshot("oxy log")?;
259 306
    let conflicted = snap.base_snapshot.is_conflicted();
260 -
    let entries = repo.log()?;
307 +
    let entries = match path {
308 +
        Some(p) => repo.log_for_path(p)?,
309 +
        None => repo.log()?,
310 +
    };
311 +
    if as_json {
312 +
        let shown = limit.unwrap_or(entries.len()).min(entries.len());
313 +
        let mut items = Vec::new();
314 +
        for e in entries.iter().take(shown) {
315 +
            items.push(log_entry_json(&repo, e)?);
316 +
        }
317 +
        println!("{}", json::obj([("bookmark", json::s(&repo.config.bookmark)), ("total", json::n(entries.len())), ("changes", json::arr(items))]));
318 +
        return Ok(0);
319 +
    }
261 320
    let total = entries.len();
262 321
    let shown = limit.unwrap_or(total).min(total);
263 322
    let rail = ui::rail();
…
50 unchanged lines
314 373
    Ok(0)
315 374
}
316 375
317 -
pub fn diff() -> Result<i32> {
376 +
pub fn diff(change: Option<&str>, as_json: bool) -> Result<i32> {
377 +
    let repo = open()?;
378 +
    let hash = change.map(|c| repo.resolve_change(c)).transpose()?;
379 +
    if as_json {
380 +
        let files = repo.diff_files(hash.as_ref())?;
381 +
        let items = files.iter().map(|f| {
382 +
            json::obj([
383 +
                ("path", json::s(&f.path)),
384 +
                ("kind", json::s(&f.kind)),
385 +
                ("from", json::opt(f.from.as_deref(), json::s)),
386 +
                (
387 +
                    "hunks",
388 +
                    json::arr(f.hunks.iter().map(|h| {
389 +
                        json::obj([
390 +
                            ("base_line", json::n(h.base_line)),
391 +
                            ("added", json::arr(h.added.iter().map(|l| json::s(&String::from_utf8_lossy(l))))),
392 +
                            ("removed", json::arr(h.removed.iter().map(|(n, l)| json::obj([("line", json::n(*n)), ("text", json::s(&String::from_utf8_lossy(l)))])))),
393 +
                        ])
394 +
                    })),
395 +
                ),
396 +
            ])
397 +
        });
398 +
        println!("{}", json::obj([("change", json::opt(hash, |h| json::s(&h.to_hex()))), ("files", json::arr(items))]));
399 +
        return Ok(0);
400 +
    }
401 +
    match hash {
402 +
        Some(h) => {
403 +
            let c = repo.store().get_change(&h)?;
404 +
            let base = repo.load_state(&c.deps)?;
405 +
            let snapshot = base.materialize();
406 +
            print!("{}", render_change(&c, &base, &snapshot));
407 +
        }
408 +
        None => {
409 +
            let snap = repo.snapshot("oxy diff")?;
410 +
            print!("{}", render_change(&snap.wc, &snap.base, &snap.base_snapshot));
411 +
        }
412 +
    }
413 +
    Ok(0)
414 +
}
415 +
416 +
pub fn cat(path: &str, change: Option<&str>) -> Result<i32> {
417 +
    let repo = open()?;
418 +
    let heads = change.map(|c| repo.resolve_heads(c)).transpose()?;
419 +
    let bytes = repo.file_at(path, heads.as_deref())?;
420 +
    use std::io::Write as _;
421 +
    std::io::stdout().write_all(&bytes)?;
422 +
    Ok(0)
423 +
}
424 +
425 +
pub fn root(as_json: bool) -> Result<i32> {
426 +
    let Ok(repo) = Repo::open(&std::env::current_dir()?) else {
427 +
        if as_json {
428 +
            println!("null");
429 +
        }
430 +
        return Ok(1);
431 +
    };
432 +
    if as_json {
433 +
        println!("{}", json::obj([("root", json::s(&repo.root().to_string_lossy())), ("workspace", json::s(&repo.workspace().to_hex())), ("bookmark", json::s(&repo.config.bookmark))]));
434 +
    } else {
435 +
        println!("{}", repo.root().display());
436 +
    }
437 +
    Ok(0)
438 +
}
439 +
440 +
pub fn version(as_json: bool) -> Result<i32> {
441 +
    if as_json {
442 +
        println!("{}", json::obj([("version", json::s(env!("CARGO_PKG_VERSION"))), ("protocol", json::n(oxy_core::protocol::PROTOCOL_VERSION)), ("format", json::n(2))]));
443 +
    } else {
444 +
        println!("oxy {} (repository format 2, protocol {})", env!("CARGO_PKG_VERSION"), oxy_core::protocol::PROTOCOL_VERSION);
445 +
    }
446 +
    Ok(0)
447 +
}
448 +
449 +
pub fn discard(paths: &[String], hunks: &[usize]) -> Result<i32> {
450 +
    let repo = open()?;
451 +
    if paths.is_empty() && hunks.is_empty() {
452 +
        anyhow::bail!("name paths to restore, or --hunks to undo chosen hunks");
453 +
    }
454 +
    let mut n = 0;
455 +
    if !hunks.is_empty() {
456 +
        n += repo.discard_hunks(hunks)?;
457 +
    }
458 +
    if !paths.is_empty() {
459 +
        n += repo.discard(paths)?;
460 +
    }
461 +
    println!("{} {} restored to the working copy's base", ui::done("Discarded"), plural(n, "path"));
462 +
    Ok(0)
463 +
}
464 +
465 +
pub fn hunks(as_json: bool) -> Result<i32> {
318 466
    let repo = open()?;
319 -
    let snap = repo.snapshot("oxy diff")?;
320 -
    print!("{}", render_change(&snap.wc, &snap.base, &snap.base_snapshot));
467 +
    let (_, hunks) = repo.hunks()?;
468 +
    if as_json {
469 +
        println!("{}", json::arr(hunks.iter().map(|h| json::obj([("index", json::n(h.index)), ("path", json::s(&h.path)), ("text", json::s(&h.text))]))));
470 +
        return Ok(0);
471 +
    }
472 +
    if hunks.is_empty() {
473 +
        println!("{}", ui::dim("No hunks: the working copy matches its base."));
474 +
    }
475 +
    for h in &hunks {
476 +
        println!("{} {}", ui::section(format!("hunk {}", h.index)), ui::path(&h.path));
477 +
        for line in h.text.lines() {
478 +
            let styled = if line.starts_with('+') { ui::ok(line) } else if line.starts_with('-') { ui::err(line) } else { ui::dim(line) };
479 +
            println!("  {styled}");
480 +
        }
481 +
    }
321 482
    Ok(0)
322 483
}
323 484
324 -
pub fn show(change: &str) -> Result<i32> {
485 +
pub fn show(change: &str, as_json: bool) -> Result<i32> {
325 486
    let repo = open()?;
326 487
    repo.snapshot("oxy show")?;
327 488
    let h = repo.resolve_change(change)?;
328 489
    let c = repo.store().get_change(&h)?;
490 +
    if as_json {
491 +
        let files = repo.diff_files(Some(&h))?;
492 +
        let heads = repo.bookmark_heads()?;
493 +
        let entry = oxy_core::repo::LogEntry { hash: h, change: oxy_core::state::ChangeMeta::of(&c), is_working_copy: c.kind == oxy_core::objects::ChangeKind::WorkingCopy, is_head: heads.contains(&h) };
494 +
        let mut body = log_entry_json(&repo, &entry)?;
495 +
        body.pop();
496 +
        body.push_str(&format!(",{}:{}}}", json::s("files"), json::arr(files.iter().map(|f| json::obj([("path", json::s(&f.path)), ("kind", json::s(&f.kind)), ("from", json::opt(f.from.as_deref(), json::s))])))));
497 +
        println!("{body}");
498 +
        return Ok(0);
499 +
    }
329 500
    println!("{} {} {}", ui::section("change"), ui::id(short_id(&c)), ui::hash(format!("#{h}")));
330 501
    println!("{}", ui::kv("kind", format!("{:?}", c.kind).to_lowercase()));
331 502
    println!("{}", ui::kv("author", ui::author(author_name(&repo, &oxy_core::state::ChangeMeta::of(&c)))));
…
33 unchanged lines
365 536
    Ok(0)
366 537
}
367 538
368 -
pub fn resolve() -> Result<i32> {
539 +
pub fn resolve(as_json: bool) -> Result<i32> {
369 540
    let repo = open()?;
370 541
    let snap = repo.snapshot("oxy resolve")?;
542 +
    if as_json {
543 +
        println!("{}", repo.status_json(&snap)?);
544 +
        return Ok(if snap.base_snapshot.is_conflicted() { 2 } else { 0 });
545 +
    }
371 546
    if !snap.base_snapshot.is_conflicted() {
372 547
        println!("The working copy's base has no conflicts.");
373 548
        return Ok(0);
…
34 unchanged lines
408 583
    Ok(0)
409 584
}
410 585
411 -
pub fn op_log() -> Result<i32> {
586 +
pub fn op_log(as_json: bool) -> Result<i32> {
412 587
    let repo = open()?;
588 +
    if as_json {
589 +
        let ops = repo.op_log()?;
590 +
        println!(
591 +
            "{}",
592 +
            json::arr(ops.iter().enumerate().map(|(i, (r, op))| {
593 +
                json::obj([
594 +
                    ("id", json::s(&r.to_hex())),
595 +
                    ("when_ms", json::n(op.hlc.wall_ms)),
596 +
                    ("command", json::s(&op.command)),
597 +
                    ("snapshot", json::b(op.command.ends_with("(snapshot)"))),
598 +
                    ("current", json::b(i == 0)),
599 +
                ])
600 +
            }))
601 +
        );
602 +
        return Ok(0);
603 +
    }
413 604
    for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() {
414 605
        let bullet = if i == 0 { ui::ok(ui::glyph("●", "@")) } else { ui::dim(ui::glyph("○", "o")) };
415 606
        let cmd = subject_of(&op.command).split("\\n").next().unwrap_or("");
…
273 unchanged lines
689 880
    Ok(0)
690 881
}
691 882
692 -
pub fn bookmark_list() -> Result<i32> {
883 +
pub fn bookmark_list(as_json: bool) -> Result<i32> {
693 884
    let repo = open()?;
885 +
    if as_json {
886 +
        let items = repo.bookmarks()?.into_iter().map(|b| {
887 +
            json::obj([
888 +
                ("name", json::s(&b.name)),
889 +
                ("heads", json::hashes(&b.heads)),
890 +
                ("current", json::b(b.current)),
891 +
                ("ahead", json::opt(b.ahead, json::n)),
892 +
                ("behind", json::opt(b.behind, json::n)),
893 +
            ])
894 +
        });
895 +
        println!("{}", json::arr(items));
896 +
        return Ok(0);
897 +
    }
694 898
    for b in repo.bookmarks()? {
695 899
        let mark = if b.current { ui::ok(ui::glyph("●", "*")) } else { ui::dim(ui::glyph("○", "o")) };
696 900
        let mut tail = String::new();
…
71 unchanged lines