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 |