OXYGENHUB

The operation log says what each operation did; op restore, op revert and --at-op; commands say where they left you (closes #51, closes #52, closes #53) 98a1620a3840

op log describes each operation from the difference between its view
and the one before (bookmarks created, moved or deleted, remote bookmarks,
tags, policy, the working copy), with the command dimmed beneath; runs of
snapshots fold into one row, and -n limits it.

op restore OP brings back the bookmarks and this working copy's base as
they were after OP, files included, as a new operation; op revert OP puts
back the bookmarks OP moved unless they have moved again, and the working
copy follows when it was clean on the bookmark's heads. --at-op OP reads
the repository at OP and refuses to record anything. Tags and the trust
policy never go backwards.

undo now takes back a command's own '(checkout)' operation with it, so
undoing a pull or a restore is whole, and checks the files out too when
the working copy is clean and was clean at the operation undone to.

Commit, checkout, switch, rebase, merge, amend, abandon, pull, undo,
restore and revert end by naming the working copy and the bookmark's heads.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-01 17:29 UTC
Signature
✔ signed by Andy McCall
Hash
98a1620a3840a7dc026e58ba7014a13ce09a7b370b85845b00d72f5f369ebef0
Depends on
135212de5370 14edbaaf5291 19555025a2ac 252e8cb23fbc 28fc23d7706a 2bf22305bc48 519c7c061bcb 614541c3cebd 6706bdcfb63e 69816d688300 6a81dbab471d 6caacfff1d4f 728da0f92161 75167672ebcd 7b9809e67c3d 83e0677b07fe 86e1824f2d0b 8fd78eb52ad2 94084087a876 957fee30826e 982864068a59 acadb5ed28a1 c6aa5bd9d1c8 d3cd7ef95da6 d42369abf180 e266b7d4065c ee06a6c557b7 fce44ae9ad29
Depended on by
767ac6cd0545 c895bc52e4e0 b689779e9e74 530a0fc650a2 dd21fd82687d 2c9ddac3c387 33472ccc08cc 1ec3f008e96a 3604179679eb
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.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
56 atoms, 1 tree operation, 9 files

Browse the tree at this change

added crates/oxy-core/src/repo/ops.rs +276 −0
1 +
//! Working with the operation log (spec §12.1): naming an operation,
2 +
//! reading the repository as it was after one (`--at-op`), going back to
3 +
//! one (`op restore`), undoing one alone (`op revert`), and saying what an
4 +
//! operation changed (`op log`).
5 +
6 +
use super::*;
7 +
8 +
/// One reference change between two views.
9 +
#[derive(Clone, Debug, PartialEq, Eq)]
10 +
pub enum ViewChange {
11 +
    BookmarkCreated {
12 +
        name: String,
13 +
        heads: usize,
14 +
    },
15 +
    BookmarkDeleted {
16 +
        name: String,
17 +
    },
18 +
    BookmarkMoved {
19 +
        name: String,
20 +
        from: usize,
21 +
        to: usize,
22 +
    },
23 +
    RemoteMoved {
24 +
        remote: String,
25 +
        name: String,
26 +
    },
27 +
    TagCreated {
28 +
        name: String,
29 +
    },
30 +
    TagDeleted {
31 +
        name: String,
32 +
    },
33 +
    Policy,
34 +
    /// This workspace's working copy is now `change`.
35 +
    WorkingCopy {
36 +
        change: ChangeHash,
37 +
    },
38 +
}
39 +
40 +
/// What `op restore` did.
41 +
#[derive(Debug)]
42 +
pub struct RestoreReport {
43 +
    /// The operation restored to.
44 +
    pub op: ObjectRef,
45 +
    pub command: String,
46 +
    pub checkout: CheckoutReport,
47 +
}
48 +
49 +
/// What `op revert` did.
50 +
#[derive(Debug)]
51 +
pub struct RevertReport {
52 +
    pub op: ObjectRef,
53 +
    pub command: String,
54 +
    /// Bookmarks put back as they were before the operation.
55 +
    pub reverted: Vec<String>,
56 +
    /// Bookmarks the operation moved that have moved again since: left as
57 +
    /// they are.
58 +
    pub kept: Vec<String>,
59 +
    /// Whether the working copy followed the current bookmark.
60 +
    pub checked_out: bool,
61 +
}
62 +
63 +
impl Repo {
64 +
    /// This repository read as it was after operation `spec` (`--at-op`).
65 +
    /// Commands that would record an operation are refused.
66 +
    pub fn at_op(mut self, spec: &str) -> Result<Repo> {
67 +
        let (r, _) = self.resolve_op(spec)?;
68 +
        self.at_op = Some(r);
69 +
        Ok(self)
70 +
    }
71 +
72 +
    /// Whether this repository is read at an earlier operation (`--at-op`).
73 +
    pub fn is_at_op(&self) -> bool {
74 +
        self.at_op.is_some()
75 +
    }
76 +
77 +
    /// The operation `spec` names: `@` for the current one, `@-` for the
78 +
    /// one before it, or a prefix of an operation ID from `oxy op log`.
79 +
    pub fn resolve_op(&self, spec: &str) -> Result<(ObjectRef, Op)> {
80 +
        let spec = spec.trim();
81 +
        let log = self.op_log()?;
82 +
        if let Some(rest) = spec.strip_prefix('@') {
83 +
            if rest.chars().all(|c| c == '-') {
84 +
                return log
85 +
                    .into_iter()
86 +
                    .nth(rest.len())
87 +
                    .ok_or_else(|| RepoError::Msg(format!("the operation log has no {spec}")));
88 +
            }
89 +
        }
90 +
        if spec.is_empty() || !spec.chars().all(|c| c.is_ascii_hexdigit()) {
91 +
            return fail(format!(
92 +
                "{spec:?} is not an operation: give an ID from oxy op log, @ or @-"
93 +
            ));
94 +
        }
95 +
        let spec = spec.to_ascii_lowercase();
96 +
        let mut found = log
97 +
            .into_iter()
98 +
            .filter(|(r, _)| r.to_hex().starts_with(&spec));
99 +
        match (found.next(), found.next()) {
100 +
            (Some(one), None) => Ok(one),
101 +
            (None, _) => fail(format!("no operation {spec} in oxy op log")),
102 +
            (Some(_), Some(_)) => fail(format!(
103 +
                "operation {spec} is ambiguous: give more of its ID"
104 +
            )),
105 +
        }
106 +
    }
107 +
108 +
    /// What changed from `before` to `after`, for this workspace's working
109 +
    /// copy and every reference.
110 +
    pub fn view_changes(&self, before: &View, after: &View) -> Result<Vec<ViewChange>> {
111 +
        let mut out = Vec::new();
112 +
        let heads = |st: &ObjectRef| -> Result<usize> { Ok(self.store.get_state(st)?.heads.len()) };
113 +
        let names: BTreeSet<&String> = before
114 +
            .bookmarks
115 +
            .keys()
116 +
            .chain(after.bookmarks.keys())
117 +
            .collect();
118 +
        for name in names {
119 +
            match (before.bookmarks.get(name), after.bookmarks.get(name)) {
120 +
                (None, Some(a)) => out.push(ViewChange::BookmarkCreated {
121 +
                    name: name.clone(),
122 +
                    heads: heads(a)?,
123 +
                }),
124 +
                (Some(_), None) => out.push(ViewChange::BookmarkDeleted { name: name.clone() }),
125 +
                (Some(b), Some(a)) if a != b => out.push(ViewChange::BookmarkMoved {
126 +
                    name: name.clone(),
127 +
                    from: heads(b)?,
128 +
                    to: heads(a)?,
129 +
                }),
130 +
                _ => {}
131 +
            }
132 +
        }
133 +
        for (remote, bookmarks) in &after.remotes {
134 +
            for (name, st) in bookmarks {
135 +
                if before.remotes.get(remote).and_then(|b| b.get(name)) != Some(st) {
136 +
                    out.push(ViewChange::RemoteMoved {
137 +
                        remote: remote.clone(),
138 +
                        name: name.clone(),
139 +
                    });
140 +
                }
141 +
            }
142 +
        }
143 +
        for name in after.tags.keys().filter(|t| !before.tags.contains_key(*t)) {
144 +
            out.push(ViewChange::TagCreated { name: name.clone() });
145 +
        }
146 +
        for name in before.tags.keys().filter(|t| !after.tags.contains_key(*t)) {
147 +
            out.push(ViewChange::TagDeleted { name: name.clone() });
148 +
        }
149 +
        if before.policy != after.policy {
150 +
            out.push(ViewChange::Policy);
151 +
        }
152 +
        if let Some(w) = after.workspaces.get(&self.workspace) {
153 +
            if before.workspaces.get(&self.workspace) != Some(w) {
154 +
                out.push(ViewChange::WorkingCopy { change: *w });
155 +
            }
156 +
        }
157 +
        Ok(out)
158 +
    }
159 +
160 +
    /// Go back to how the bookmarks and this working copy were after
161 +
    /// operation `spec`, recorded as a new operation, so it can be undone
162 +
    /// in turn. The working copy is checked out at the base it had then;
163 +
    /// uncommitted work stops it unless `discard`. Tags and the trust
164 +
    /// policy are left as they are: neither goes backwards.
165 +
    pub fn op_restore(&self, spec: &str, discard: bool) -> Result<RestoreReport> {
166 +
        let _wc = self.lock_working_copy()?;
167 +
        let (target_ref, target) = self.resolve_op(spec)?;
168 +
        let command = format!("oxy op restore {}", target_ref.short(12));
169 +
        // Refuse before anything moves, as the checkout below would.
170 +
        if !discard && !self.clean_by_manifest()? {
171 +
            let snap = self.snapshot_status(&command)?;
172 +
            if !snap.statuses.is_empty() {
173 +
                return fail(format!(
174 +
                    "the working copy has uncommitted changes ({}): commit them, or drop them with --discard",
175 +
                    plural_paths(snap.statuses.len())
176 +
                ));
177 +
            }
178 +
        }
179 +
        let (head_ref, head) = self.head_op()?;
180 +
        let mut view = head.view.clone();
181 +
        view.bookmarks = target.view.bookmarks.clone();
182 +
        self.record_op(&[head_ref], &command, view)?;
183 +
        let base = match target.view.workspaces.get(&self.workspace) {
184 +
            Some(w) => {
185 +
                let wc = self.store.get_change(w)?;
186 +
                self.load_state_history(&wc.deps)?.maximal(&wc.deps)
187 +
            }
188 +
            None => self.bookmark_heads()?,
189 +
        };
190 +
        let checkout = self.checkout(&base, discard, &format!("{command} (checkout)"))?;
191 +
        Ok(RestoreReport {
192 +
            op: target_ref,
193 +
            command: target.command,
194 +
            checkout,
195 +
        })
196 +
    }
197 +
198 +
    /// Undo operation `spec` alone, keeping everything after it: each
199 +
    /// bookmark it moved goes back to where it was before, unless it has
200 +
    /// moved again since. The working copy follows the current bookmark
201 +
    /// when it was clean and on its heads, as after a pull.
202 +
    pub fn op_revert(&self, spec: &str) -> Result<RevertReport> {
203 +
        let _wc = self.lock_working_copy()?;
204 +
        let (target_ref, target) = self.resolve_op(spec)?;
205 +
        if target.is_snapshot() {
206 +
            return fail(
207 +
                "that operation only recorded the working copy: it moved no bookmark to revert",
208 +
            );
209 +
        }
210 +
        let Some(parent) = target.parents.first() else {
211 +
            return fail("that is the first operation: there is nothing before it");
212 +
        };
213 +
        let before = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?.view;
214 +
        let (head_ref, head) = self.head_op()?;
215 +
        let mut view = head.view.clone();
216 +
        let (mut reverted, mut kept) = (Vec::new(), Vec::new());
217 +
        let names: BTreeSet<&String> = before
218 +
            .bookmarks
219 +
            .keys()
220 +
            .chain(target.view.bookmarks.keys())
221 +
            .collect();
222 +
        for name in names {
223 +
            let (was, became) = (before.bookmarks.get(name), target.view.bookmarks.get(name));
224 +
            if was == became {
225 +
                continue;
226 +
            }
227 +
            if head.view.bookmarks.get(name) != became {
228 +
                kept.push(name.clone());
229 +
                continue;
230 +
            }
231 +
            match was {
232 +
                Some(st) => view.bookmarks.insert(name.clone(), *st),
233 +
                None => view.bookmarks.remove(name),
234 +
            };
235 +
            reverted.push(name.clone());
236 +
        }
237 +
        if reverted.is_empty() {
238 +
            return fail(if kept.is_empty() {
239 +
                "that operation moved no bookmark: nothing to revert".to_string()
240 +
            } else {
241 +
                format!(
242 +
                    "every bookmark that operation moved has moved again since ({}); oxy op restore goes back to an operation instead",
243 +
                    kept.join(", ")
244 +
                )
245 +
            });
246 +
        }
247 +
        let command = format!("oxy op revert {}", target_ref.short(12));
248 +
        let current = self.config.bookmark.clone();
249 +
        let mut old = self.heads_of(&head.view, &current)?;
250 +
        old.sort();
251 +
        self.record_op(&[head_ref], &command, view.clone())?;
252 +
        let mut checked_out = false;
253 +
        if reverted.contains(&current) {
254 +
            let snap = self.snapshot(&command)?;
255 +
            let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty();
256 +
            let mut base = snap.base.maximal(&snap.wc.deps);
257 +
            base.sort();
258 +
            if clean && base == old {
259 +
                let new = self.heads_of(&view, &current)?;
260 +
                self.checkout(&new, false, &format!("{command} (checkout)"))?;
261 +
                checked_out = true;
262 +
            }
263 +
        }
264 +
        Ok(RevertReport {
265 +
            op: target_ref,
266 +
            command: target.command,
267 +
            reverted,
268 +
            kept,
269 +
            checked_out,
270 +
        })
271 +
    }
272 +
}
273 +
274 +
fn plural_paths(n: usize) -> String {
275 +
    format!("{n} path{}", if n == 1 { "" } else { "s" })
276 +
}
modified CHANGELOG.md +4 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **The operation log says what each operation did (#51).** `oxy op log` gives each operation a summary, such as "main: 1 → 2 heads; working copy now 91ab4d50", with its command dimmed beneath. Runs of working-copy snapshots fold into one row, and `-n` limits the listing. `--json` adds each operation's changes.
6 +
- **`oxy op restore`, `oxy op revert` and `--at-op` (#52).** `op restore OP` brings back the bookmarks and working copy as they were after OP. `op revert OP` undoes OP alone and keeps everything after it. `--at-op OP` runs any read-only command on the repository as it was then. Restore and revert are operations too, so `oxy undo` takes them back. `undo` now takes back a command's own checkout with it (pull and restore record one), and brings the files back when nothing uncommitted would be lost.
7 +
- **Commands say where they left you (#53).** Commit, checkout, switch, rebase, merge, amend, abandon, pull, undo, restore and revert end with the working copy and the bookmark's heads, newest first, as jj does.
8 +
5 9
- **`oxy log` is a graph (#50).** Each change takes two lines: its ID (the shortest unique prefix bright), author, time, bookmarks and tags, hash and signature, then its subject. Lanes follow the change each one most directly builds on, and join where two lines of work meet, so independent work shows side by side. A dependency further than twelve rows down is marked `~`, which keeps the graph to a few lanes even on a long history. `--json` is unchanged.
6 10
7 11
- **A remote's bookmarks work like local ones (#48).**
…
44 unchanged lines
modified crates/oxy-core/src/repo/checkout.rs +1 −1
…
346 unchanged lines
347 347
    }
348 348
349 349
    /// Refuse to leave uncommitted work behind unless `discard`.
350 -
    fn refuse_dirty(&self, snap: &SnapshotResult, discard: bool) -> Result<()> {
350 +
    pub(crate) fn refuse_dirty(&self, snap: &SnapshotResult, discard: bool) -> Result<()> {
351 351
        if discard || (snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty()) {
352 352
            return Ok(());
353 353
        }
…
105 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +65 −12
…
74 unchanged lines
75 75
mod hosted;
76 76
mod identity;
77 77
mod inspect;
78 +
mod ops;
78 79
mod policy;
79 80
mod prompt;
80 81
mod receipts;
…
17 unchanged lines
98 99
pub use hosted::FileEdit;
99 100
pub use identity::{Rotation, SignatureStatus};
100 101
pub use inspect::{BisectStep, BlameLine, Description};
102 +
pub use ops::{RestoreReport, RevertReport, ViewChange};
101 103
pub use policy::PolicyUpdate;
102 104
pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT, GLYPH_PROMPT_FORMAT};
103 105
pub use receipts::ReceiptInfo;
…
122 unchanged lines
226 228
    /// The encodings of the changes last read for their inline inserts,
227 229
    /// oldest first.
228 230
    last_change: std::cell::RefCell<Vec<(ChangeHash, std::sync::Arc<Vec<u8>>)>>,
231 +
    /// `--at-op`: read the repository as it was after this operation;
232 +
    /// recording an operation is refused.
233 +
    at_op: Option<ObjectRef>,
229 234
}
230 235
231 236
/// Marker file of a bare repository, inside `.oxy/`.
…
60 unchanged lines
292 297
        let store = Store::open(oxy.join("objects"))?;
293 298
        let repo = Repo {
294 299
            last_change: Default::default(),
300 +
            at_op: None,
295 301
            git_export_skipped: Default::default(),
296 302
            manifest_cache: Default::default(),
297 303
            listing_cache: Default::default(),
…
47 unchanged lines
345 351
        let store = Store::open(oxy.join("objects"))?;
346 352
        let repo = Repo {
347 353
            last_change: Default::default(),
354 +
            at_op: None,
348 355
            git_export_skipped: Default::default(),
349 356
            manifest_cache: Default::default(),
350 357
            listing_cache: Default::default(),
…
69 unchanged lines
420 427
                let store = Store::open(shared.join("objects"))?;
421 428
                let mut repo = Repo {
422 429
                    last_change: Default::default(),
430 +
                    at_op: None,
423 431
                    git_export_skipped: Default::default(),
424 432
                    manifest_cache: Default::default(),
425 433
                    listing_cache: Default::default(),
…
24 unchanged lines
450 458
                let store = Store::open(oxy.join("objects"))?;
451 459
                let mut repo = Repo {
452 460
                    last_change: Default::default(),
461 +
                    at_op: None,
453 462
                    git_export_skipped: Default::default(),
454 463
                    manifest_cache: Default::default(),
455 464
                    listing_cache: Default::default(),
…
187 unchanged lines
643 652
    // ------------------------------------------------------------ operation log
644 653
645 654
    pub fn head_op(&self) -> Result<(ObjectRef, Op)> {
655 +
        if let Some(r) = self.at_op {
656 +
            return Ok((r, Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?));
657 +
        }
646 658
        let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?;
647 659
        let r = ObjectRef::from_hex(hex.trim())
648 660
            .ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?;
…
6 unchanged lines
655 667
    }
656 668
657 669
    fn record_op(&self, parents: &[ObjectRef], command: &str, view: View) -> Result<ObjectRef> {
670 +
        if self.at_op.is_some() {
671 +
            return fail(
672 +
                "--at-op shows the repository as it was; this command would change it (run it without --at-op)",
673 +
            );
674 +
        }
658 675
        let op = Op {
659 676
            parents: parents.to_vec(),
660 677
            hlc: Hlc::now(),
…
166 unchanged lines
827 844
828 845
    /// Undo the last user command (spec §12.1). Operations that only recorded
829 846
    /// a working-copy snapshot are skipped, since the files are still on disk
830 -
    /// and the next snapshot recreates them.
847 +
    /// and the next snapshot recreates them, and a command's own checkout
848 +
    /// (`… (checkout)`) goes with the command. When the working copy is
849 +
    /// clean and was clean after the operation undone to, its files follow
850 +
    /// too, since nothing on disk can be lost; otherwise they stay as they
851 +
    /// are (an undone commit's edits come back as uncommitted work).
831 852
    pub fn undo(&self) -> Result<Op> {
832 853
        let _wc = self.lock_working_copy()?;
854 +
        let clean_now =
855 +
            self.clean_by_manifest()? || self.snapshot_status("oxy undo")?.statuses.is_empty();
833 856
        let (read_head, mut op) = self.head_op()?;
834 -
        while op.is_snapshot() {
835 -
            let Some(parent) = op.parents.first() else {
857 +
        let wc_now = op.view.workspaces.get(&self.workspace).copied();
858 +
        let up = |op: Op| -> Result<Option<Op>> {
859 +
            match op.parents.first() {
860 +
                Some(p) => Ok(Some(Op::decode(&self.store.get_typed(p, ObjectType::Op)?)?)),
861 +
                None => Ok(None),
862 +
            }
863 +
        };
864 +
        loop {
865 +
            while op.is_snapshot() {
866 +
                match up(op.clone())? {
867 +
                    Some(p) => op = p,
868 +
                    None => break,
869 +
                }
870 +
            }
871 +
            if !op.command.ends_with(" (checkout)") {
836 872
                break;
837 -
            };
838 -
            op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
873 +
            }
874 +
            match up(op.clone())? {
875 +
                Some(p) => op = p,
876 +
                None => break,
877 +
            }
839 878
        }
840 879
        let Some(parent) = op.parents.first() else {
841 880
            return fail("nothing to undo");
842 881
        };
843 882
        let parent_op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
844 -
        let _lock = self.lock_ops()?;
845 -
        if self.head_ref()? != read_head {
846 -
            return fail("another oxy command ran while undoing; run undo again");
883 +
        {
884 +
            let _lock = self.lock_ops()?;
885 +
            if self.head_ref()? != read_head {
886 +
                return fail("another oxy command ran while undoing; run undo again");
887 +
            }
888 +
            write_atomic(
889 +
                &self.oxy.join("ops").join("head"),
890 +
                parent.to_hex().as_bytes(),
891 +
            )?;
847 892
        }
848 -
        write_atomic(
849 -
            &self.oxy.join("ops").join("head"),
850 -
            parent.to_hex().as_bytes(),
851 -
        )?;
852 893
        self.drop_manifest();
894 +
        if let Some(then) = parent_op.view.workspaces.get(&self.workspace) {
895 +
            if clean_now && Some(*then) != wc_now {
896 +
                let wc = self.store.get_change(then)?;
897 +
                if wc.edits.is_empty() && wc.tree_ops.is_empty() {
898 +
                    let base = self.load_state_history(&wc.deps)?.maximal(&wc.deps);
899 +
                    // The files differ from the restored working copy by
900 +
                    // exactly what the undone command did: nothing else is
901 +
                    // on disk, as the working copy was clean.
902 +
                    self.checkout(&base, true, "oxy undo (checkout)")?;
903 +
                }
904 +
            }
905 +
        }
853 906
        Ok(parent_op)
854 907
    }
855 908
…
1170 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +70 −0
…
5830 unchanged lines
5831 5831
    assert!(e.bookmark_heads().unwrap().contains(&m));
5832 5832
}
5833 5833
5834 +
/// oxygen#51, #52: --at-op reads an earlier view and refuses changes;
5835 +
/// op restore goes back to an operation, files included, and undo takes
5836 +
/// the restore back whole; op revert undoes one operation alone; each
5837 +
/// operation's changes are described from the views.
5838 +
#[test]
5839 +
fn operations_can_be_read_restored_and_reverted() {
5840 +
    hermetic();
5841 +
    let dir = tempfile::tempdir().unwrap();
5842 +
    let root = dir.path();
5843 +
    let repo = Repo::init(root).unwrap();
5844 +
    write(root, "a.txt", b"a\n");
5845 +
    let first = repo.commit("first", &[]).unwrap();
5846 +
    write(root, "a.txt", b"a\nb\n");
5847 +
    let second = repo.commit("second", &[]).unwrap();
5848 +
    let at_second = repo.head_op().unwrap().0.to_hex();
5849 +
    repo.bookmark_create("keep", None).unwrap();
5850 +
    let created = repo.head_op().unwrap().0.to_hex();
5851 +
    write(root, "n.txt", b"n\n");
5852 +
    let third = repo.commit("third", &[]).unwrap();
5853 +
5854 +
    // What each operation changed.
5855 +
    let ops = repo.op_log().unwrap();
5856 +
    let changes = repo.view_changes(&ops[1].1.view, &ops[0].1.view).unwrap();
5857 +
    assert!(
5858 +
        changes
5859 +
            .iter()
5860 +
            .any(|c| matches!(c, ViewChange::BookmarkMoved { name, .. } if name == "main")),
5861 +
        "{changes:?}"
5862 +
    );
5863 +
    assert!(changes
5864 +
        .iter()
5865 +
        .any(|c| matches!(c, ViewChange::WorkingCopy { .. })));
5866 +
5867 +
    // Reading at an earlier operation.
5868 +
    let old = Repo::open(root).unwrap().at_op(&at_second[..10]).unwrap();
5869 +
    assert_eq!(old.bookmark_heads().unwrap(), vec![second]);
5870 +
    assert!(!old.view().unwrap().bookmarks.contains_key("keep"));
5871 +
    write(root, "z.txt", b"z\n");
5872 +
    assert!(old.commit("refused", &[]).is_err(), "--at-op is read-only");
5873 +
    fs::remove_file(root.join("z.txt")).unwrap();
5874 +
    assert!(repo.resolve_op("zz").is_err());
5875 +
    assert_eq!(repo.resolve_op("@").unwrap().0, repo.head_op().unwrap().0);
5876 +
5877 +
    // Restore goes back, files and all; undo takes it back whole.
5878 +
    repo.op_restore(&at_second, false).unwrap();
5879 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![second]);
5880 +
    assert!(!root.join("n.txt").exists());
5881 +
    repo.undo().unwrap();
5882 +
    let mut heads = repo.bookmark_heads().unwrap();
5883 +
    heads.sort();
5884 +
    let mut want = vec![second, third];
5885 +
    want.sort();
5886 +
    assert_eq!(heads, want);
5887 +
    assert!(
5888 +
        root.join("n.txt").exists(),
5889 +
        "the files came back with the undo"
5890 +
    );
5891 +
5892 +
    // Revert the bookmark's creation alone: the commit after it stays.
5893 +
    let r = repo.op_revert(&created).unwrap();
5894 +
    assert_eq!(r.reverted, vec!["keep".to_string()]);
5895 +
    assert!(!repo.view().unwrap().bookmarks.contains_key("keep"));
5896 +
    assert!(repo
5897 +
        .load_state(&repo.bookmark_heads().unwrap())
5898 +
        .unwrap()
5899 +
        .contains(&third));
5900 +
    assert!(repo.op_revert(&created).is_err(), "nothing left to revert");
5901 +
    let _ = first;
5902 +
}
5903 +
5834 5904
/// oxygen#35: a history-only state keeps no inserted text. It reads a
5835 5905
/// stored insert's text blob only when it builds that file's graph, and
5836 5906
/// builds the same graph a full load does.
…
708 unchanged lines
modified docs/guide.md +42 −1
…
207 unchanged lines
208 208
oxy rebase 658990aa --onto feature      # re-express a change against another base
209 209
oxy rebase 658990aa --onto develop --into-working-copy   # conflicted? resolve it here, then commit
210 210
oxy undo                                # undo the last operation, whatever it was
211 -
oxy op log                              # the operation log
211 +
oxy op log                              # every operation, what it changed, and its command
212 212
```
213 213
214 +
### The operation log
215 +
216 +
Every command that changes the repository records an operation: the
217 +
bookmarks, tags and working copy as they were after it. `oxy op log` lists
218 +
them newest first, each with what it changed and the command that did it;
219 +
working-copy snapshots fold into one row.
220 +
221 +
```
222 +
@  13adb0d0b8bf just now
223 +
│  main: 1 → 2 heads; working copy now 91ab4d50
224 +
│  oxy commit -m "Third"
225 +
○  fa21727d70da just now
226 +
│  created bookmark test (1 head)
227 +
│  oxy bookmark create test
228 +
○  2d15930afdef … 830168a3ddcd just now
229 +
│  2 working-copy snapshots
230 +
```
231 +
232 +
Any operation can be looked at, gone back to, or undone alone:
233 +
234 +
```
235 +
oxy --at-op fa21727d log                # the log as it was after that operation; any read-only command works
236 +
oxy op restore fa21727d                 # bookmarks and working copy as they were then
237 +
oxy op revert fa21727d                  # undo just that operation, keeping everything after it
238 +
oxy undo                                # takes back the last command, a restore included
239 +
```
240 +
241 +
`op restore` and `op revert` are operations themselves, so `oxy undo` takes
242 +
them back. `op revert` puts back each bookmark the operation moved, unless
243 +
it has moved again since. Tags and the trust policy never go backwards.
244 +
`undo` brings the files back as well when nothing uncommitted would be
245 +
lost; an undone commit's edits stay on disk as uncommitted work.
246 +
247 +
Commands that move the working copy end by saying where it is:
248 +
249 +
```
250 +
* Committed 95245a21 #35bbf5097e0c  Third
251 +
  Working copy  91ab4d50
252 +
  Bookmark      main: 2 heads, newest 95245a21 Third
253 +
```
254 +
214 255
Rewriting a change that others depend on rewrites them too, keeping their
215 256
ChangeIds. Because unrelated changes already coexist, `rebase` is rarely
216 257
needed; its job is to change what a change is said to build on, for instance
…
356 unchanged lines
modified src/cli/mod.rs +34 −5
…
24 unchanged lines
25 25
    /// Plain output: no colour, no spinner (also implied by NO_COLOR or a non-terminal)
26 26
    #[arg(long, global = true)]
27 27
    plain: bool,
28 +
    /// Read the repository as it was after operation OP (an ID from oxy op log, @ or @-); commands that would change it are refused
29 +
    #[arg(long, global = true, value_name = "OP")]
30 +
    at_op: Option<String>,
28 31
    #[command(subcommand)]
29 32
    cmd: Cmd,
30 33
}
31 34
35 +
/// `--at-op`, for `open` to apply.
36 +
static AT_OP: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
37 +
32 38
#[derive(Subcommand)]
33 39
enum Cmd {
34 40
    /// Create a repository in PATH (default: current directory)
…
428 unchanged lines
463 469
464 470
#[derive(Subcommand)]
465 471
enum OpCmd {
466 -
    /// List operations, newest first
472 +
    /// List operations, newest first, with what each changed
467 473
    Log {
474 +
        /// Show only the latest N (snapshots folded together count as one)
475 +
        #[arg(short = 'n', long)]
476 +
        limit: Option<usize>,
468 477
        /// Machine-readable output: one JSON document on stdout
469 478
        #[arg(long)]
470 479
        json: bool,
480 +
    },
481 +
    /// Go back to the bookmarks and working copy as they were after OP (recorded as a new operation, so it can be undone)
482 +
    Restore {
483 +
        /// The operation: an ID prefix from oxy op log, @ or @-
484 +
        op: String,
485 +
        /// Throw away uncommitted working-copy changes
486 +
        #[arg(long)]
487 +
        discard: bool,
488 +
    },
489 +
    /// Undo OP alone, keeping every operation after it: the bookmarks it moved go back
490 +
    Revert {
491 +
        /// The operation: an ID prefix from oxy op log
492 +
        op: String,
471 493
    },
472 494
}
473 495
…
469 unchanged lines
943 965
944 966
/// Open the repository containing the current directory.
945 967
fn open() -> Result<Repo> {
946 -
    Repo::open(&std::env::current_dir()?).context("opening repository")
968 +
    let repo = Repo::open(&std::env::current_dir()?).context("opening repository")?;
969 +
    match AT_OP.get().and_then(|o| o.as_deref()) {
970 +
        Some(op) => Ok(repo.at_op(op)?),
971 +
        None => Ok(repo),
972 +
    }
947 973
}
948 974
949 975
/// `oxy man`: every page into `dir`, or the top-level page on stdout.
…
24 unchanged lines
974 1000
pub fn run() -> Result<i32> {
975 1001
    let cli = Cli::parse();
976 1002
    ui::init(cli.plain);
1003 +
    AT_OP.set(cli.at_op.clone()).ok();
977 1004
    match cli.cmd {
978 1005
        Cmd::Completions { shell } => {
979 1006
            use clap::CommandFactory;
…
87 unchanged lines
1067 1094
        Cmd::Fsck { json } => workspace::fsck(json),
1068 1095
        Cmd::Doctor { offline, json } => workspace::doctor(offline, json),
1069 1096
        Cmd::Checkpoint => workspace::checkpoint(),
1070 -
        Cmd::Op {
1071 -
            cmd: OpCmd::Log { json },
1072 -
        } => workspace::op_log(json),
1097 +
        Cmd::Op { cmd } => match cmd {
1098 +
            OpCmd::Log { limit, json } => workspace::op_log(limit, json),
1099 +
            OpCmd::Restore { op, discard } => workspace::op_restore(&op, discard),
1100 +
            OpCmd::Revert { op } => workspace::op_revert(&op),
1101 +
        },
1073 1102
        Cmd::Clone {
1074 1103
            url,
1075 1104
            dest,
…
213 unchanged lines
modified src/cli/sync.rs +1 −0
…
335 unchanged lines
336 336
    others_pulled(&r.others);
337 337
    others_behind(&repo, &remote);
338 338
    subrepos_behind(&repo);
339 +
    super::workspace::where_now(&repo);
339 340
    Ok(0)
340 341
}
341 342
…
490 unchanged lines
modified src/cli/workspace.rs +237 −25
…
301 unchanged lines
302 302
            );
303 303
        }
304 304
    }
305 +
    where_now(&repo);
305 306
    Ok(0)
306 307
}
307 308
…
250 unchanged lines
558 559
    let repo = open()?;
559 560
    // Whether the working copy is clean and conflicted: from the manifest
560 561
    // when it proves the working copy clean, else from a snapshot.
561 -
    let (conflicted, clean) = if repo.clean_by_manifest()? {
562 +
    let (conflicted, clean) = if repo.is_at_op() || repo.clean_by_manifest()? {
562 563
        (false, true)
563 564
    } else {
564 565
        let snap = repo.snapshot_status("oxy log")?;
…
547 unchanged lines
1112 1113
            ui::warn("this state has conflicts; see oxy status")
1113 1114
        );
1114 1115
    }
1116 +
    where_now(&repo);
1115 1117
    Ok(0)
1116 1118
}
1117 1119
…
50 unchanged lines
1168 1170
    let repo = open()?;
1169 1171
    let op = repo.undo()?;
1170 1172
    println!("{} now at: {}", ui::done("Undone;"), ui::dim(&op.command));
1173 +
    where_now(&repo);
1171 1174
    Ok(0)
1172 1175
}
1173 1176
1174 -
pub fn op_log(as_json: bool) -> Result<i32> {
1177 +
pub fn op_log(limit: Option<usize>, as_json: bool) -> Result<i32> {
1175 1178
    let repo = open()?;
1179 +
    let ops = repo.op_log()?;
1180 +
    // What each operation changed, against the one before it.
1181 +
    let changes_of = |i: usize| -> Result<Vec<oxy_core::repo::ViewChange>> {
1182 +
        match ops.get(i + 1) {
1183 +
            Some((_, before)) => Ok(repo.view_changes(&before.view, &ops[i].1.view)?),
1184 +
            None => Ok(vec![]),
1185 +
        }
1186 +
    };
1176 1187
    if as_json {
1177 -
        let ops = repo.op_log()?;
1178 -
        println!(
1179 -
            "{}",
1180 -
            json::arr(ops.iter().enumerate().map(|(i, (r, op))| {
1181 -
                json::obj([
1182 -
                    ("id", json::s(&r.to_hex())),
1183 -
                    ("when_ms", json::n(op.hlc.wall_ms)),
1184 -
                    ("command", json::s(&op.command)),
1185 -
                    ("snapshot", json::b(op.command.ends_with("(snapshot)"))),
1186 -
                    ("current", json::b(i == 0)),
1187 -
                ])
1188 -
            }))
1189 -
        );
1188 +
        let mut items = Vec::new();
1189 +
        for (i, (r, op)) in ops.iter().enumerate().take(limit.unwrap_or(usize::MAX)) {
1190 +
            let summary: Vec<String> = changes_of(i)?
1191 +
                .iter()
1192 +
                .map(|c| change_words(&repo, c))
1193 +
                .collect();
1194 +
            items.push(json::obj([
1195 +
                ("id", json::s(&r.to_hex())),
1196 +
                ("when_ms", json::n(op.hlc.wall_ms)),
1197 +
                ("command", json::s(&op.command)),
1198 +
                ("snapshot", json::b(op.is_snapshot())),
1199 +
                ("current", json::b(i == 0)),
1200 +
                ("changes", json::arr(summary.iter().map(|x| json::s(x)))),
1201 +
            ]));
1202 +
        }
1203 +
        println!("{}", json::arr(items));
1190 1204
        return Ok(0);
1191 1205
    }
1192 -
    for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() {
1206 +
    let id_prefix = unique_prefixes(ops.iter().map(|(r, _)| r.to_hex()));
1207 +
    let id = |r: &oxy_core::hash::ObjectRef| {
1208 +
        let hex = r.to_hex();
1209 +
        let n = id_prefix.get(&hex).copied().unwrap_or(12).min(12);
1210 +
        format!("{}{}", ui::id(&hex[..n]), ui::dim(&hex[n..12]))
1211 +
    };
1212 +
    let rail = ui::dim(ui::glyph("│", "|"));
1213 +
    let (mut i, mut shown) = (0, 0);
1214 +
    while i < ops.len() && shown < limit.unwrap_or(usize::MAX) {
1215 +
        let (r, op) = &ops[i];
1193 1216
        let bullet = if i == 0 {
1194 -
            ui::ok(ui::glyph("●", "@"))
1217 +
            ui::ok(ui::glyph("@", "@"))
1195 1218
        } else {
1196 1219
            ui::dim(ui::glyph("○", "o"))
1197 1220
        };
1198 -
        let cmd = subject_of(&op.command).split("\\n").next().unwrap_or("");
1221 +
        // A run of snapshots folds into one entry.
1222 +
        let mut run = 1;
1223 +
        while op.is_snapshot() && i + run < ops.len() && ops[i + run].1.is_snapshot() {
1224 +
            run += 1;
1225 +
        }
1226 +
        let when = ui::dim(ui::ago(op.hlc.wall_ms));
1227 +
        if op.is_snapshot() {
1228 +
            let span = if run > 1 {
1229 +
                format!("{} {}", ui::dim("…"), id(&ops[i + run - 1].0))
1230 +
            } else {
1231 +
                String::new()
1232 +
            };
1233 +
            println!(
1234 +
                "{bullet}  {}{} {when}",
1235 +
                id(r),
1236 +
                if span.is_empty() {
1237 +
                    String::new()
1238 +
                } else {
1239 +
                    format!(" {span}")
1240 +
                }
1241 +
            );
1242 +
            println!(
1243 +
                "{rail}  {}",
1244 +
                ui::dim(if run == 1 {
1245 +
                    "working-copy snapshot".to_string()
1246 +
                } else {
1247 +
                    format!("{run} working-copy snapshots")
1248 +
                })
1249 +
            );
1250 +
        } else {
1251 +
            println!("{bullet}  {} {when}", id(r));
1252 +
            let words: Vec<String> = changes_of(i)?
1253 +
                .iter()
1254 +
                .map(|c| change_words(&repo, c))
1255 +
                .collect();
1256 +
            let summary = if words.is_empty() {
1257 +
                if i + 1 == ops.len() {
1258 +
                    "created the repository".to_string()
1259 +
                } else {
1260 +
                    "no bookmark or working-copy change".to_string()
1261 +
                }
1262 +
            } else {
1263 +
                words.join("; ")
1264 +
            };
1265 +
            println!(
1266 +
                "{rail}  {}",
1267 +
                if i == 0 {
1268 +
                    ui::subject(&summary)
1269 +
                } else {
1270 +
                    summary
1271 +
                }
1272 +
            );
1273 +
            let cmd = op.command.lines().next().unwrap_or("");
1274 +
            let cmd: String = cmd.chars().take(100).collect();
1275 +
            println!("{rail}  {}", ui::dim(cmd));
1276 +
        }
1277 +
        i += run;
1278 +
        shown += 1;
1279 +
    }
1280 +
    if i < ops.len() {
1199 1281
        println!(
1200 -
            "{bullet} {}{}{}  {}",
1201 -
            ui::hash(r.short(12)),
1202 -
            ui::sep(),
1203 -
            ui::dim(format!("{:<16}", ui::ago(op.hlc.wall_ms))),
1204 -
            if i == 0 {
1205 -
                ui::subject(cmd)
1282 +
            "{}",
1283 +
            ui::dim(format!(
1284 +
                "{} {} older; pass -n to see more",
1285 +
                ui::glyph("…", "..."),
1286 +
                plural(ops.len() - i, "operation")
1287 +
            ))
1288 +
        );
1289 +
    }
1290 +
    Ok(0)
1291 +
}
1292 +
1293 +
/// One change an operation made, in words.
1294 +
fn change_words(repo: &Repo, c: &oxy_core::repo::ViewChange) -> String {
1295 +
    use oxy_core::repo::ViewChange::*;
1296 +
    match c {
1297 +
        BookmarkCreated { name, heads } => {
1298 +
            format!("created bookmark {name} ({})", plural(*heads, "head"))
1299 +
        }
1300 +
        BookmarkDeleted { name } => format!("deleted bookmark {name}"),
1301 +
        BookmarkMoved { name, from, to } if from == to => format!("moved {name}"),
1302 +
        BookmarkMoved { name, from, to } => format!(
1303 +
            "{name}: {from} {} {}",
1304 +
            ui::glyph("→", "->"),
1305 +
            plural(*to, "head")
1306 +
        ),
1307 +
        RemoteMoved { remote, name } => format!("{remote}/{name} updated"),
1308 +
        TagCreated { name } => format!("tagged {name}"),
1309 +
        TagDeleted { name } => format!("removed tag {name}"),
1310 +
        Policy => "trust policy changed".to_string(),
1311 +
        WorkingCopy { change } => match repo.store().get_change_meta(change) {
1312 +
            Ok(m) => format!("working copy now {}", m.change_id.short(8)),
1313 +
            Err(_) => "working copy moved".to_string(),
1314 +
        },
1315 +
    }
1316 +
}
1317 +
1318 +
pub fn op_restore(op: &str, discard: bool) -> Result<i32> {
1319 +
    let repo = open()?;
1320 +
    let sp = ui::spinner(&format!("Restoring to operation {op}…"));
1321 +
    let r = repo.op_restore(op, discard)?;
1322 +
    sp.done(&format!(
1323 +
        "{} to operation {} {}",
1324 +
        ui::done("Restored"),
1325 +
        ui::hash(r.op.short(12)),
1326 +
        ui::dim(r.command.lines().next().unwrap_or(""))
1327 +
    ));
1328 +
    println!(
1329 +
        "  {} {}",
1330 +
        ui::note(),
1331 +
        ui::dim(format!(
1332 +
            "{} written, {} removed; oxy undo goes back again",
1333 +
            plural(r.checkout.written, "file"),
1334 +
            r.checkout.removed
1335 +
        ))
1336 +
    );
1337 +
    where_now(&repo);
1338 +
    Ok(0)
1339 +
}
1340 +
1341 +
pub fn op_revert(op: &str) -> Result<i32> {
1342 +
    let repo = open()?;
1343 +
    let r = repo.op_revert(op)?;
1344 +
    let view = repo.view()?;
1345 +
    let names: Vec<String> = r
1346 +
        .reverted
1347 +
        .iter()
1348 +
        .map(|n| {
1349 +
            if view.bookmarks.contains_key(n) {
1350 +
                ui::bookmark(n)
1206 1351
            } else {
1207 -
                cmd.to_string()
1352 +
                format!("{} (removed)", ui::bookmark(n))
1208 1353
            }
1354 +
        })
1355 +
        .collect();
1356 +
    println!(
1357 +
        "{} operation {} {}",
1358 +
        ui::done("Reverted"),
1359 +
        ui::hash(r.op.short(12)),
1360 +
        ui::dim(r.command.lines().next().unwrap_or(""))
1361 +
    );
1362 +
    println!(
1363 +
        "  {} {}",
1364 +
        ui::note(),
1365 +
        ui::dim(format!("as before it: {}", names.join(", ")))
1366 +
    );
1367 +
    if !r.kept.is_empty() {
1368 +
        println!(
1369 +
            "  {} {}",
1370 +
            ui::caution(),
1371 +
            ui::warn(format!(
1372 +
                "left as they are, having moved again since: {}",
1373 +
                r.kept.join(", ")
1374 +
            ))
1209 1375
        );
1210 1376
    }
1377 +
    if !r.checked_out && r.reverted.contains(&repo.config.bookmark) {
1378 +
        println!(
1379 +
            "  {} {}",
1380 +
            ui::note(),
1381 +
            ui::dim("the working copy has work of its own: oxy checkout to move it")
1382 +
        );
1383 +
    }
1384 +
    where_now(&repo);
1211 1385
    Ok(0)
1386 +
}
1387 +
1388 +
/// Where the working copy and bookmark are now, after a command moved
1389 +
/// them (as jj says "Working copy now at").
1390 +
pub fn where_now(repo: &Repo) {
1391 +
    let Ok((_, wc)) = repo.working_copy() else {
1392 +
        return;
1393 +
    };
1394 +
    let heads = repo.bookmark_heads().unwrap_or_default();
1395 +
    let newest = heads
1396 +
        .iter()
1397 +
        .filter_map(|h| repo.store().get_change_meta(h).ok().map(|m| (m, *h)))
1398 +
        .max_by_key(|(m, _)| m.hlc);
1399 +
    println!(
1400 +
        "  {} {}",
1401 +
        ui::dim(format!("{:<13}", "Working copy")),
1402 +
        ui::id(wc.change_id.short(8))
1403 +
    );
1404 +
    let tail = match newest {
1405 +
        Some((m, _)) => format!(
1406 +
            ", newest {} {}",
1407 +
            ui::id(m.change_id.short(8)),
1408 +
            subject_of(&m.message)
1409 +
        ),
1410 +
        None => String::new(),
1411 +
    };
1412 +
    println!(
1413 +
        "  {} {}: {}{tail}",
1414 +
        ui::dim(format!("{:<13}", "Bookmark")),
1415 +
        ui::bookmark(&repo.config.bookmark),
1416 +
        plural(heads.len(), "head")
1417 +
    );
1212 1418
}
1213 1419
1214 1420
pub fn gc(now: bool) -> Result<i32> {
…
274 unchanged lines
1489 1695
        ui::hash(format!("#{}", new.short(12))),
1490 1696
        ui::subject(subject_of(&c.message))
1491 1697
    );
1698 +
    where_now(&repo);
1492 1699
    Ok(0)
1493 1700
}
1494 1701
…
12 unchanged lines
1507 1714
    for n in notes {
1508 1715
        println!("  {} {n}", ui::note());
1509 1716
    }
1717 +
    where_now(&repo);
1510 1718
    Ok(0)
1511 1719
}
1512 1720
…
526 unchanged lines
2039 2247
        );
2040 2248
    }
2041 2249
    super::sync::subrepos_behind(&repo);
2250 +
    where_now(&repo);
2042 2251
    Ok(0)
2043 2252
}
2044 2253
…
32 unchanged lines
2077 2286
            ui::warn("the result has conflicts; see oxy status")
2078 2287
        );
2079 2288
    }
2289 +
    where_now(&repo);
2080 2290
    Ok(0)
2081 2291
}
2082 2292
…
45 unchanged lines
2128 2338
                    ui::warn("the working copy has changes of its own: commit or discard them, then oxy checkout to take the conflict in")
2129 2339
                );
2130 2340
            }
2341 +
            where_now(&repo);
2131 2342
            return Ok(0);
2132 2343
        }
2133 2344
        other => other?,
…
9 unchanged lines
2143 2354
            plural(new.deps.len(), "change")
2144 2355
        ))
2145 2356
    ));
2357 +
    where_now(&repo);
2146 2358
    Ok(0)
2147 2359
}