|
|
… |
9 unchanged lines |
| 10 |
10 |
|
use crate::hash::{hash_object, ChangeHash, ObjectRef, ObjectType}; |
| 11 |
11 |
|
use crate::ids::{ChangeId, NodeId, WorkspaceId}; |
| 12 |
12 |
|
use crate::objects::{Blob, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, OpRef, TreeOp}; |
| 13 |
|
- |
use crate::oplog::{Op, View}; |
|
13 |
+ |
use crate::oplog::{Op, View, SNAPSHOT_SUFFIX}; |
| 14 |
14 |
|
use crate::state::{FileContent, Snapshot, State}; |
| 15 |
15 |
|
use crate::store::{Store, StoreError}; |
| 16 |
16 |
|
use crate::tree::OpKey; |
|
|
… |
248 unchanged lines |
| 265 |
265 |
|
Ok(out) |
| 266 |
266 |
|
} |
| 267 |
267 |
|
|
| 268 |
|
- |
/// Move to the parent operation (spec §12.1). |
|
268 |
+ |
/// Undo the last user command (spec §12.1). Operations that only recorded |
|
269 |
+ |
/// a working-copy snapshot are skipped, since the files are still on disk |
|
270 |
+ |
/// and the next snapshot recreates them. |
| 269 |
271 |
|
pub fn undo(&self) -> Result<Op> { |
| 270 |
|
- |
let (_, op) = self.head_op()?; |
|
272 |
+ |
let (_, mut op) = self.head_op()?; |
|
273 |
+ |
while op.is_snapshot() { |
|
274 |
+ |
let Some(parent) = op.parents.first() else { break }; |
|
275 |
+ |
op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?; |
|
276 |
+ |
} |
| 271 |
277 |
|
let Some(parent) = op.parents.first() else { |
| 272 |
278 |
|
return fail("nothing to undo"); |
| 273 |
279 |
|
}; |
|
|
… |
2 unchanged lines |
| 276 |
282 |
|
Ok(parent_op) |
| 277 |
283 |
|
} |
| 278 |
284 |
|
|
|
285 |
+ |
/// The last operation that was not a snapshot. |
|
286 |
+ |
pub fn last_command(&self) -> Result<Op> { |
|
287 |
+ |
let (_, mut op) = self.head_op()?; |
|
288 |
+ |
while op.is_snapshot() { |
|
289 |
+ |
let Some(parent) = op.parents.first() else { break }; |
|
290 |
+ |
op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?; |
|
291 |
+ |
} |
|
292 |
+ |
Ok(op) |
|
293 |
+ |
} |
|
294 |
+ |
|
| 279 |
295 |
|
// ------------------------------------------------------------ states |
| 280 |
296 |
|
|
| 281 |
297 |
|
/// Load the dependency closure of `heads` from the store. |
|
|
… |
144 unchanged lines |
| 426 |
442 |
|
self.store.put_change(&wc)?; |
| 427 |
443 |
|
let mut view = op.view.clone(); |
| 428 |
444 |
|
view.workspaces.insert(self.workspace, new_hash); |
| 429 |
|
- |
self.record_op(&[op_ref], &format!("{command} (snapshot)"), view)?; |
|
445 |
+ |
self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?; |
| 430 |
446 |
|
} |
| 431 |
447 |
|
Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed }) |
| 432 |
448 |
|
} |
|
|
… |
68 unchanged lines |
| 501 |
517 |
|
if spec == "@" { |
| 502 |
518 |
|
return Ok(self.working_copy()?.0); |
| 503 |
519 |
|
} |
|
520 |
+ |
if spec.is_empty() || spec == "#" { |
|
521 |
+ |
return fail("empty change specifier"); |
|
522 |
+ |
} |
| 504 |
523 |
|
let entries = self.log()?; |
| 505 |
524 |
|
let matches: Vec<&LogEntry> = if let Some(hp) = spec.strip_prefix('#') { |
| 506 |
525 |
|
entries.iter().filter(|e| e.hash.starts_with_hex(hp)).collect() |
|
|
… |
166 unchanged lines |
| 673 |
692 |
|
} |
| 674 |
693 |
|
} |
| 675 |
694 |
|
} |
| 676 |
|
- |
if status != Some(ChangeStatus::Modified) || !is_binary(&e.content) { |
| 677 |
|
- |
if e.exec != expected.mode.exec { |
|
695 |
+ |
let switched_to_opaque = status == Some(ChangeStatus::Modified) && is_binary(&e.content); |
|
696 |
+ |
if !switched_to_opaque && e.exec != expected.mode.exec { |
| 678 |
697 |
|
if let Some(old) = mode_atom { |
| 679 |
698 |
|
self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } }); |
| 680 |
699 |
|
status.get_or_insert(ChangeStatus::ModeChanged); |
| 681 |
|
- |
} |
| 682 |
700 |
|
} |
| 683 |
701 |
|
} |
| 684 |
702 |
|
} |
|
|
… |
336 unchanged lines |
| 1021 |
1039 |
|
} |
| 1022 |
1040 |
|
|
| 1023 |
1041 |
|
#[test] |
|
1042 |
+ |
fn undo_skips_snapshot_operations() { |
|
1043 |
+ |
let dir = tempfile::tempdir().unwrap(); |
|
1044 |
+ |
let root = dir.path(); |
|
1045 |
+ |
let repo = Repo::init(root).unwrap(); |
|
1046 |
+ |
write(root, "a.txt", b"a\n"); |
|
1047 |
+ |
let h1 = repo.commit("a", &[]).unwrap(); |
|
1048 |
+ |
// A later command snapshots new edits on top of the commit. |
|
1049 |
+ |
write(root, "b.txt", b"b\n"); |
|
1050 |
+ |
let s = repo.snapshot("oxy status").unwrap(); |
|
1051 |
+ |
assert!(s.changed); |
|
1052 |
+ |
assert!(repo.head_op().unwrap().1.is_snapshot()); |
|
1053 |
+ |
assert_eq!(repo.last_command().unwrap().command, "oxy commit -m \"a\""); |
|
1054 |
+ |
// Undo must undo the commit, not the snapshot. |
|
1055 |
+ |
repo.undo().unwrap(); |
|
1056 |
+ |
assert_eq!(repo.bookmark_heads().unwrap(), vec![]); |
|
1057 |
+ |
let s = repo.snapshot("oxy status").unwrap(); |
|
1058 |
+ |
let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect(); |
|
1059 |
+ |
assert_eq!(paths, ["a.txt", "b.txt"], "both files are back in the working copy"); |
|
1060 |
+ |
assert!(repo.store().get_change(&h1).unwrap().deps.is_empty()); |
|
1061 |
+ |
// Only snapshots remain above `init`, so there is nothing left to undo. |
|
1062 |
+ |
assert_eq!(repo.last_command().unwrap().command, "oxy init"); |
|
1063 |
+ |
assert!(repo.undo().is_err()); |
|
1064 |
+ |
} |
|
1065 |
+ |
|
|
1066 |
+ |
#[test] |
| 1024 |
1067 |
|
fn independent_changes_commute_and_make_multiple_heads() { |
| 1025 |
1068 |
|
let dir = tempfile::tempdir().unwrap(); |
| 1026 |
1069 |
|
let root = dir.path(); |
|
|
… |
54 unchanged lines |