OXYGENHUB

undo: this workspace's last command, saying what it undid 530a0fc650a2

Operations record the workspace whose command made them. undo takes back
this workspace's newest command and leaves other workspaces' operations:
with none since, the head moves back as before; otherwise the command is
reverted in place as an operation naming it, a shared bookmark put back
head by head, and a reference that cannot be put back so refuses the
undo, naming it. Undo passes over pushes, reports the command it undid
and what that changed, and warns when the undone changes are on a
remote; amend warns when it rewrites a change a remote already holds.

closes #99
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 10:55 UTC
Signature
✔ signed by Andy McCall
Hash
530a0fc650a2e6f9e9cc220ded8be1621bc03070e410899e90856c763311e3b0
Depends on
135212de5370 19555025a2ac 23b217d75848 28fc23d7706a 34fad4a92451 614541c3cebd 6a81dbab471d 6caacfff1d4f 75167672ebcd 86e682c242c6 8fd78eb52ad2 94084087a876 982864068a59 98a1620a3840 9d580f2132f3 dd21fd82687d fce44ae9ad29 ff0af1ee299d
Depended on by
767ac6cd0545 c895bc52e4e0 b689779e9e74
In bookmarks
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.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
34 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **`oxy undo` undoes this workspace's last command (#99).** It undid the repository's newest operation, whichever workspace made it: in a repository with several workspaces an undo could take back another workspace's commit or merge, and it said only which operation it went back to. Operations now record their workspace, and undo takes back this workspace's last command, leaving other workspaces' work as it is. On a bookmark both share, only its own heads go; a change another workspace built on it, or the workspace it would take away, refuses the undo and names it. Undo now says which command it undid and what that changed, passes over pushes (the remote keeps what it was sent), and warns when an undone commit is already on a remote. `oxy amend` warns when it rewrites a change a remote already has, so the next push will need `--force`.
6 +
5 7
- **gc can keep what a host names (oxygenhub#126).** `Repo::gc_keeping` keeps extra blobs and their chunks beside what history reaches, so a forge's job artifacts, referenced only from its own database, survive garbage collection.
6 8
7 9
- **`oxy remote remove` removes all of a remote (#86).** It blanked the URL alone: the token, CA and store settings stayed in the config and the remote's tracking bookmarks in the view, read later as if current. Now every `remote.NAME.*` key goes, with its tracking bookmarks and a store remote's mirror; an unknown name is refused.
…
168 unchanged lines
modified crates/oxy-core/src/oplog.rs +33 −2
…
40 unchanged lines
41 41
    pub hlc: Hlc,
42 42
    pub command: String,
43 43
    pub view: View,
44 +
    /// The workspace whose command recorded it (oxygen#99); absent in
45 +
    /// operations written before it was kept.
46 +
    pub workspace: Option<WorkspaceId>,
47 +
    /// For an undo that reverted an older operation in place: that
48 +
    /// operation, so a further undo goes on past it.
49 +
    pub undoes: Option<ObjectRef>,
44 50
}
45 51
46 52
impl View {
…
253 unchanged lines
300 306
    }
301 307
302 308
    pub fn to_value(&self) -> Value {
303 -
        Value::map([
309 +
        let mut fields = vec![
304 310
            (0, Value::Uint(FORMAT_VERSION)),
305 311
            (
306 312
                1,
…
7 unchanged lines
314 320
            (2, self.hlc.to_value()),
315 321
            (3, Value::Text(self.command.clone())),
316 322
            (4, self.view.to_value()),
317 -
        ])
323 +
        ];
324 +
        // Absent when unknown, so older operations keep their encoding.
325 +
        if let Some(w) = &self.workspace {
326 +
            fields.push((5, Value::Bytes(w.0.to_vec())));
327 +
        }
328 +
        if let Some(u) = &self.undoes {
329 +
            fields.push((6, Value::Bytes(u.0.to_vec())));
330 +
        }
331 +
        Value::map(fields)
318 332
    }
319 333
    pub fn encode(&self) -> Vec<u8> {
320 334
        cbor::encode(&self.to_value())
…
15 unchanged lines
336 350
            hlc: Hlc::from_value(field(m, 2, "hlc")?)?,
337 351
            command: text(field(m, 3, "command")?, "command")?.to_owned(),
338 352
            view: View::from_value(field(m, 4, "view")?)?,
353 +
            workspace: match m.get(&5) {
354 +
                Some(v) => Some(
355 +
                    WorkspaceId::from_slice(bytes(v, "workspace")?).ok_or_else(|| {
356 +
                        ObjectError::Schema("workspace: expected 16 bytes".into())
357 +
                    })?,
358 +
                ),
359 +
                None => None,
360 +
            },
361 +
            undoes: m.get(&6).map(|v| oref(v, "undoes")).transpose()?,
339 362
        })
340 363
    }
341 364
…
41 unchanged lines
383 406
            },
384 407
            command: "oxy commit".into(),
385 408
            view,
409 +
            workspace: None,
410 +
            undoes: None,
386 411
        };
387 412
        let d = Op::decode(&op.encode()).unwrap();
388 413
        assert_eq!(d, op);
414 +
        // The workspace and an undo's target travel when known.
415 +
        let mut known = op.clone();
416 +
        known.workspace = Some(WorkspaceId([3; 16]));
417 +
        known.undoes = Some(ObjectRef([1; 32]));
418 +
        assert_eq!(Op::decode(&known.encode()).unwrap(), known);
419 +
        assert_ne!(known.hash(), op.hash());
389 420
        assert_eq!(d.hash(), Op::decode(&d.encode()).unwrap().hash());
390 421
        // A format-1 op does not decode as format 2, but does through the migration reader.
391 422
        let mut v1 = Format1View::default();
…
18 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +313 −37
…
142 unchanged lines
143 143
pub use hosted::FileEdit;
144 144
pub use identity::{Rotation, SignatureStatus};
145 145
pub use inspect::{BisectStep, BlameLine, Description};
146 -
pub use ops::{RestoreReport, RevertReport, ViewChange};
146 +
pub use ops::{RestoreReport, RevertReport, Undone, ViewChange};
147 147
pub use policy::PolicyUpdate;
148 148
pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT, GLYPH_PROMPT_FORMAT};
149 149
pub use receipts::ReceiptInfo;
…
460 unchanged lines
610 610
                hlc: old.hlc,
611 611
                command: old.command,
612 612
                view,
613 +
                workspace: None,
614 +
                undoes: None,
613 615
            };
614 616
            new_parent = Some(self.store.put(ObjectType::Op, &op.encode())?);
615 617
        }
…
137 unchanged lines
753 755
    }
754 756
755 757
    fn record_op(&self, parents: &[ObjectRef], command: &str, view: View) -> Result<ObjectRef> {
758 +
        self.record_op_undoing(parents, command, view, None)
759 +
    }
760 +
761 +
    /// [`Repo::record_op`], for an undo that reverts operation `undoes`.
762 +
    fn record_op_undoing(
763 +
        &self,
764 +
        parents: &[ObjectRef],
765 +
        command: &str,
766 +
        view: View,
767 +
        undoes: Option<ObjectRef>,
768 +
    ) -> Result<ObjectRef> {
756 769
        if self.at_op.is_some() {
757 770
            return fail(
758 771
                "--at-op shows the repository as it was; this command would change it (run it without --at-op)",
…
4 unchanged lines
763 776
            hlc: Hlc::now(),
764 777
            command: command.into(),
765 778
            view,
779 +
            workspace: Some(self.workspace),
780 +
            undoes,
766 781
        };
767 782
        let r = self.store.put(ObjectType::Op, &op.encode())?;
768 783
        // The head is the commit point: everything it names must be on disk first.
…
32 unchanged lines
801 816
                    hlc: Hlc::now(),
802 817
                    command: format!("{command} (merged with a concurrent operation)"),
803 818
                    view: self.merge_views(&base, &op.view, &theirs)?,
819 +
                    workspace: Some(self.workspace),
820 +
                    undoes: None,
804 821
                };
805 822
                let m = self.store.put(ObjectType::Op, &merged.encode())?;
806 823
                self.store.flush()?;
…
188 unchanged lines
995 1012
        Ok(out)
996 1013
    }
997 1014
998 -
    /// Undo the last user command (spec §12.1). Operations that only recorded
999 -
    /// a working-copy snapshot are skipped, since the files are still on disk
1000 -
    /// and the next snapshot recreates them, and a command's own checkout
1001 -
    /// (`… (checkout)`) goes with the command. When the working copy is
1002 -
    /// clean and was clean after the operation undone to, its files follow
1003 -
    /// too, since nothing on disk can be lost; otherwise they stay as they
1004 -
    /// are (an undone commit's edits come back as uncommitted work).
1005 -
    pub fn undo(&self) -> Result<Op> {
1015 +
    /// Undo this workspace's last command (spec §12.1, oxygen#99).
1016 +
    ///
1017 +
    /// The command undone is the newest one this workspace ran: operations
1018 +
    /// that only recorded a working-copy snapshot are skipped (the files are
1019 +
    /// still on disk and the next snapshot recreates them), as are pushes
1020 +
    /// (the remote keeps what it was sent; see [`Undone::pushed`]), a command's own
1021 +
    /// checkout (`… (checkout)`) goes with the command, other workspaces'
1022 +
    /// operations are never undone, and a command an earlier undo already
1023 +
    /// reverted is passed over, so undoing again goes further back.
1024 +
    ///
1025 +
    /// When nothing newer came from another workspace, the operation head
1026 +
    /// moves back to before the command. Otherwise the command is reverted
1027 +
    /// in place: every reference it changed goes back to its value before
1028 +
    /// it, recorded as a new operation that names it, and anything another
1029 +
    /// workspace changed since is kept. A reference both the command and a
1030 +
    /// later operation of another workspace changed is not guessed at: the
1031 +
    /// undo is refused, naming it.
1032 +
    ///
1033 +
    /// When the working copy is clean and was clean after the command's
1034 +
    /// predecessor, its files follow too, since nothing on disk can be
1035 +
    /// lost; otherwise they stay as they are (an undone commit's edits come
1036 +
    /// back as uncommitted work).
1037 +
    pub fn undo(&self) -> Result<Undone> {
1006 1038
        let _wc = self.lock_working_copy()?;
1007 1039
        let clean_now =
1008 1040
            self.clean_by_manifest()? || self.snapshot_status("oxy undo")?.statuses.is_empty();
1009 -
        let (read_head, mut op) = self.head_op()?;
1010 -
        let wc_now = op.view.workspaces.get(&self.workspace).copied();
1011 -
        let up = |op: Op| -> Result<Option<Op>> {
1012 -
            match op.parents.first() {
1013 -
                Some(p) => Ok(Some(Op::decode(&self.store.get_typed(p, ObjectType::Op)?)?)),
1014 -
                None => Ok(None),
1015 -
            }
1041 +
        let (head_ref, head) = self.head_op()?;
1042 +
        let wc_now = head.view.workspaces.get(&self.workspace).copied();
1043 +
        let load = |r: &ObjectRef| -> Result<Op> {
1044 +
            Ok(Op::decode(&self.store.get_typed(r, ObjectType::Op)?)?)
1016 1045
        };
1017 -
        loop {
1018 -
            while op.is_snapshot() {
1019 -
                match up(op.clone())? {
1020 -
                    Some(p) => op = p,
1021 -
                    None => break,
1046 +
        let mut skip: BTreeSet<ObjectRef> = BTreeSet::new();
1047 +
        let mut pending_checkout: Option<Op> = None;
1048 +
        let mut others = 0usize;
1049 +
        let (mut r, mut op) = (head_ref, head.clone());
1050 +
        let (target_ref, target, parent) = loop {
1051 +
            let parent = match op.parents.first() {
1052 +
                Some(p) => Some((*p, load(p)?)),
1053 +
                None => None,
1054 +
            };
1055 +
            if skip.contains(&r) {
1056 +
                pending_checkout = None;
1057 +
            } else if op.is_snapshot() || Self::is_push(&op, parent.as_ref().map(|(_, p)| p)) {
1058 +
                // A push cannot be taken back: undoing it would only make
1059 +
                // this repository forget what the remote holds.
1060 +
            } else if !self.op_is_ours(&op, parent.as_ref().map(|(_, p)| p)) {
1061 +
                others += 1;
1062 +
            } else if let Some(u) = op.undoes {
1063 +
                skip.insert(u);
1064 +
                pending_checkout = None;
1065 +
            } else if op.command.ends_with(" (checkout)") {
1066 +
                pending_checkout = Some(op.clone());
1067 +
            } else {
1068 +
                match parent {
1069 +
                    Some((_, p)) => break (r, op, p),
1070 +
                    None => return fail("nothing to undo in this workspace"),
1022 1071
                }
1023 1072
            }
1024 -
            if !op.command.ends_with(" (checkout)") {
1025 -
                break;
1073 +
            match parent {
1074 +
                Some((pr, p)) => (r, op) = (pr, p),
1075 +
                None => return fail("nothing to undo in this workspace"),
1026 1076
            }
1027 -
            match up(op.clone())? {
1028 -
                Some(p) => op = p,
1029 -
                None => break,
1030 -
            }
1031 -
        }
1032 -
        let Some(parent) = op.parents.first() else {
1033 -
            return fail("nothing to undo");
1034 1077
        };
1035 -
        let parent_op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
1036 -
        {
1078 +
        let before = parent.view;
1079 +
        let after = pending_checkout
1080 +
            .map(|o| o.view)
1081 +
            .unwrap_or_else(|| target.view.clone());
1082 +
        let changes = self.view_changes(&before, &after)?;
1083 +
        let pushed = self.pushed_by(&before, &after, &head.view)?;
1084 +
        let restored_wc = before.workspaces.get(&self.workspace).copied();
1085 +
        if others == 0 {
1086 +
            // Only this workspace's operations since: move the head back.
1037 1087
            let _lock = if self.store.is_local() {
1038 1088
                Some(self.lock_ops()?)
1039 1089
            } else {
1040 1090
                None
1041 1091
            };
1092 +
            let parent_ref = *target.parents.first().expect("the target has a parent");
1042 1093
            let (now, version) = self.read_head()?;
1043 -
            if now != read_head || !self.swap_head(parent, version.as_ref())? {
1094 +
            if now != head_ref || !self.swap_head(&parent_ref, version.as_ref())? {
1044 1095
                return fail("another oxy command ran while undoing; run undo again");
1045 1096
            }
1097 +
        } else {
1098 +
            let view = self.revert_view(&head.view, &before, &after)?;
1099 +
            self.record_op_undoing(&[head_ref], "oxy undo", view, Some(target_ref))?;
1046 1100
        }
1047 1101
        self.drop_manifest();
1048 -
        if let Some(then) = parent_op.view.workspaces.get(&self.workspace) {
1049 -
            if clean_now && Some(*then) != wc_now {
1050 -
                let wc = self.store.get_change(then)?;
1102 +
        if let Some(then) = restored_wc {
1103 +
            if clean_now && Some(then) != wc_now {
1104 +
                let wc = self.store.get_change(&then)?;
1051 1105
                if wc.edits.is_empty() && wc.tree_ops.is_empty() {
1052 1106
                    let base = self.load_state_history(&wc.deps)?.maximal(&wc.deps);
1053 1107
                    // The files differ from the restored working copy by
…
2 unchanged lines
1056 1110
                    self.checkout(&base, true, "oxy undo (checkout)")?;
1057 1111
                }
1058 1112
            }
1113 +
        }
1114 +
        Ok(Undone {
1115 +
            command: target.command,
1116 +
            changes,
1117 +
            others_kept: others,
1118 +
            pushed,
1119 +
        })
1120 +
    }
1121 +
1122 +
    /// Whether operation `op` was a push: it changed only what this
1123 +
    /// repository knows of remotes.
1124 +
    fn is_push(op: &Op, parent: Option<&Op>) -> bool {
1125 +
        let Some(p) = parent else {
1126 +
            return false;
1127 +
        };
1128 +
        op.command.starts_with("oxy push")
1129 +
            && op.view.bookmarks == p.view.bookmarks
1130 +
            && op.view.tags == p.view.tags
1131 +
            && op.view.workspaces == p.view.workspaces
1132 +
            && op.view.policy == p.view.policy
1133 +
    }
1134 +
1135 +
    /// Whether operation `op` (whose predecessor is `parent`) was this
1136 +
    /// workspace's. Operations record their workspace (oxygen#99); for older
1137 +
    /// ones it is read from the view: one that changed only other
1138 +
    /// workspaces' working copies was theirs, anything else counts as ours.
1139 +
    fn op_is_ours(&self, op: &Op, parent: Option<&Op>) -> bool {
1140 +
        if let Some(w) = op.workspace {
1141 +
            return w == self.workspace;
1059 1142
        }
1060 -
        Ok(parent_op)
1143 +
        let Some(p) = parent else {
1144 +
            return true;
1145 +
        };
1146 +
        let ids: BTreeSet<&WorkspaceId> = op
1147 +
            .view
1148 +
            .workspaces
1149 +
            .keys()
1150 +
            .chain(p.view.workspaces.keys())
1151 +
            .collect();
1152 +
        let moved: Vec<&WorkspaceId> = ids
1153 +
            .into_iter()
1154 +
            .filter(|id| op.view.workspaces.get(id) != p.view.workspaces.get(id))
1155 +
            .collect();
1156 +
        moved.is_empty() || moved.contains(&&self.workspace)
1157 +
    }
1158 +
1159 +
    /// `current` with what changed from `before` to `after` put back, this
1160 +
    /// workspace's working copy included; refused when a later operation
1161 +
    /// changed one of the same references again.
1162 +
    fn revert_view(&self, current: &View, before: &View, after: &View) -> Result<View> {
1163 +
        fn back<K: Ord + Clone, V: Clone + PartialEq>(
1164 +
            cur: &mut BTreeMap<K, V>,
1165 +
            before: &BTreeMap<K, V>,
1166 +
            after: &BTreeMap<K, V>,
1167 +
            name: impl Fn(&K) -> String,
1168 +
            clashes: &mut Vec<String>,
1169 +
        ) {
1170 +
            let keys: BTreeSet<&K> = before.keys().chain(after.keys()).collect();
1171 +
            for k in keys {
1172 +
                let (b, a) = (before.get(k), after.get(k));
1173 +
                if a == b {
1174 +
                    continue;
1175 +
                }
1176 +
                if cur.get(k) != a {
1177 +
                    clashes.push(name(k));
1178 +
                    continue;
1179 +
                }
1180 +
                match b {
1181 +
                    Some(v) => cur.insert(k.clone(), v.clone()),
1182 +
                    None => cur.remove(k),
1183 +
                };
1184 +
            }
1185 +
        }
1186 +
        let mut out = current.clone();
1187 +
        let mut clashes = Vec::new();
1188 +
        // A bookmark another workspace moved since is put back head by
1189 +
        // head: the heads the command added go, the heads it replaced
1190 +
        // return, and the other workspace's heads stay. Only when a later
1191 +
        // change was built on one the command added is that not possible.
1192 +
        let names: BTreeSet<&String> = before
1193 +
            .bookmarks
1194 +
            .keys()
1195 +
            .chain(after.bookmarks.keys())
1196 +
            .collect();
1197 +
        for name in names {
1198 +
            let (b, a) = (before.bookmarks.get(name), after.bookmarks.get(name));
1199 +
            let cur = current.bookmarks.get(name);
1200 +
            if a == b {
1201 +
                continue;
1202 +
            }
1203 +
            if cur == a {
1204 +
                match b {
1205 +
                    Some(st) => out.bookmarks.insert(name.clone(), *st),
1206 +
                    None => out.bookmarks.remove(name),
1207 +
                };
1208 +
                continue;
1209 +
            }
1210 +
            let (Some(b), Some(a), Some(cur)) = (b, a, cur) else {
1211 +
                clashes.push(format!("bookmark {name}"));
1212 +
                continue;
1213 +
            };
1214 +
            let was: BTreeSet<ChangeHash> = self.store.get_state(b)?.heads.into_iter().collect();
1215 +
            let became: BTreeSet<ChangeHash> = self.store.get_state(a)?.heads.into_iter().collect();
1216 +
            let now: BTreeSet<ChangeHash> = self.store.get_state(cur)?.heads.into_iter().collect();
1217 +
            let added: BTreeSet<&ChangeHash> = became.difference(&was).collect();
1218 +
            if !added.iter().all(|h| now.contains(h)) {
1219 +
                clashes.push(format!(
1220 +
                    "bookmark {name} (a later change builds on this one)"
1221 +
                ));
1222 +
                continue;
1223 +
            }
1224 +
            let mut heads: Vec<ChangeHash> = now
1225 +
                .iter()
1226 +
                .filter(|h| !added.contains(h))
1227 +
                .chain(was.difference(&became))
1228 +
                .copied()
1229 +
                .collect();
1230 +
            heads.sort();
1231 +
            heads.dedup();
1232 +
            let heads = self.load_state(&heads)?.maximal(&heads);
1233 +
            out.bookmarks.insert(
1234 +
                name.clone(),
1235 +
                self.store.put_state(&StateObject::new(heads))?,
1236 +
            );
1237 +
        }
1238 +
1239 +
        back(
1240 +
            &mut out.tags,
1241 +
            &before.tags,
1242 +
            &after.tags,
1243 +
            |k| format!("tag {k}"),
1244 +
            &mut clashes,
1245 +
        );
1246 +
        let mine = self.workspace;
1247 +
        let others = |v: &View| -> BTreeMap<WorkspaceId, ChangeHash> {
1248 +
            v.workspaces
1249 +
                .iter()
1250 +
                .filter(|(k, _)| **k != mine)
1251 +
                .map(|(k, v)| (*k, *v))
1252 +
                .collect()
1253 +
        };
1254 +
        let mut theirs = others(&out);
1255 +
        let workspace_name = |id: &WorkspaceId| match self.workspace_path(id) {
1256 +
            Some(p) => format!("the workspace at {}", p.display()),
1257 +
            None => format!("workspace {}", id.to_hex()),
1258 +
        };
1259 +
        back(
1260 +
            &mut theirs,
1261 +
            &others(before),
1262 +
            &others(after),
1263 +
            workspace_name,
1264 +
            &mut clashes,
1265 +
        );
1266 +
        let empty = BTreeMap::new();
1267 +
        let names: BTreeSet<&String> = before.remotes.keys().chain(after.remotes.keys()).collect();
1268 +
        for n in names {
1269 +
            let mut cur = out.remotes.get(n).cloned().unwrap_or_default();
1270 +
            back(
1271 +
                &mut cur,
1272 +
                before.remotes.get(n).unwrap_or(&empty),
1273 +
                after.remotes.get(n).unwrap_or(&empty),
1274 +
                |k| format!("{n}/{k}"),
1275 +
                &mut clashes,
1276 +
            );
1277 +
            if cur.is_empty() {
1278 +
                out.remotes.remove(n);
1279 +
            } else {
1280 +
                out.remotes.insert(n.clone(), cur);
1281 +
            }
1282 +
        }
1283 +
        if before.policy != after.policy {
1284 +
            if out.policy == after.policy {
1285 +
                out.policy = before.policy;
1286 +
            } else {
1287 +
                clashes.push("the policy".into());
1288 +
            }
1289 +
        }
1290 +
        if !clashes.is_empty() {
1291 +
            return fail(format!(
1292 +
                "undo would also take back what another workspace has done since to {}; \
1293 +
                 see `oxy op log`, and restore a whole earlier state with `oxy op restore OP` if that is what you want",
1294 +
                clashes.join(", ")
1295 +
            ));
1296 +
        }
1297 +
        out.workspaces = theirs;
1298 +
        match before.workspaces.get(&mine) {
1299 +
            Some(wc) => out.workspaces.insert(mine, *wc),
1300 +
            None => out.workspaces.remove(&mine),
1301 +
        };
1302 +
        Ok(out)
1303 +
    }
1304 +
1305 +
    /// Bookmarks whose heads, added between `before` and `after`, a remote
1306 +
    /// in `now` already holds: undoing them locally leaves them there.
1307 +
    fn pushed_by(&self, before: &View, after: &View, now: &View) -> Result<Vec<(String, String)>> {
1308 +
        let mut out = Vec::new();
1309 +
        for (name, st) in &after.bookmarks {
1310 +
            if before.bookmarks.get(name) == Some(st) {
1311 +
                continue;
1312 +
            }
1313 +
            let had: BTreeSet<ChangeHash> = match before.bookmarks.get(name) {
1314 +
                Some(b) => self.store.get_state(b)?.heads.into_iter().collect(),
1315 +
                None => BTreeSet::new(),
1316 +
            };
1317 +
            let added: Vec<ChangeHash> = self
1318 +
                .store
1319 +
                .get_state(st)?
1320 +
                .heads
1321 +
                .into_iter()
1322 +
                .filter(|h| !had.contains(h))
1323 +
                .collect();
1324 +
            if added.is_empty() {
1325 +
                continue;
1326 +
            }
1327 +
            for (remote, bookmarks) in &now.remotes {
1328 +
                if let Some(rs) = bookmarks.get(name) {
1329 +
                    let theirs = self.store.get_state(rs)?.heads;
1330 +
                    if added.iter().any(|h| theirs.contains(h)) {
1331 +
                        out.push((name.clone(), remote.clone()));
1332 +
                    }
1333 +
                }
1334 +
            }
1335 +
        }
1336 +
        Ok(out)
1061 1337
    }
1062 1338
1063 1339
    /// The last operation that was not a snapshot.
…
1325 unchanged lines
modified crates/oxy-core/src/repo/ops.rs +14 −0
…
36 unchanged lines
37 37
    },
38 38
}
39 39
40 +
/// What `undo` did (oxygen#99).
41 +
#[derive(Debug)]
42 +
pub struct Undone {
43 +
    /// The command undone.
44 +
    pub command: String,
45 +
    /// What that command had changed, now put back.
46 +
    pub changes: Vec<ViewChange>,
47 +
    /// Newer operations from other workspaces, left as they are.
48 +
    pub others_kept: usize,
49 +
    /// `(bookmark, remote)`: changes the command added to a bookmark that
50 +
    /// the remote already holds; undoing here does not take them back there.
51 +
    pub pushed: Vec<(String, String)>,
52 +
}
53 +
40 54
/// What `op restore` did.
41 55
#[derive(Debug)]
42 56
pub struct RestoreReport {
…
234 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +134 −0
…
8319 unchanged lines
8320 8320
    repo.gc(true).unwrap();
8321 8321
    assert!(!repo.store().has(&kept), "a plain gc prunes it");
8322 8322
}
8323 +
8324 +
/// oxygen#99: undo in one workspace undoes that workspace's last command,
8325 +
/// never another workspace's, says what it undid, and goes further back
8326 +
/// when run again.
8327 +
#[test]
8328 +
fn undo_is_scoped_to_its_workspace() {
8329 +
    hermetic();
8330 +
    let dir = tempfile::tempdir().unwrap();
8331 +
    let root = dir.path().join("main");
8332 +
    let a = Repo::init(&root).unwrap();
8333 +
    write(&root, "base.txt", b"base\n");
8334 +
    let base = a.commit("base", &[]).unwrap();
8335 +
    a.bookmark_create("feature", None).unwrap();
8336 +
    let ws_dir = dir.path().join("ws");
8337 +
    let heads = a.resolve_heads("feature").unwrap();
8338 +
    let b = a
8339 +
        .workspace_add_on(&ws_dir, Some(&heads), Some("feature"))
8340 +
        .unwrap();
8341 +
8342 +
    // A commits on main, then B commits on feature: B's is the newest.
8343 +
    write(&root, "a.txt", b"a\n");
8344 +
    let ca = a.commit("from a", &[]).unwrap();
8345 +
    write(&ws_dir, "b.txt", b"b\n");
8346 +
    let cb = b.commit("from b", &[]).unwrap();
8347 +
8348 +
    // A's undo undoes A's commit and leaves B's.
8349 +
    let u = a.undo().unwrap();
8350 +
    assert_eq!(u.command, "oxy commit -m \"from a\"");
8351 +
    assert_eq!(u.others_kept, 1, "B's newer commit is left");
8352 +
    assert!(!u.changes.is_empty());
8353 +
    let view = a.view().unwrap();
8354 +
    // (Independent changes commute, so a bookmark's heads may hold base too.)
8355 +
    assert_eq!(a.heads_of(&view, "main").unwrap(), vec![base]);
8356 +
    assert!(
8357 +
        a.heads_of(&view, "feature").unwrap().contains(&cb),
8358 +
        "B's commit stands"
8359 +
    );
8360 +
    assert!(
8361 +
        a.store().get_change(&ca).is_ok(),
8362 +
        "the change itself is still in the store"
8363 +
    );
8364 +
    // A's working copy was clean, so its files followed; B's are untouched.
8365 +
    assert!(!root.join("a.txt").exists());
8366 +
    assert!(a.snapshot("oxy status").unwrap().statuses.is_empty());
8367 +
    assert!(ws_dir.join("b.txt").exists());
8368 +
8369 +
    // B's undo still finds B's own commit, under A's undo.
8370 +
    let u = b.undo().unwrap();
8371 +
    assert_eq!(u.command, "oxy commit -m \"from b\"");
8372 +
    assert!(!b
8373 +
        .heads_of(&b.view().unwrap(), "feature")
8374 +
        .unwrap()
8375 +
        .contains(&cb));
8376 +
    assert_eq!(
8377 +
        a.heads_of(&a.view().unwrap(), "main").unwrap(),
8378 +
        vec![base],
8379 +
        "A's undo stands"
8380 +
    );
8381 +
8382 +
    // A's next undo goes past the commit it already undid, to adding B's
8383 +
    // workspace: refused, since B has worked there since.
8384 +
    let e = a.undo().unwrap_err().to_string();
8385 +
    assert!(e.contains("the workspace at") && e.contains("ws"), "{e}");
8386 +
}
8387 +
8388 +
/// oxygen#99: two workspaces on one bookmark: an undo takes back only its
8389 +
/// own commit's head, and the other workspace's head stays.
8390 +
#[test]
8391 +
fn undo_on_a_shared_bookmark_keeps_the_other_workspaces_head() {
8392 +
    hermetic();
8393 +
    let dir = tempfile::tempdir().unwrap();
8394 +
    let root = dir.path().join("main");
8395 +
    let a = Repo::init(&root).unwrap();
8396 +
    write(&root, "base.txt", b"base\n");
8397 +
    a.commit("base", &[]).unwrap();
8398 +
    let ws_dir = dir.path().join("ws");
8399 +
    let b = a.workspace_add(&ws_dir, None).unwrap();
8400 +
    write(&root, "a.txt", b"a\n");
8401 +
    let ca = a.commit("from a", &[]).unwrap();
8402 +
    write(&ws_dir, "b.txt", b"b\n");
8403 +
    let cb = b.commit("from b", &[]).unwrap();
8404 +
    let u = a.undo().unwrap();
8405 +
    assert_eq!(u.command, "oxy commit -m \"from a\"");
8406 +
    let heads = a.heads_of(&a.view().unwrap(), "main").unwrap();
8407 +
    assert!(heads.contains(&cb) && !heads.contains(&ca), "{heads:?}");
8408 +
}
8409 +
8410 +
/// oxygen#99: an undo that would also take back what another workspace
8411 +
/// did since to the same bookmark is refused, naming it.
8412 +
#[test]
8413 +
fn undo_refuses_to_take_back_another_workspaces_work() {
8414 +
    hermetic();
8415 +
    let dir = tempfile::tempdir().unwrap();
8416 +
    let root = dir.path().join("main");
8417 +
    let a = Repo::init(&root).unwrap();
8418 +
    write(&root, "base.txt", b"base\n");
8419 +
    a.commit("base", &[]).unwrap();
8420 +
    let ws_dir = dir.path().join("ws");
8421 +
    let b = a.workspace_add(&ws_dir, None).unwrap();
8422 +
    // Both on main: A commits, then B builds on it, editing A's file.
8423 +
    write(&root, "a.txt", b"a\n");
8424 +
    a.commit("from a", &[]).unwrap();
8425 +
    let heads = b.resolve_heads("main").unwrap();
8426 +
    b.checkout(&heads, false, "oxy checkout").unwrap();
8427 +
    write(&ws_dir, "a.txt", b"a, then b\n");
8428 +
    b.commit("from b", &[]).unwrap();
8429 +
    let e = a.undo().unwrap_err().to_string();
8430 +
    assert!(
8431 +
        e.contains("bookmark main") && e.contains("builds on"),
8432 +
        "{e}"
8433 +
    );
8434 +
    assert!(e.contains("oxy op restore"), "{e}");
8435 +
}
8436 +
8437 +
/// oxygen#99: undoing a commit that was already pushed says so: the remote
8438 +
/// still has it.
8439 +
#[test]
8440 +
fn undo_says_when_the_commit_was_pushed() {
8441 +
    hermetic();
8442 +
    let dir = tempfile::tempdir().unwrap();
8443 +
    let root = dir.path();
8444 +
    Repo::init(&root.join("origin")).unwrap();
8445 +
    let url = root.join("origin").to_string_lossy().to_string();
8446 +
    let repo = Repo::clone(&url, &root.join("work"))
8447 +
        .unwrap()
8448 +
        .with_home(root.join("home"));
8449 +
    write(&root.join("work"), "a.txt", b"a\n");
8450 +
    repo.commit("a", &[]).unwrap();
8451 +
    assert!(repo.push("origin", false).unwrap()[0].ok);
8452 +
    // The push itself is passed over: the remote keeps what it was sent.
8453 +
    let u = repo.undo().unwrap();
8454 +
    assert_eq!(u.command, "oxy commit -m \"a\"");
8455 +
    assert_eq!(u.pushed, vec![("main".to_string(), "origin".to_string())]);
8456 +
}
modified docs/spec.md +3 −1
…
472 unchanged lines
473 473
  hlc     [2]: HLC
474 474
  command [3]: text                   // e.g. "oxy rebase -s k7m2p9 -d main"
475 475
  view    [4]: { bookmarks: map<name, state ObjectRef>, remotes: map, tags: map, workspaces: map<WorkspaceId, ChangeHash> }
476 +
  workspace [5]: WorkspaceId          // optional: the workspace whose command recorded it
477 +
  undoes  [6]: ObjectRef              // optional: the operation an in-place undo reverted
476 478
}
477 479
```
478 480
479 481
`view` is the complete state of all references and workspaces after the operation. The operation log is a DAG. The head (`.oxy/ops/head`) moves by compare-and-swap under a short lock (`.oxy/ops/lock`), held only while the pointer is written: a process that finds the head is no longer the parent it built on records a merge operation with both as parents, whose view is the three-way merge of the two views against that parent. A reference changed on one side takes that side's value; changed differently on both, a bookmark keeps both sides' heads (it then has several), a change beats a deletion, a tag keeps its first value, remote references and the policy take the later process's value, and a workspace changed by both is an error: the later operation is not recorded, since the files on disk match only one of the two working-copy changes. `oxy undo` refuses when the head moved while it ran.
480 482
481 -
`oxy undo` moves the current operation to its parent's view. `oxy redo` and `oxy op log` expose the DAG. Operations are retained for `core.op-retention` (default 90 days) and then eligible for garbage collection.
483 +
`oxy undo` undoes **this workspace's** last command (oxygen#99): the newest operation recorded by this workspace (for operations older than the `workspace` field, one that moved only other workspaces' working copies is theirs), passing over working-copy snapshots, pushes (the remote keeps what it was sent) and commands an earlier undo reverted, and taking a command's own `(checkout)` with it. When no other workspace recorded an operation since, the head moves back to the command's parent. Otherwise the command is reverted in place as a new operation naming it in `undoes`: each reference it changed returns to its value before it; a bookmark another workspace moved since is put back head by head (the heads the command added go, those it replaced return, the other workspace's stay); and a reference that cannot be put back so (a later change builds on one the command added, or another workspace changed the same tag, remote reference, policy or working copy) refuses the undo, naming it. Undo reports the command it undid, what that changed, the other workspaces' operations it kept, and bookmarks whose undone changes a remote already holds. `oxy redo` and `oxy op log` expose the DAG. Operations are retained for `core.op-retention` (default 90 days) and then eligible for garbage collection.
482 484
483 485
Because all mutation goes through the operation log and object writes are idempotent, no long-held repository lock is needed: the operation head's lock covers one pointer write. Each workspace has a **working-copy lock** (`.oxy/workspaces/<id>/lock`), held by every command that reads or writes the working copy and replaces its working-copy change (snapshot, checkout, bookmark switch, merge, commit, amend and rewrites, discard, pull), from reading the operation head until its operation is recorded. A snapshot therefore never sees a half-written checkout, and never records one as edits. The lock is re-entrant within a process thread; a waiting command gives up after a minute and says why; a lock whose process is gone is taken over, and where that cannot be told, one older than a minute.
484 486
…
570 unchanged lines
modified src/cli/workspace.rs +48 −2
…
1345 unchanged lines
1346 1346
1347 1347
pub fn undo() -> Result<i32> {
1348 1348
    let repo = open()?;
1349 -
    let op = repo.undo()?;
1350 -
    println!("{} now at: {}", ui::done("Undone;"), ui::dim(&op.command));
1349 +
    let u = repo.undo()?;
1350 +
    println!("{} {}", ui::done("Undone:"), u.command);
1351 +
    for c in &u.changes {
1352 +
        println!("  {} {}", ui::note(), change_words(&repo, c));
1353 +
    }
1354 +
    if u.others_kept > 0 {
1355 +
        let s = if u.others_kept == 1 { "" } else { "s" };
1356 +
        println!(
1357 +
            "  {} {}",
1358 +
            ui::note(),
1359 +
            ui::dim(format!(
1360 +
                "{} newer operation{s} from other workspaces left as they are",
1361 +
                u.others_kept
1362 +
            ))
1363 +
        );
1364 +
    }
1365 +
    for (bookmark, remote) in &u.pushed {
1366 +
        println!(
1367 +
            "  {} {}",
1368 +
            ui::caution(),
1369 +
            ui::warn(format!(
1370 +
                "{remote}/{bookmark} already has what this undid: undone here only; the next push of {bookmark} needs --force to take it back there"
1371 +
            ))
1372 +
        );
1373 +
    }
1351 1374
    where_now(&repo);
1352 1375
    Ok(0)
1353 1376
}
…
504 unchanged lines
1858 1881
1859 1882
pub fn amend(message: Option<&str>, into: Option<&str>) -> Result<i32> {
1860 1883
    let repo = open()?;
1884 +
    let before = repo.view()?;
1861 1885
    let new = match into {
1862 1886
        Some(spec) => {
1863 1887
            let target = repo.resolve_change(spec)?;
…
9 unchanged lines
1873 1897
        ui::hash(format!("#{}", new.short(12))),
1874 1898
        ui::subject(subject_of(&c.message))
1875 1899
    );
1900 +
    // Rewriting what a remote already has means the next push replaces it
1901 +
    // there (oxygen#99): say so now, not when the push is refused.
1902 +
    let bookmark = repo.config.bookmark.clone();
1903 +
    let (had, has) = (
1904 +
        repo.heads_of(&before, &bookmark)?,
1905 +
        repo.heads_of(&repo.view()?, &bookmark)?,
1906 +
    );
1907 +
    for (remote, bookmarks) in &before.remotes {
1908 +
        let Some(st) = bookmarks.get(&bookmark) else {
1909 +
            continue;
1910 +
        };
1911 +
        let theirs = repo.store().get_state(st)?.heads;
1912 +
        if theirs.iter().any(|h| had.contains(h) && !has.contains(h)) {
1913 +
            println!(
1914 +
                "  {} {}",
1915 +
                ui::caution(),
1916 +
                ui::warn(format!(
1917 +
                    "{remote}/{bookmark} has the change as it was: the next push of {bookmark} needs --force to replace it"
1918 +
                ))
1919 +
            );
1920 +
        }
1921 +
    }
1876 1922
    where_now(&repo);
1877 1923
    Ok(0)
1878 1924
}
…
897 unchanged lines