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 |