//! Working with the operation log (spec ยง12.1): naming an operation, //! reading the repository as it was after one (`--at-op`), going back to //! one (`op restore`), undoing one alone (`op revert`), and saying what an //! operation changed (`op log`). use super::*; /// One reference change between two views. #[derive(Clone, Debug, PartialEq, Eq)] pub enum ViewChange { BookmarkCreated { name: String, heads: usize, }, BookmarkDeleted { name: String, }, BookmarkMoved { name: String, from: usize, to: usize, }, RemoteMoved { remote: String, name: String, }, TagCreated { name: String, }, TagDeleted { name: String, }, Policy, /// This workspace's working copy is now `change`. WorkingCopy { change: ChangeHash, }, } /// What `undo` did (oxygen#99). #[derive(Debug)] pub struct Undone { /// The command undone. pub command: String, /// What that command had changed, now put back. pub changes: Vec, /// Newer operations from other workspaces, left as they are. pub others_kept: usize, /// `(bookmark, remote)`: changes the command added to a bookmark that /// the remote already holds; undoing here does not take them back there. pub pushed: Vec<(String, String)>, } /// What `op restore` did. #[derive(Debug)] pub struct RestoreReport { /// The operation restored to. pub op: ObjectRef, pub command: String, pub checkout: CheckoutReport, } /// What `op revert` did. #[derive(Debug)] pub struct RevertReport { pub op: ObjectRef, pub command: String, /// Bookmarks put back as they were before the operation. pub reverted: Vec, /// Bookmarks the operation moved that have moved again since: left as /// they are. pub kept: Vec, /// Whether the working copy followed the current bookmark. pub checked_out: bool, } impl Repo { /// This repository read as it was after operation `spec` (`--at-op`). /// Commands that would record an operation are refused. pub fn at_op(mut self, spec: &str) -> Result { let (r, _) = self.resolve_op(spec)?; self.at_op = Some(r); Ok(self) } /// Whether this repository is read at an earlier operation (`--at-op`). pub fn is_at_op(&self) -> bool { self.at_op.is_some() } /// The operation `spec` names: `@` for the current one, `@-` for the /// one before it, or a prefix of an operation ID from `oxy op log`. pub fn resolve_op(&self, spec: &str) -> Result<(ObjectRef, Op)> { let spec = spec.trim(); let log = self.op_log()?; if let Some(rest) = spec.strip_prefix('@') { if rest.chars().all(|c| c == '-') { return log .into_iter() .nth(rest.len()) .ok_or_else(|| RepoError::Msg(format!("the operation log has no {spec}"))); } } if spec.is_empty() || !spec.chars().all(|c| c.is_ascii_hexdigit()) { return fail(format!( "{spec:?} is not an operation: give an ID from oxy op log, @ or @-" )); } let spec = spec.to_ascii_lowercase(); let mut found = log .into_iter() .filter(|(r, _)| r.to_hex().starts_with(&spec)); match (found.next(), found.next()) { (Some(one), None) => Ok(one), (None, _) => fail(format!("no operation {spec} in oxy op log")), (Some(_), Some(_)) => fail(format!( "operation {spec} is ambiguous: give more of its ID" )), } } /// What changed from `before` to `after`, for this workspace's working /// copy and every reference. pub fn view_changes(&self, before: &View, after: &View) -> Result> { let mut out = Vec::new(); let heads = |st: &ObjectRef| -> Result { Ok(self.store.get_state(st)?.heads.len()) }; let names: BTreeSet<&String> = before .bookmarks .keys() .chain(after.bookmarks.keys()) .collect(); for name in names { match (before.bookmarks.get(name), after.bookmarks.get(name)) { (None, Some(a)) => out.push(ViewChange::BookmarkCreated { name: name.clone(), heads: heads(a)?, }), (Some(_), None) => out.push(ViewChange::BookmarkDeleted { name: name.clone() }), (Some(b), Some(a)) if a != b => out.push(ViewChange::BookmarkMoved { name: name.clone(), from: heads(b)?, to: heads(a)?, }), _ => {} } } for (remote, bookmarks) in &after.remotes { for (name, st) in bookmarks { if before.remotes.get(remote).and_then(|b| b.get(name)) != Some(st) { out.push(ViewChange::RemoteMoved { remote: remote.clone(), name: name.clone(), }); } } } for name in after.tags.keys().filter(|t| !before.tags.contains_key(*t)) { out.push(ViewChange::TagCreated { name: name.clone() }); } for name in before.tags.keys().filter(|t| !after.tags.contains_key(*t)) { out.push(ViewChange::TagDeleted { name: name.clone() }); } if before.policy != after.policy { out.push(ViewChange::Policy); } if let Some(w) = after.workspaces.get(&self.workspace) { if before.workspaces.get(&self.workspace) != Some(w) { out.push(ViewChange::WorkingCopy { change: *w }); } } Ok(out) } /// Go back to how the bookmarks and this working copy were after /// operation `spec`, recorded as a new operation, so it can be undone /// in turn. The working copy is checked out at the base it had then; /// uncommitted work stops it unless `discard`. Tags and the trust /// policy are left as they are: neither goes backwards. pub fn op_restore(&self, spec: &str, discard: bool) -> Result { let _wc = self.lock_working_copy()?; let (target_ref, target) = self.resolve_op(spec)?; let command = format!("oxy op restore {}", target_ref.short(12)); // Refuse before anything moves, as the checkout below would. if !discard && !self.clean_by_manifest()? { let snap = self.snapshot_status(&command)?; if !snap.statuses.is_empty() { return fail(format!( "the working copy has uncommitted changes ({}): commit them, or drop them with --discard", plural_paths(snap.statuses.len()) )); } } let (head_ref, head) = self.head_op()?; let mut view = head.view.clone(); view.bookmarks = target.view.bookmarks.clone(); self.record_op(&[head_ref], &command, view)?; let base = match target.view.workspaces.get(&self.workspace) { Some(w) => { let wc = self.store.get_change(w)?; self.load_state_history(&wc.deps)?.maximal(&wc.deps) } None => self.bookmark_heads()?, }; let checkout = self.checkout(&base, discard, &format!("{command} (checkout)"))?; Ok(RestoreReport { op: target_ref, command: target.command, checkout, }) } /// Undo operation `spec` alone, keeping everything after it: each /// bookmark it moved goes back to where it was before, unless it has /// moved again since. The working copy follows the current bookmark /// when it was clean and on its heads, as after a pull. pub fn op_revert(&self, spec: &str) -> Result { let _wc = self.lock_working_copy()?; let (target_ref, target) = self.resolve_op(spec)?; if target.is_snapshot() { return fail( "that operation only recorded the working copy: it moved no bookmark to revert", ); } let Some(parent) = target.parents.first() else { return fail("that is the first operation: there is nothing before it"); }; let before = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?.view; let (head_ref, head) = self.head_op()?; let mut view = head.view.clone(); let (mut reverted, mut kept) = (Vec::new(), Vec::new()); let names: BTreeSet<&String> = before .bookmarks .keys() .chain(target.view.bookmarks.keys()) .collect(); for name in names { let (was, became) = (before.bookmarks.get(name), target.view.bookmarks.get(name)); if was == became { continue; } if head.view.bookmarks.get(name) != became { kept.push(name.clone()); continue; } match was { Some(st) => view.bookmarks.insert(name.clone(), *st), None => view.bookmarks.remove(name), }; reverted.push(name.clone()); } if reverted.is_empty() { return fail(if kept.is_empty() { "that operation moved no bookmark: nothing to revert".to_string() } else { format!( "every bookmark that operation moved has moved again since ({}); oxy op restore goes back to an operation instead", kept.join(", ") ) }); } let command = format!("oxy op revert {}", target_ref.short(12)); let current = self.config.bookmark.clone(); let mut old = self.heads_of(&head.view, ¤t)?; old.sort(); self.record_op(&[head_ref], &command, view.clone())?; let mut checked_out = false; if reverted.contains(¤t) { let snap = self.snapshot(&command)?; let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty(); let mut base = snap.base.maximal(&snap.wc.deps); base.sort(); if clean && base == old { let new = self.heads_of(&view, ¤t)?; self.checkout(&new, false, &format!("{command} (checkout)"))?; checked_out = true; } } Ok(RevertReport { op: target_ref, command: target.command, reverted, kept, checked_out, }) } } fn plural_paths(n: usize) -> String { format!("{n} path{}", if n == 1 { "" } else { "s" }) }