OXYGENHUB

Reuse NodeIds across snapshots, make undo skip snapshot ops, clippy fixes 55e5a16a2b9c

Author
unknown
When
· 2026-09-26 17:22 UTC
Signature
◈ sealed by Andy McCall
Hash
55e5a16a2b9cecc868423abd9b2b7bdc3235d6304fb1aeb9986eb945d7f34103
Depends on
ff0af1ee299d
Depended on by
d45dab0484ea dfb23a3a8cfa 119bbf50cb86 252e8cb23fbc c3069537d7c5 86e682c242c6 f9c98deef326
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
14 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified crates/oxy-core/src/oplog.rs +9 −0
…
81 unchanged lines
82 82
    }
83 83
}
84 84
85 +
/// Suffix on the `command` of operations recorded by an automatic
86 +
/// working-copy snapshot rather than by a user command.
87 +
pub const SNAPSHOT_SUFFIX: &str = " (snapshot)";
88 +
85 89
impl Op {
90 +
    /// Whether this operation only recorded a working-copy snapshot.
91 +
    pub fn is_snapshot(&self) -> bool {
92 +
        self.command.ends_with(SNAPSHOT_SUFFIX)
93 +
    }
94 +
86 95
    pub fn to_value(&self) -> Value {
87 96
        Value::map([
88 97
            (0, Value::Uint(FORMAT_VERSION)),
…
44 unchanged lines
modified crates/oxy-core/src/repo.rs +53 −10
…
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 {
678 -
                        if let Some(old) = mode_atom {
679 -
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
680 -
                            status.get_or_insert(ChangeStatus::ModeChanged);
681 -
                        }
695 +
                let switched_to_opaque = status == Some(ChangeStatus::Modified) && is_binary(&e.content);
696 +
                if !switched_to_opaque && e.exec != expected.mode.exec {
697 +
                    if let Some(old) = mode_atom {
698 +
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
699 +
                        status.get_or_insert(ChangeStatus::ModeChanged);
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
modified crates/oxy-core/src/state.rs +4 −1
…
15 unchanged lines
16 16
    MissingDep(ChangeHash, ChangeHash),
17 17
}
18 18
19 +
/// One `Replace` edit: `(change, old blob, new blob)`.
20 +
type ReplaceRecord = (ChangeHash, Option<ObjectRef>, Option<ObjectRef>);
21 +
19 22
#[derive(Default)]
20 23
pub struct State {
21 24
    changes: HashMap<ChangeHash, Change>,
22 25
    tree: TreeGraph,
23 26
    files: HashMap<NodeId, FileGraph>,
24 -
    replaces: HashMap<NodeId, Vec<(ChangeHash, Option<ObjectRef>, Option<ObjectRef>)>>,
27 +
    replaces: HashMap<NodeId, Vec<ReplaceRecord>>,
25 28
    closure: RefCell<HashMap<ChangeHash, Rc<HashSet<ChangeHash>>>>,
26 29
}
27 30
…
365 unchanged lines