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, ¤t)?; |
|
| 250 | + | old.sort(); |
|
| 251 | + | self.record_op(&[head_ref], &command, view.clone())?; |
|
| 252 | + | let mut checked_out = false; |
|
| 253 | + | if reverted.contains(¤t) { |
|
| 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, ¤t)?; |
|
| 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 | } |