Rewrite propagation: describe and abandon on any change, dependents rewritten with translated references c07e0e7d2d85
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-26 19:37 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
c07e0e7d2d8537a07ead0a19ffaab76899ddedc1259d19538d9a07443eb0ed73- Depends on
- 614541c3cebd 982864068a59 ff0af1ee299d
- Depended on by
- 47ce8b8e2d4b 5b4c51307b14 01a47cd7f0c8 57eaac9107df 6caacfff1d4f 6f8aeb996558 75167672ebcd 728da0f92161 6706bdcfb63e 23b217d75848 135212de5370 252e8cb23fbc
- 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
- 19 atoms, 1 tree operation, 5 files
Browse the tree at this change
added crates/oxy-core/src/rewrite.rs +274 −0
| 1 | + | //! Rewrite propagation (spec §8.4): when change `old` is replaced by `new`, |
|
| 2 | + | //! every change that depends on `old` is rewritten with its references |
|
| 3 | + | //! translated through a correspondence from `old`'s atoms and tree operations |
|
| 4 | + | //! to `new`'s. Rewritten changes keep their ChangeIds. Where a reference has |
|
| 5 | + | //! no counterpart, the rewrite fails with the orphaned reference named; |
|
| 6 | + | //! turning that into a reified conflict is future work. |
|
| 7 | + | ||
| 8 | + | use crate::hash::ChangeHash; |
|
| 9 | + | use crate::objects::{AtomRange, AtomRef, Change, Edit, OpRef, TreeOp}; |
|
| 10 | + | use crate::state::State; |
|
| 11 | + | use std::collections::{BTreeSet, HashMap}; |
|
| 12 | + | ||
| 13 | + | /// How `old`'s atoms and tree operations map onto `new`'s. |
|
| 14 | + | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
|
| 15 | + | pub struct Correspondence { |
|
| 16 | + | /// Old atom index → new atom index, per node the old change edited. |
|
| 17 | + | /// Absent means the atom no longer exists. |
|
| 18 | + | pub atoms: HashMap<u32, u32>, |
|
| 19 | + | /// Old tree-op index → new tree-op index. |
|
| 20 | + | pub ops: HashMap<u32, u32>, |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | impl Correspondence { |
|
| 24 | + | /// Every atom and operation keeps its index: `new` has the same edits as `old`. |
|
| 25 | + | pub fn identity(change: &Change) -> Correspondence { |
|
| 26 | + | let mut atoms = HashMap::new(); |
|
| 27 | + | let mut per_node: HashMap<crate::ids::NodeId, u32> = HashMap::new(); |
|
| 28 | + | for e in &change.edits { |
|
| 29 | + | if let Edit::Insert { node, lines, .. } = e { |
|
| 30 | + | let idx = per_node.entry(*node).or_default(); |
|
| 31 | + | for _ in lines { |
|
| 32 | + | atoms.insert(*idx, *idx); |
|
| 33 | + | *idx += 1; |
|
| 34 | + | } |
|
| 35 | + | } |
|
| 36 | + | } |
|
| 37 | + | // Atom indices are per node, so the identity map is index → index for |
|
| 38 | + | // the largest index any node uses. |
|
| 39 | + | let max = per_node.values().copied().max().unwrap_or(0); |
|
| 40 | + | for i in 0..max { |
|
| 41 | + | atoms.insert(i, i); |
|
| 42 | + | } |
|
| 43 | + | let ops = (0..change.tree_ops.len() as u32).map(|i| (i, i)).collect(); |
|
| 44 | + | Correspondence { atoms, ops } |
|
| 45 | + | } |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] |
|
| 49 | + | pub enum RewriteError { |
|
| 50 | + | #[error("change {change} references {what} of the rewritten change, which no longer exists")] |
|
| 51 | + | Orphaned { change: ChangeHash, what: String }, |
|
| 52 | + | #[error("change {0} is not in the state")] |
|
| 53 | + | Missing(ChangeHash), |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | /// What a rewritten change was replaced by. |
|
| 57 | + | #[derive(Clone, Debug)] |
|
| 58 | + | enum Replacement { |
|
| 59 | + | Change(ChangeHash), |
|
| 60 | + | /// Removed: dependents inherit these dependencies instead. |
|
| 61 | + | Removed(Vec<ChangeHash>), |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | /// Rewrite every change in `state` that depends on `old`, given `new` (or |
|
| 65 | + | /// `None` when `old` is removed) and the correspondence between them. |
|
| 66 | + | /// Returns `(old hash, rewritten change)` pairs in dependency order, not |
|
| 67 | + | /// including `old` → `new` itself. |
|
| 68 | + | pub fn propagate(state: &State, old: ChangeHash, new: Option<&Change>, corr: &Correspondence) -> Result<Vec<(ChangeHash, Change)>, RewriteError> { |
|
| 69 | + | let old_change = state.get(&old).ok_or(RewriteError::Missing(old))?; |
|
| 70 | + | let mut mapping: HashMap<ChangeHash, Replacement> = HashMap::new(); |
|
| 71 | + | mapping.insert(old, match new { |
|
| 72 | + | Some(n) => Replacement::Change(n.hash()), |
|
| 73 | + | None => Replacement::Removed(old_change.deps.clone()), |
|
| 74 | + | }); |
|
| 75 | + | let mut corrs: HashMap<ChangeHash, Correspondence> = HashMap::new(); |
|
| 76 | + | corrs.insert(old, corr.clone()); |
|
| 77 | + | ||
| 78 | + | // Dependents in topological order: every change whose closure has `old`, |
|
| 79 | + | // sorted so that dependencies come first. |
|
| 80 | + | let mut dependents: Vec<ChangeHash> = state.changes().filter(|(h, _)| **h != old && state.closure(h).contains(&old)).map(|(h, _)| *h).collect(); |
|
| 81 | + | dependents.sort_by_key(|h| (state.closure(h).len(), *h)); |
|
| 82 | + | ||
| 83 | + | let mut out = Vec::new(); |
|
| 84 | + | for h in dependents { |
|
| 85 | + | let c = state.get(&h).ok_or(RewriteError::Missing(h))?; |
|
| 86 | + | let rewritten = rewrite_one(&h, c, &mapping, &corrs)?; |
|
| 87 | + | let rh = rewritten.hash(); |
|
| 88 | + | corrs.insert(h, Correspondence::identity(&rewritten)); |
|
| 89 | + | mapping.insert(h, Replacement::Change(rh)); |
|
| 90 | + | out.push((h, rewritten)); |
|
| 91 | + | } |
|
| 92 | + | Ok(out) |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | fn rewrite_one(hash: &ChangeHash, c: &Change, mapping: &HashMap<ChangeHash, Replacement>, corrs: &HashMap<ChangeHash, Correspondence>) -> Result<Change, RewriteError> { |
|
| 96 | + | let orphan = |what: String| RewriteError::Orphaned { change: *hash, what }; |
|
| 97 | + | let target = |change: &ChangeHash, what: &str| -> Result<Option<ChangeHash>, RewriteError> { |
|
| 98 | + | match mapping.get(change) { |
|
| 99 | + | Some(Replacement::Change(nh)) => Ok(Some(*nh)), |
|
| 100 | + | Some(Replacement::Removed(_)) => Err(orphan(what.to_string())), |
|
| 101 | + | None => Ok(None), |
|
| 102 | + | } |
|
| 103 | + | }; |
|
| 104 | + | let map_atom = |r: AtomRef| -> Result<AtomRef, RewriteError> { |
|
| 105 | + | match r { |
|
| 106 | + | AtomRef::Atom { change, index } => match target(&change, &format!("atom {index}"))? { |
|
| 107 | + | Some(nh) => { |
|
| 108 | + | let idx = corrs[&change].atoms.get(&index).ok_or_else(|| orphan(format!("atom {index}")))?; |
|
| 109 | + | Ok(AtomRef::Atom { change: nh, index: *idx }) |
|
| 110 | + | } |
|
| 111 | + | None => Ok(r), |
|
| 112 | + | }, |
|
| 113 | + | other => Ok(other), |
|
| 114 | + | } |
|
| 115 | + | }; |
|
| 116 | + | let map_op = |r: OpRef| -> Result<OpRef, RewriteError> { |
|
| 117 | + | match target(&r.change, &format!("tree operation {}", r.index))? { |
|
| 118 | + | Some(nh) => { |
|
| 119 | + | let idx = corrs[&r.change].ops.get(&r.index).ok_or_else(|| orphan(format!("tree operation {}", r.index)))?; |
|
| 120 | + | Ok(OpRef { change: nh, index: *idx }) |
|
| 121 | + | } |
|
| 122 | + | None => Ok(r), |
|
| 123 | + | } |
|
| 124 | + | }; |
|
| 125 | + | let mut out = c.clone(); |
|
| 126 | + | let mut deps: BTreeSet<ChangeHash> = BTreeSet::new(); |
|
| 127 | + | for d in &c.deps { |
|
| 128 | + | match mapping.get(d) { |
|
| 129 | + | Some(Replacement::Change(nh)) => { |
|
| 130 | + | deps.insert(*nh); |
|
| 131 | + | } |
|
| 132 | + | Some(Replacement::Removed(inherited)) => deps.extend(inherited.iter().copied()), |
|
| 133 | + | None => { |
|
| 134 | + | deps.insert(*d); |
|
| 135 | + | } |
|
| 136 | + | } |
|
| 137 | + | } |
|
| 138 | + | out.deps = deps.into_iter().collect(); |
|
| 139 | + | for op in &mut out.tree_ops { |
|
| 140 | + | match op { |
|
| 141 | + | TreeOp::RemoveNode { placement, .. } => *placement = map_op(*placement)?, |
|
| 142 | + | TreeOp::Move { old, .. } => *old = map_op(*old)?, |
|
| 143 | + | TreeOp::SetMode { old, .. } => *old = map_op(*old)?, |
|
| 144 | + | TreeOp::AddNode { .. } => {} |
|
| 145 | + | } |
|
| 146 | + | } |
|
| 147 | + | for e in &mut out.edits { |
|
| 148 | + | match e { |
|
| 149 | + | Edit::Insert { after, before, .. } => { |
|
| 150 | + | *after = map_atom(*after)?; |
|
| 151 | + | *before = map_atom(*before)?; |
|
| 152 | + | } |
|
| 153 | + | Edit::Delete { atoms, .. } => { |
|
| 154 | + | let mut mapped: Vec<(ChangeHash, u32)> = Vec::new(); |
|
| 155 | + | for r in atoms.iter() { |
|
| 156 | + | for i in r.from..r.to { |
|
| 157 | + | match map_atom(AtomRef::Atom { change: r.change, index: i })? { |
|
| 158 | + | AtomRef::Atom { change, index } => mapped.push((change, index)), |
|
| 159 | + | _ => unreachable!(), |
|
| 160 | + | } |
|
| 161 | + | } |
|
| 162 | + | } |
|
| 163 | + | let mut ranges: Vec<AtomRange> = Vec::new(); |
|
| 164 | + | for (ch, i) in mapped { |
|
| 165 | + | match ranges.last_mut() { |
|
| 166 | + | Some(r) if r.change == ch && r.to == i => r.to = i + 1, |
|
| 167 | + | _ => ranges.push(AtomRange { change: ch, from: i, to: i + 1 }), |
|
| 168 | + | } |
|
| 169 | + | } |
|
| 170 | + | *atoms = ranges; |
|
| 171 | + | } |
|
| 172 | + | Edit::Replace { .. } => {} |
|
| 173 | + | } |
|
| 174 | + | } |
|
| 175 | + | out.normalize(); |
|
| 176 | + | Ok(out) |
|
| 177 | + | } |
|
| 178 | + | ||
| 179 | + | #[cfg(test)] |
|
| 180 | + | mod tests { |
|
| 181 | + | use super::*; |
|
| 182 | + | use crate::hash::ObjectRef; |
|
| 183 | + | use crate::ids::{ChangeId, NodeId}; |
|
| 184 | + | use crate::objects::{ChangeKind, Hlc, Mode, NodeKind}; |
|
| 185 | + | use crate::state::FileContent; |
|
| 186 | + | ||
| 187 | + | fn change(deps: Vec<ChangeHash>, tree_ops: Vec<TreeOp>, edits: Vec<Edit>, msg: &str) -> Change { |
|
| 188 | + | let mut c = Change { change_id: ChangeId::random(), deps, author: ObjectRef::ZERO, hlc: Hlc { wall_ms: 1, logical: 0 }, message: msg.into(), tree_ops, edits, kind: ChangeKind::Normal }; |
|
| 189 | + | c.normalize(); |
|
| 190 | + | c |
|
| 191 | + | } |
|
| 192 | + | ||
| 193 | + | #[test] |
|
| 194 | + | fn describe_propagates_through_dependents() { |
|
| 195 | + | let mut st = State::new(); |
|
| 196 | + | let f = NodeId::random(); |
|
| 197 | + | let c1 = change(vec![], vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "f".into() }], vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"a\n".to_vec(), b"b\n".to_vec()] }], "one"); |
|
| 198 | + | let h1 = st.add(c1.clone()).unwrap(); |
|
| 199 | + | let c2 = change(vec![h1], vec![], vec![Edit::Delete { node: f, atoms: vec![AtomRange { change: h1, from: 0, to: 1 }] }, Edit::Insert { node: f, after: AtomRef::Atom { change: h1, index: 1 }, before: AtomRef::End, lines: vec![b"c\n".to_vec()] }], "two"); |
|
| 200 | + | let h2 = st.add(c2.clone()).unwrap(); |
|
| 201 | + | let c3 = change(vec![h2], vec![TreeOp::SetMode { node: f, old: OpRef { change: h1, index: 0 }, mode: Mode { exec: true, opaque: false } }], vec![Edit::Insert { node: f, after: AtomRef::Atom { change: h2, index: 0 }, before: AtomRef::End, lines: vec![b"d\n".to_vec()] }], "three"); |
|
| 202 | + | let h3 = st.add(c3.clone()).unwrap(); |
|
| 203 | + | let before = st.materialize(); |
|
| 204 | + | ||
| 205 | + | // Rewrite c1's message; everything downstream follows. |
|
| 206 | + | let mut n1 = c1.clone(); |
|
| 207 | + | n1.message = "one, described".into(); |
|
| 208 | + | let rewritten = propagate(&st, h1, Some(&n1), &Correspondence::identity(&c1)).unwrap(); |
|
| 209 | + | assert_eq!(rewritten.iter().map(|(h, _)| *h).collect::<Vec<_>>(), vec![h2, h3]); |
|
| 210 | + | let mut st2 = State::new(); |
|
| 211 | + | st2.add(n1.clone()).unwrap(); |
|
| 212 | + | for (_, c) in &rewritten { |
|
| 213 | + | st2.add(c.clone()).unwrap(); |
|
| 214 | + | } |
|
| 215 | + | assert_eq!(st2.len(), 3); |
|
| 216 | + | let after = st2.materialize(); |
|
| 217 | + | assert_eq!(after.files["f"].mode, before.files["f"].mode); |
|
| 218 | + | match (&after.files["f"].content, &before.files["f"].content) { |
|
| 219 | + | (FileContent::Text { bytes: a, .. }, FileContent::Text { bytes: b, .. }) => assert_eq!(a, b), |
|
| 220 | + | _ => panic!(), |
|
| 221 | + | } |
|
| 222 | + | assert!(matches!(&after.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"b\nc\nd\n")); |
|
| 223 | + | // ChangeIds survive; hashes change; deps point at the new hashes. |
|
| 224 | + | assert_eq!(rewritten[0].1.change_id, c2.change_id); |
|
| 225 | + | assert_eq!(rewritten[0].1.deps, vec![n1.hash()]); |
|
| 226 | + | assert_ne!(rewritten[0].1.hash(), h2); |
|
| 227 | + | assert_eq!(rewritten[1].1.deps, vec![rewritten[0].1.hash()]); |
|
| 228 | + | } |
|
| 229 | + | ||
| 230 | + | #[test] |
|
| 231 | + | fn missing_counterpart_is_reported() { |
|
| 232 | + | let mut st = State::new(); |
|
| 233 | + | let f = NodeId::random(); |
|
| 234 | + | let c1 = change(vec![], vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "f".into() }], vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"a\n".to_vec(), b"b\n".to_vec()] }], "one"); |
|
| 235 | + | let h1 = st.add(c1.clone()).unwrap(); |
|
| 236 | + | let c2 = change(vec![h1], vec![], vec![Edit::Insert { node: f, after: AtomRef::Atom { change: h1, index: 1 }, before: AtomRef::End, lines: vec![b"c\n".to_vec()] }], "two"); |
|
| 237 | + | let h2 = st.add(c2).unwrap(); |
|
| 238 | + | // The rewritten c1 drops line "b": atom 1 has no counterpart. |
|
| 239 | + | let mut n1 = c1.clone(); |
|
| 240 | + | n1.edits = vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"a\n".to_vec()] }]; |
|
| 241 | + | let corr = Correspondence { atoms: [(0, 0)].into_iter().collect(), ops: [(0, 0)].into_iter().collect() }; |
|
| 242 | + | let err = propagate(&st, h1, Some(&n1), &corr).unwrap_err(); |
|
| 243 | + | assert_eq!(err, RewriteError::Orphaned { change: h2, what: "atom 1".into() }); |
|
| 244 | + | assert_eq!(propagate(&st, ObjectRef([9; 32]), Some(&n1), &corr).unwrap_err(), RewriteError::Missing(ObjectRef([9; 32]))); |
|
| 245 | + | } |
|
| 246 | + | ||
| 247 | + | #[test] |
|
| 248 | + | fn removal_rewires_dependencies_or_fails_on_references() { |
|
| 249 | + | let mut st = State::new(); |
|
| 250 | + | let f = NodeId::random(); |
|
| 251 | + | let g = NodeId::random(); |
|
| 252 | + | let c1 = change(vec![], vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "f".into() }], vec![], "one"); |
|
| 253 | + | let h1 = st.add(c1).unwrap(); |
|
| 254 | + | // c2 merely depends on c1 explicitly (touches another file). |
|
| 255 | + | let c2 = change(vec![h1], vec![TreeOp::AddNode { node: g, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "g".into() }], vec![], "two"); |
|
| 256 | + | let h2 = st.add(c2.clone()).unwrap(); |
|
| 257 | + | let c3 = change(vec![h2], vec![], vec![Edit::Insert { node: g, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"x\n".to_vec()] }], "three"); |
|
| 258 | + | let h3 = st.add(c3).unwrap(); |
|
| 259 | + | // Removing c2 (an explicit-only dependency of nothing that references it) works: c3 inherits h1. |
|
| 260 | + | let out = propagate(&st, h2, None, &Correspondence::default()).unwrap(); |
|
| 261 | + | assert_eq!(out.len(), 1); |
|
| 262 | + | assert_eq!(out[0].0, h3); |
|
| 263 | + | assert_eq!(out[0].1.deps, vec![h1]); |
|
| 264 | + | // Removing c1 fails: c2 does not reference c1 (only an explicit dep), but |
|
| 265 | + | // nothing else does either, so it succeeds and c2 inherits no deps. |
|
| 266 | + | let out = propagate(&st, h1, None, &Correspondence::default()).unwrap(); |
|
| 267 | + | assert!(out.iter().any(|(h, c)| *h == h2 && c.deps.is_empty())); |
|
| 268 | + | // Removing c2 when c3 references its placement fails. |
|
| 269 | + | let c4 = change(vec![h2], vec![TreeOp::RemoveNode { node: g, placement: OpRef { change: h2, index: 0 } }], vec![], "four"); |
|
| 270 | + | let h4 = st.add(c4).unwrap(); |
|
| 271 | + | let err = propagate(&st, h2, None, &Correspondence::default()).unwrap_err(); |
|
| 272 | + | assert!(matches!(err, RewriteError::Orphaned { change, .. } if change == h4 || change == h3), "{err:?}"); |
|
| 273 | + | } |
|
| 274 | + | } |
modified crates/oxy-core/src/lib.rs +1 −0
| … | 14 unchanged lines |
||
| 15 | 15 | pub mod policy; |
|
| 16 | 16 | pub mod protocol; |
|
| 17 | 17 | pub mod repo; |
|
| 18 | + | pub mod rewrite; |
|
| 18 | 19 | pub mod state; |
|
| 19 | 20 | pub mod store; |
|
| 20 | 21 | pub mod tree; |
modified crates/oxy-core/src/repo/history.rs +84 −11
| 1 | 1 | //! History editing (spec §8.4): describe, amend, abandon. |
|
| 2 | 2 | //! |
|
| 3 | - | //! Only heads can be rewritten so far. A head has no dependents, so |
|
| 4 | - | //! rewriting it needs no propagation; rewriting a change with dependents |
|
| 5 | - | //! (translating their atom and placement references) is not implemented. |
|
| 6 | - | //! The rewritten change keeps its ChangeId and gets a new hash; the old |
|
| 7 | - | //! object stays in the store until garbage collection. |
|
| 3 | + | //! `describe` and `abandon` work on any change of the bookmark: dependents |
|
| 4 | + | //! are rewritten through `crate::rewrite`, with their references translated. |
|
| 5 | + | //! `amend` works on heads, where the amended change is rebuilt from the files |
|
| 6 | + | //! on disk. A rewritten change keeps its ChangeId and gets a new hash; old |
|
| 7 | + | //! objects stay in the store until garbage collection. Rewritten changes by |
|
| 8 | + | //! other authors lose their signatures, as with any rewrite of others' work. |
|
| 8 | 9 | ||
| 9 | 10 | use super::*; |
|
| 11 | + | use crate::rewrite::{propagate, Correspondence}; |
|
| 10 | 12 | ||
| 11 | 13 | impl Repo { |
|
| 12 | 14 | /// The bookmark head `hash`, or an error explaining why it cannot be rewritten. |
|
| … | 57 unchanged lines |
||
| 70 | 72 | Ok(new_hash) |
|
| 71 | 73 | } |
|
| 72 | 74 | ||
| 73 | - | /// Change the message of a head. |
|
| 75 | + | /// Replace `old` by `new` (or remove it) anywhere in the bookmark's state, |
|
| 76 | + | /// rewriting every dependent, including the working copy. Returns the |
|
| 77 | + | /// new hash of `old`, if any. |
|
| 78 | + | fn rewrite_change(&self, old: &ChangeHash, new: Option<&Change>, corr: &Correspondence, command: &str) -> Result<Option<ChangeHash>> { |
|
| 79 | + | let (op_ref, op) = self.head_op()?; |
|
| 80 | + | let bookmark = self.config.bookmark.clone(); |
|
| 81 | + | let heads = self.heads_of(&op.view, &bookmark)?; |
|
| 82 | + | let (wc_hash, _) = self.working_copy()?; |
|
| 83 | + | let mut all = heads.clone(); |
|
| 84 | + | all.push(wc_hash); |
|
| 85 | + | let state = self.load_state(&all)?; |
|
| 86 | + | if !state.closure(&wc_hash).contains(old) && !heads.contains(old) && !state.heads().contains(old) && !state.contains(old) { |
|
| 87 | + | return fail(format!("change #{} is not in {} or under the working copy", old.short(12), bookmark)); |
|
| 88 | + | } |
|
| 89 | + | let old_change = self.store.get_change(old)?; |
|
| 90 | + | if old_change.kind == ChangeKind::WorkingCopy { |
|
| 91 | + | return fail("that is the working copy; commit it instead"); |
|
| 92 | + | } |
|
| 93 | + | let rewritten = propagate(&state, *old, new, corr).map_err(|e| RepoError::Msg(format!("{e}; rewriting through such references is not implemented yet")))?; |
|
| 94 | + | ||
| 95 | + | let mut mapping: HashMap<ChangeHash, ChangeHash> = HashMap::new(); |
|
| 96 | + | let new_hash = match new { |
|
| 97 | + | Some(c) => { |
|
| 98 | + | let h = self.store_signed(c)?; |
|
| 99 | + | mapping.insert(*old, h); |
|
| 100 | + | Some(h) |
|
| 101 | + | } |
|
| 102 | + | None => None, |
|
| 103 | + | }; |
|
| 104 | + | for (h, c) in &rewritten { |
|
| 105 | + | let nh = if c.kind == ChangeKind::WorkingCopy { self.store.put_change(c)? } else { self.store_signed(c)? }; |
|
| 106 | + | mapping.insert(*h, nh); |
|
| 107 | + | } |
|
| 108 | + | let remap = |set: &[ChangeHash]| -> Vec<ChangeHash> { |
|
| 109 | + | let mut out = Vec::new(); |
|
| 110 | + | for h in set { |
|
| 111 | + | if h == old && new_hash.is_none() { |
|
| 112 | + | out.extend(old_change.deps.iter().copied()); |
|
| 113 | + | } else { |
|
| 114 | + | out.push(*mapping.get(h).unwrap_or(h)); |
|
| 115 | + | } |
|
| 116 | + | } |
|
| 117 | + | out |
|
| 118 | + | }; |
|
| 119 | + | let candidates = remap(&heads); |
|
| 120 | + | let st = self.load_state(&candidates)?; |
|
| 121 | + | let new_heads = st.maximal(&candidates); |
|
| 122 | + | let new_wc = mapping.get(&wc_hash).copied().unwrap_or(wc_hash); |
|
| 123 | + | ||
| 124 | + | let mut view = op.view.clone(); |
|
| 125 | + | view.bookmarks.insert(bookmark, self.store.put_state(&StateObject::new(new_heads))?); |
|
| 126 | + | view.workspaces.insert(self.workspace, new_wc); |
|
| 127 | + | self.record_op(&[op_ref], command, view)?; |
|
| 128 | + | Ok(new_hash) |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | /// Change the message of any change of the bookmark; dependents are rewritten. |
|
| 74 | 132 | pub fn describe(&self, hash: &ChangeHash, message: &str) -> Result<ChangeHash> { |
|
| 75 | 133 | if message.trim().is_empty() { |
|
| 76 | 134 | return fail("a message is required"); |
|
| 77 | 135 | } |
|
| 78 | - | let mut c = self.rewritable_head(hash)?; |
|
| 136 | + | let mut c = self.store.get_change(hash)?; |
|
| 137 | + | if c.kind == ChangeKind::WorkingCopy { |
|
| 138 | + | return fail("that is the working copy; commit it instead"); |
|
| 139 | + | } |
|
| 79 | 140 | if c.message == message.trim_end() { |
|
| 80 | 141 | return Ok(*hash); |
|
| 81 | 142 | } |
|
| 143 | + | let corr = Correspondence::identity(&c); |
|
| 82 | 144 | c.message = message.trim_end().to_string(); |
|
| 83 | 145 | c.hlc = Hlc::now(); |
|
| 84 | - | Ok(self.replace_head(hash, Some(&c), &format!("oxy describe {} -m {message:?}", c.change_id.short(8)))?.unwrap()) |
|
| 146 | + | let id = c.change_id; |
|
| 147 | + | Ok(self.rewrite_change(hash, Some(&c), &corr, &format!("oxy describe {} -m {message:?}", id.short(8)))?.unwrap()) |
|
| 85 | 148 | } |
|
| 86 | 149 | ||
| 87 | 150 | /// Fold the working copy into the head it sits on: the head is rebuilt |
|
| … | 38 unchanged lines |
||
| 126 | 189 | Ok(self.replace_head(&head, Some(&new), &format!("oxy amend {}", old.change_id.short(8)))?.unwrap()) |
|
| 127 | 190 | } |
|
| 128 | 191 | ||
| 129 | - | /// Drop a head from the bookmark. Its content stays on disk as |
|
| 192 | + | /// Drop a change from the bookmark. Dependents that merely depend on it |
|
| 193 | + | /// inherit its dependencies; one that references its atoms or placements |
|
| 194 | + | /// cannot be rewritten yet. A dropped head's content stays on disk as |
|
| 130 | 195 | /// uncommitted work if the working copy sat on it. |
|
| 131 | 196 | pub fn abandon(&self, hash: &ChangeHash) -> Result<()> { |
|
| 132 | - | let c = self.rewritable_head(hash)?; |
|
| 133 | - | self.replace_head(hash, None, &format!("oxy abandon {}", c.change_id.short(8)))?; |
|
| 197 | + | let c = self.store.get_change(hash)?; |
|
| 198 | + | if c.kind == ChangeKind::WorkingCopy { |
|
| 199 | + | return fail("that is the working copy; commit it instead"); |
|
| 200 | + | } |
|
| 201 | + | let heads = self.bookmark_heads()?; |
|
| 202 | + | if heads.contains(hash) { |
|
| 203 | + | self.replace_head(hash, None, &format!("oxy abandon {}", c.change_id.short(8)))?; |
|
| 204 | + | } else { |
|
| 205 | + | self.rewrite_change(hash, None, &Correspondence::default(), &format!("oxy abandon {}", c.change_id.short(8)))?; |
|
| 206 | + | } |
|
| 134 | 207 | Ok(()) |
|
| 135 | 208 | } |
|
| 136 | 209 | } |
modified crates/oxy-core/src/repo/snapshot.rs +6 −3
| … | 82 unchanged lines |
||
| 83 | 83 | Ok(BuiltChange { tree_ops: b.tree_ops, edits: b.edits, contributors: b.contributors.into_iter().collect(), resolves: b.resolves, statuses }) |
|
| 84 | 84 | } |
|
| 85 | 85 | ||
| 86 | - | /// Store a change and sign it when `core.sign = change`. |
|
| 86 | + | /// Store a change and, when `core.sign = change` and we are its author, |
|
| 87 | + | /// sign it. A rewritten change by another author is stored unsigned: only |
|
| 88 | + | /// its author (or a delegate) can vouch for it. |
|
| 87 | 89 | pub(crate) fn store_signed(&self, change: &Change) -> Result<ChangeHash> { |
|
| 88 | - | if self.config.sign_changes && self.secret_key()?.is_none() { |
|
| 90 | + | let ours = change.author == self.author(); |
|
| 91 | + | if self.config.sign_changes && ours && self.secret_key()?.is_none() { |
|
| 89 | 92 | return fail("core.sign is \"change\" but no secret key is available for author.key"); |
|
| 90 | 93 | } |
|
| 91 | 94 | let hash = self.store.put_change(change)?; |
|
| 92 | - | if self.config.sign_changes { |
|
| 95 | + | if self.config.sign_changes && ours { |
|
| 93 | 96 | self.sign_change(&hash)?; |
|
| 94 | 97 | } |
|
| 95 | 98 | Ok(hash) |
|
| … | 485 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +33 −3
| … | 810 unchanged lines |
||
| 811 | 811 | let s = repo.snapshot("status").unwrap(); |
|
| 812 | 812 | assert!(s.statuses.is_empty(), "{:?}", s.statuses); |
|
| 813 | 813 | assert_eq!(repo.resolve_change(&id2.short(8)).unwrap(), h2b, "the old object is no longer reachable from the log"); |
|
| 814 | - | // Non-heads cannot be rewritten yet. |
|
| 815 | - | assert!(repo.describe(&h1, "x").is_err()); |
|
| 814 | + | // Describing a non-head rewrites its dependents (h2b → h2c') and the working copy. |
|
| 815 | + | let h1b = repo.describe(&h1, "first, described").unwrap(); |
|
| 816 | + | assert_ne!(h1b, h1); |
|
| 817 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 818 | + | assert_eq!(heads.len(), 1); |
|
| 819 | + | let top = repo.store().get_change(&heads[0]).unwrap(); |
|
| 820 | + | assert_eq!((top.change_id, top.deps.clone()), (id2, vec![h1b])); |
|
| 821 | + | assert!(matches!(repo.verify_change(&heads[0]).unwrap(), SignatureStatus::Valid { .. }), "rewritten own change is re-signed"); |
|
| 822 | + | let (_, wc) = repo.working_copy().unwrap(); |
|
| 823 | + | assert_eq!(wc.deps, vec![heads[0]], "the working copy follows the rewrite"); |
|
| 824 | + | assert!(!repo.snapshot("status").unwrap().changed); |
|
| 825 | + | let snap = repo.load_state(&heads).unwrap().materialize(); |
|
| 826 | + | assert!(matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n")); |
|
| 816 | 827 | ||
| 817 | 828 | // amend: fold an edit into the head; the result is one change from h1. |
|
| 818 | 829 | write(root, "a.txt", b"one\ntwo\nthree\n"); |
|
| … | 14 unchanged lines |
||
| 833 | 844 | ||
| 834 | 845 | // abandon: the head goes, its content stays as uncommitted work. |
|
| 835 | 846 | repo.abandon(&h2d).unwrap(); |
|
| 836 | - | assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]); |
|
| 847 | + | assert_eq!(repo.bookmark_heads().unwrap(), vec![h1b], "the described first change is the head again"); |
|
| 837 | 848 | let s = repo.snapshot("status").unwrap(); |
|
| 838 | 849 | let paths: Vec<_> = s.statuses.iter().map(|p| (p.path.as_str(), p.status)).collect(); |
|
| 839 | 850 | assert_eq!(paths, vec![("a.txt", ChangeStatus::Modified), ("b.txt", ChangeStatus::Added)]); |
|
| … | 4 unchanged lines |
||
| 844 | 855 | repo.undo().unwrap(); |
|
| 845 | 856 | repo.undo().unwrap(); |
|
| 846 | 857 | assert_eq!(repo.bookmark_heads().unwrap(), vec![h2d]); |
|
| 858 | + | ||
| 859 | + | // Abandoning a non-head whose dependent references its atoms is refused; |
|
| 860 | + | // one that is only an explicit dependency can go. |
|
| 861 | + | let base = repo.bookmark_heads().unwrap()[0]; |
|
| 862 | + | let base_change = repo.store().get_change(&base).unwrap(); |
|
| 863 | + | let h1_now = base_change.deps[0]; |
|
| 864 | + | assert!(repo.abandon(&h1_now).unwrap_err().to_string().contains("not implemented")); |
|
| 865 | + | write(root, "c.txt", b"c\n"); |
|
| 866 | + | let hc = repo.commit("c", &[]).unwrap(); |
|
| 867 | + | write(root, "d.txt", b"d\n"); |
|
| 868 | + | let hd = repo.commit("d", &[hc]).unwrap(); |
|
| 869 | + | assert_eq!(repo.store().get_change(&hd).unwrap().deps, vec![hc]); |
|
| 870 | + | repo.abandon(&hc).unwrap(); |
|
| 871 | + | let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect(); |
|
| 872 | + | assert!(!heads.contains(&hc)); |
|
| 873 | + | let d_now = heads.iter().find(|h| repo.store().get_change(h).unwrap().message == "d").copied().unwrap(); |
|
| 874 | + | assert!(repo.store().get_change(&d_now).unwrap().deps.is_empty(), "d inherited c's (empty) dependencies"); |
|
| 875 | + | let s = repo.snapshot("status").unwrap(); |
|
| 876 | + | assert_eq!(s.statuses.iter().map(|p| p.path.as_str()).collect::<Vec<_>>(), vec!["c.txt"], "c's content is now uncommitted work"); |
|
| 847 | 877 | } |