History editing: orphaned references on rewrite are reified by re-anchoring (nearest surviving line of the chain, else the chain's anchors; vanished nodes drop their edits) and reported as notes instead of refused (spec 0.6m) 6706bdcfb63e
--- crates/oxy-core/src/repo/history.rs
-//! are rewritten through `crate::rewrite`, with their references translated.
- let rewritten = propagate(&state, *old, new, corr, &load).map_err(|e| RepoError::Msg(format!("{e}; rewriting through such references is not implemented yet")))?;
- Ok(new_hash)
- /// Drop a change from the bookmark. Dependents that merely depend on it
- /// inherit its dependencies; one that references its atoms or placements
- /// cannot be rewritten yet. A dropped head's content stays on disk as
- /// uncommitted work if the working copy sat on it.
- pub fn abandon(&self, hash: &ChangeHash) -> Result<()> {
- self.rewrite_change(hash, None, &Correspondence::default(), &format!("oxy abandon {}", c.change_id.short(8)))?;
- Ok(())
+//! are rewritten through `crate::rewrite`, with their references translated
+//! and orphaned references reified as re-anchoring, reported as notes.
+ self.rewrite_change_noted(old, new, corr, command).map(|(h, _)| h)
+ }
+
+ /// Like `rewrite_change`, also returning notes about reified references.
+ fn rewrite_change_noted(&self, old: &ChangeHash, new: Option<&Change>, corr: &Correspondence, command: &str) -> Result<(Option<ChangeHash>, Vec<String>)> {
+ let propagation = propagate(&state, *old, new, corr, &load).map_err(|e| RepoError::Msg(e.to_string()))?;
+ let rewritten = propagation.changes;
+ Ok((new_hash, propagation.notes))
+ /// Drop a change from the bookmark. Dependents inherit its dependencies;
+ /// references into it are reified by re-anchoring (spec §8.4) and the
+ /// adjustments are returned as notes. A dropped head's content stays on
+ /// disk as uncommitted work if the working copy sat on it.
+ pub fn abandon(&self, hash: &ChangeHash) -> Result<Vec<String>> {
+ Ok(vec![])
+ let (_, notes) = self.rewrite_change_noted(hash, None, &Correspondence::default(), &format!("oxy abandon {}", c.change_id.short(8)))?;
+ Ok(notes)
--- crates/oxy-core/src/repo/tests.rs
- // Abandoning a non-head whose dependent references its atoms is refused;
- // one that is only an explicit dependency can go.
- assert!(repo.abandon(&h1_now).unwrap_err().to_string().contains("not implemented"));
+ // Abandoning a non-head whose dependent references its atoms reifies
+ // the references (here: a.txt vanishes with it, so the dependent's edit
+ // of a.txt is dropped with a note) instead of refusing; undo restores it.
+ let notes = repo.abandon(&h1_now).unwrap();
+ assert!(!notes.is_empty(), "the dependent's edit of a.txt was reified");
+ let heads = repo.bookmark_heads().unwrap();
+ let after = repo.load_state(&heads).unwrap().materialize();
+ assert!(!after.files.contains_key("a.txt") && after.files.contains_key("b.txt"), "{:?}", after.files.keys());
+ repo.undo().unwrap();
+ assert_eq!(repo.bookmark_heads().unwrap(), vec![base]);
--- crates/oxy-core/src/rewrite.rs
-//! to `new`'s. Rewritten changes keep their ChangeIds. Where a reference has
-//! no counterpart, the rewrite fails with the orphaned reference named;
-//! turning that into a reified conflict is future work.
- #[error("change {change} references {what} of the rewritten change, which no longer exists")]
- Orphaned { change: ChangeHash, what: String },
-/// Returns `(old hash, rewritten change)` pairs in dependency order, not
-/// including `old` → `new` itself.
-pub fn propagate(state: &State, old: ChangeHash, new: Option<&Change>, corr: &Correspondence, load: &dyn Fn(&ChangeHash) -> Option<Change>) -> Result<Vec<(ChangeHash, Change)>, RewriteError> {
- let old_change = state.get(&old).ok_or(RewriteError::Missing(old))?;
- None => Replacement::Removed(old_change.deps.clone()),
- let rewritten = rewrite_one(&h, &c, &mapping, &corrs)?;
- Ok(out)
-fn rewrite_one(hash: &ChangeHash, c: &Change, mapping: &HashMap<ChangeHash, Replacement>, corrs: &HashMap<ChangeHash, Correspondence>) -> Result<Change, RewriteError> {
- let orphan = |what: String| RewriteError::Orphaned { change: *hash, what };
- let target = |change: &ChangeHash, what: &str| -> Result<Option<ChangeHash>, RewriteError> {
- match mapping.get(change) {
- Some(Replacement::Change(nh)) => Ok(Some(*nh)),
- Some(Replacement::Removed(_)) => Err(orphan(what.to_string())),
- None => Ok(None),
- };
- let map_atom = |r: AtomRef| -> Result<AtomRef, RewriteError> {
- AtomRef::Atom { change, index } => match target(&change, &format!("atom {index}"))? {
- Some(nh) => {
- let idx = corrs[&change].atoms.get(&index).ok_or_else(|| orphan(format!("atom {index}")))?;
- Ok(AtomRef::Atom { change: nh, index: *idx })
- None => Ok(r),
- other => Ok(other),
- let map_op = |r: OpRef| -> Result<OpRef, RewriteError> {
- match target(&r.change, &format!("tree operation {}", r.index))? {
- Some(nh) => {
- let idx = corrs[&r.change].ops.get(&r.index).ok_or_else(|| orphan(format!("tree operation {}", r.index)))?;
- Ok(OpRef { change: nh, index: *idx })
- }
- None => Ok(r),
- for op in &mut out.tree_ops {
- TreeOp::RemoveNode { placement, .. } => *placement = map_op(*placement)?,
- TreeOp::Move { old, .. } => *old = map_op(*old)?,
- TreeOp::SetMode { old, .. } => *old = map_op(*old)?,
- TreeOp::AddNode { .. } => {}
- for e in &mut out.edits {
- Edit::Insert { after, before, .. } => {
- *after = map_atom(*after)?;
- *before = map_atom(*before)?;
- }
- Edit::Delete { atoms, .. } => {
- match map_atom(AtomRef::Atom { change: r.change, index: i })? {
- AtomRef::Atom { change, index } => mapped.push((change, index)),
- _ => unreachable!(),
- *atoms = ranges;
- Edit::Replace { .. } => {}
- Ok(out)
- let rewritten = propagate(&st, h1, Some(&n1), &Correspondence::identity(&c1), &load).unwrap();
- fn missing_counterpart_is_reported() {
- // The rewritten c1 drops line "b": atom 1 has no counterpart.
- let err = propagate(&st, h1, Some(&n1), &corr, &load).unwrap_err();
- assert_eq!(err, RewriteError::Orphaned { change: h2, what: "atom 1".into() });
- let out = propagate(&st, h2, None, &Correspondence::default(), &load).unwrap();
- let out = propagate(&st, h1, None, &Correspondence::default(), &load).unwrap();
- // Removing c2 when c3 references its placement fails.
- let err = propagate(&st, h2, None, &Correspondence::default(), &load2).unwrap_err();
- assert!(matches!(err, RewriteError::Orphaned { change, .. } if change == h4 || change == h3), "{err:?}");
+//! to `new`'s. Rewritten changes keep their ChangeIds.
+//!
+//! Where a reference has no counterpart it is reified rather than refused:
+//! an insert anchor moves to the nearest surviving atom of the same insert
+//! chain in `old`, else to that chain's own anchors (an order conflict then
+//! arises naturally if `new` placed other content there); deletions of
+//! vanished atoms are dropped; operations and edits on nodes that vanished
+//! are dropped. Every such adjustment is reported as a note.
+/// The result of propagating a rewrite.
+#[derive(Clone, Debug, Default)]
+pub struct Propagation {
+ /// `(old hash, rewritten change)` in dependency order.
+ pub changes: Vec<(ChangeHash, Change)>,
+ /// Human-readable descriptions of reified orphaned references.
+ pub notes: Vec<String>,
+}
+
+/// Where each atom of a change was inserted: the chain it belongs to.
+struct Chain {
+ first: u32,
+ last: u32,
+ after: AtomRef,
+ before: AtomRef,
+}
+
+fn chains_of(c: &Change) -> Vec<Chain> {
+ let mut per_node: HashMap<crate::ids::NodeId, u32> = HashMap::new();
+ let mut out = Vec::new();
+ for e in &c.edits {
+ if let Edit::Insert { node, after, before, lines } = e {
+ let idx = per_node.entry(*node).or_default();
+ out.push(Chain { first: *idx, last: *idx + lines.len() as u32 - 1, after: *after, before: *before });
+ *idx += lines.len() as u32;
+ }
+ }
+ out
+}
+
+/// The result lists `(old hash, rewritten change)` pairs in dependency
+/// order, not including `old` → `new` itself, plus notes about reified
+/// orphaned references.
+pub fn propagate(state: &State, old: ChangeHash, new: Option<&Change>, corr: &Correspondence, load: &dyn Fn(&ChangeHash) -> Option<Change>) -> Result<Propagation, RewriteError> {
+ let old_meta = state.get(&old).ok_or(RewriteError::Missing(old))?;
+ let old_full = load(&old);
+ None => Replacement::Removed(old_meta.deps.clone()),
+ // Chains of the rewritten change, for re-anchoring; vanished nodes.
+ let chains: HashMap<ChangeHash, Vec<Chain>> = old_full.as_ref().map(|c| (old, chains_of(c))).into_iter().collect();
+ let mut vanished_nodes: BTreeSet<crate::ids::NodeId> = BTreeSet::new();
+ if let Some(c) = &old_full {
+ for (i, op) in c.tree_ops.iter().enumerate() {
+ if let TreeOp::AddNode { node, .. } = op {
+ let survives = new.is_some() && corr.ops.contains_key(&(i as u32));
+ if !survives {
+ vanished_nodes.insert(*node);
+ }
+ }
+ }
+ }
+ let mut notes = Vec::new();
+ let rewritten = rewrite_one(&h, &c, &mapping, &corrs, &chains, &vanished_nodes, &mut notes);
+ Ok(Propagation { changes: out, notes })
+/// Re-anchor a reference to a vanished atom of `change`: the nearest
+/// surviving atom of its chain (before it for `after` anchors, after it for
+/// `before` anchors), else the chain's own anchor.
+fn reanchor(change: &ChangeHash, index: u32, want_after: bool, mapping: &HashMap<ChangeHash, Replacement>, corrs: &HashMap<ChangeHash, Correspondence>, chains: &HashMap<ChangeHash, Vec<Chain>>) -> AtomRef {
+ let corr = corrs.get(change);
+ let chain = chains.get(change).and_then(|cs| cs.iter().find(|ch| ch.first <= index && index <= ch.last));
+ if let (Some(Replacement::Change(nh)), Some(corr), Some(ch)) = (mapping.get(change), corr, chain) {
+ let candidates: Box<dyn Iterator<Item = u32>> = if want_after { Box::new((ch.first..index).rev()) } else { Box::new(index + 1..=ch.last) };
+ for j in candidates {
+ if let Some(nj) = corr.atoms.get(&j) {
+ return AtomRef::Atom { change: *nh, index: *nj };
+ }
+ }
+ match chain {
+ Some(ch) => if want_after { ch.after } else { ch.before },
+ None => if want_after { AtomRef::Start } else { AtomRef::End },
+ }
+}
+
+fn rewrite_one(
+ hash: &ChangeHash,
+ c: &Change,
+ mapping: &HashMap<ChangeHash, Replacement>,
+ corrs: &HashMap<ChangeHash, Correspondence>,
+ chains: &HashMap<ChangeHash, Vec<Chain>>,
+ vanished_nodes: &BTreeSet<crate::ids::NodeId>,
+ notes: &mut Vec<String>,
+) -> Change {
+ let short = hash.short(12);
+ // An anchor: translate, or re-anchor when its counterpart is gone.
+ let map_anchor = |r: AtomRef, want_after: bool, notes: &mut Vec<String>| -> AtomRef {
+ AtomRef::Atom { change, index } => match mapping.get(&change) {
+ Some(Replacement::Change(nh)) => match corrs[&change].atoms.get(&index) {
+ Some(idx) => AtomRef::Atom { change: *nh, index: *idx },
+ None => {
+ notes.push(format!("change {short}: an anchor line of the rewritten change vanished; re-anchored to its nearest neighbour"));
+ reanchor(&change, index, want_after, mapping, corrs, chains)
+ }
+ },
+ Some(Replacement::Removed(_)) => {
+ notes.push(format!("change {short}: an anchor line of the removed change vanished; re-anchored to its neighbours"));
+ reanchor(&change, index, want_after, mapping, corrs, chains)
+ None => r,
+ other => other,
+ // A tree-operation reference: translate, or `None` when gone.
+ let map_op = |r: OpRef| -> Option<OpRef> {
+ match mapping.get(&r.change) {
+ Some(Replacement::Change(nh)) => corrs[&r.change].ops.get(&r.index).map(|idx| OpRef { change: *nh, index: *idx }),
+ Some(Replacement::Removed(_)) => None,
+ None => Some(r),
+ let mut ops = Vec::new();
+ for op in &out.tree_ops {
+ let node = op.node();
+ if vanished_nodes.contains(&node) || matches!(op, TreeOp::AddNode { parent, .. } if vanished_nodes.contains(parent)) {
+ notes.push(format!("change {short}: an operation on a file that vanished with the rewritten change was dropped"));
+ continue;
+ }
+ TreeOp::RemoveNode { node, placement } => match map_op(*placement) {
+ Some(p) => ops.push(TreeOp::RemoveNode { node: *node, placement: p }),
+ None => notes.push(format!("change {short}: removal of a placement that vanished was dropped")),
+ },
+ TreeOp::Move { node, old, parent, name } => match map_op(*old) {
+ Some(o) => ops.push(TreeOp::Move { node: *node, old: o, parent: *parent, name: name.clone() }),
+ None => notes.push(format!("change {short}: a move of {name} lost its origin and was dropped")),
+ },
+ TreeOp::SetMode { node, old, mode } => match map_op(*old) {
+ Some(o) => ops.push(TreeOp::SetMode { node: *node, old: o, mode: *mode }),
+ None => notes.push(format!("change {short}: a mode change lost its origin and was dropped")),
+ },
+ add @ TreeOp::AddNode { .. } => ops.push(add.clone()),
+ out.tree_ops = ops;
+ let mut edits = Vec::new();
+ for e in &out.edits {
+ if vanished_nodes.contains(&e.node()) {
+ notes.push(format!("change {short}: an edit of a file that vanished with the rewritten change was dropped"));
+ continue;
+ }
+ Edit::Insert { node, after, before, lines } => edits.push(Edit::Insert {
+ node: *node,
+ after: map_anchor(*after, true, notes),
+ before: map_anchor(*before, false, notes),
+ lines: lines.clone(),
+ }),
+ Edit::Delete { node, atoms } => {
+ let mut dropped = 0;
+ match mapping.get(&r.change) {
+ Some(Replacement::Change(nh)) => match corrs[&r.change].atoms.get(&i) {
+ Some(idx) => mapped.push((*nh, *idx)),
+ None => dropped += 1,
+ },
+ Some(Replacement::Removed(_)) => dropped += 1,
+ None => mapped.push((r.change, i)),
+ if dropped > 0 {
+ notes.push(format!("change {short}: {dropped} deletion(s) of lines that vanished were dropped"));
+ }
+ if !ranges.is_empty() {
+ edits.push(Edit::Delete { node: *node, atoms: ranges });
+ }
+ other => edits.push(other.clone()),
+ out.edits = edits;
+ out
+ let rewritten = propagate(&st, h1, Some(&n1), &Correspondence::identity(&c1), &load).unwrap().changes;
+ fn missing_counterpart_is_reanchored() {
+ // The rewritten c1 drops line "b": atom 1 has no counterpart, so c2's
+ // insert after "b" is re-anchored after "a", the nearest survivor.
+ let p = propagate(&st, h1, Some(&n1), &corr, &load).unwrap();
+ assert_eq!(p.notes.len(), 1, "{:?}", p.notes);
+ let n1h = n1.hash();
+ assert!(matches!(p.changes[0].1.edits[0], Edit::Insert { after: AtomRef::Atom { change, index: 0 }, before: AtomRef::End, .. } if change == n1h));
+ let mut st2 = State::new();
+ st2.add(n1.clone()).unwrap();
+ st2.add(p.changes[0].1.clone()).unwrap();
+ let m = st2.materialize();
+ assert!(!m.is_conflicted());
+ assert!(matches!(&m.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n"));
+
+ // Removing c1 altogether: f vanishes with it, so c2's edit is dropped.
+ let p = propagate(&st, h1, None, &Correspondence::default(), &load).unwrap();
+ assert_eq!(p.notes.len(), 1, "{:?}", p.notes);
+ assert!(p.changes[0].1.edits.is_empty() && p.changes[0].1.deps.is_empty());
+
+ // Rewriting c1 to keep the file but none of its lines: c2's line goes
+ // to c1's chain anchors (START, END).
+ let mut n2 = c1.clone();
+ n2.edits = vec![];
+ let corr = Correspondence { atoms: HashMap::new(), ops: [(0, 0)].into_iter().collect() };
+ let p = propagate(&st, h1, Some(&n2), &corr, &load).unwrap();
+ assert!(matches!(p.changes[0].1.edits[0], Edit::Insert { after: AtomRef::Start, before: AtomRef::End, .. }));
+ let mut st3 = State::new();
+ st3.add(n2.clone()).unwrap();
+ st3.add(p.changes[0].1.clone()).unwrap();
+ let m = st3.materialize();
+ assert!(!m.is_conflicted());
+ assert!(matches!(&m.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"c\n"));
+ let out = propagate(&st, h2, None, &Correspondence::default(), &load).unwrap().changes;
+ let out = propagate(&st, h1, None, &Correspondence::default(), &load).unwrap().changes;
+ // Removing c2, which created g: c3's edit of g and c4's removal of g's
+ // placement are dropped with notes, since the file no longer exists.
+ let p = propagate(&st, h2, None, &Correspondence::default(), &load2).unwrap();
+ assert_eq!(p.notes.len(), 2, "{:?}", p.notes);
+ for (_, c) in &p.changes {
+ assert!(c.edits.is_empty() && c.tree_ops.is_empty());
+ }
--- docs/spec.md
-| **Status** | Draft 0.6l |
-Rewriting a change `C` to `C'` (amend, rebase, split, squash) produces a new object with the same ChangeId. Amending an older change with the working copy's edits (`oxy amend --into C`) is allowed when every atom, placement, and node those edits reference exists in `C`'s closure; the edits are appended to `C` with references to `C` turned into self references, so `C`'s own atoms keep their indices and dependents are rewritten with an identity correspondence. Splitting a head (`oxy split C PATH..`) rebuilds it as a first change holding its effect on the named paths and a second holding the rest under the original ChangeId. Any change `D` with `hash(C) ∈ deps(D)` is orphaned relative to `C'`. `oxy` MUST automatically rewrite orphaned descendants by translating their AtomRefs and PlacementRefs from `C` to `C'` where a correspondence exists, and MUST record a conflict (§9) where it does not. Until the shape of that conflict is specified, an implementation MAY instead refuse the rewrite, naming the orphaned reference. Removing `C` altogether rewires each dependent to depend on `deps(C)`, and is refused where a dependent references `C`'s atoms or placements. Changes that do not depend on `C` are untouched, and a rewritten change by another author is stored without that author's signature, which only they can renew. Rewriting is recorded in the operation log (§12) and is undoable.
-*Changelog.* Draft 0.6l (2026-09-27): amend into older changes and split (§8.4).
+| **Status** | Draft 0.6m |
+Rewriting a change `C` to `C'` (amend, rebase, split, squash) produces a new object with the same ChangeId. Amending an older change with the working copy's edits (`oxy amend --into C`) is allowed when every atom, placement, and node those edits reference exists in `C`'s closure; the edits are appended to `C` with references to `C` turned into self references, so `C`'s own atoms keep their indices and dependents are rewritten with an identity correspondence. Splitting a head (`oxy split C PATH..`) rebuilds it as a first change holding its effect on the named paths and a second holding the rest under the original ChangeId. Any change `D` with `hash(C) ∈ deps(D)` is orphaned relative to `C'`. `oxy` MUST automatically rewrite orphaned descendants by translating their AtomRefs and PlacementRefs from `C` to `C'` where a correspondence exists. Where none exists the reference is **reified by re-anchoring**: an insert anchored on a vanished atom is anchored instead on the nearest surviving atom of the same insert chain of `C` (the preceding one for `after`, the following one for `before`), or on that chain's own anchors when none survives, so that an order conflict (§9.1) arises exactly when `C'` placed other content there; a deletion of a vanished atom is dropped; an operation or edit on a node that vanished with `C` is dropped. Each adjustment MUST be reported to the user. Removing `C` altogether rewires each dependent to depend on `deps(C)` and reifies references into `C` the same way. Changes that do not depend on `C` are untouched, and a rewritten change by another author is stored without that author's signature, which only they can renew. Rewriting is recorded in the operation log (§12) and is undoable.
+*Changelog.* Draft 0.6m (2026-09-27): orphaned references on rewrite are reified by re-anchoring rather than refused (§8.4).
+
+Draft 0.6l (2026-09-27): amend into older changes and split (§8.4).
--- src/cli/workspace.rs
- repo.abandon(&h)?;
+ let notes = repo.abandon(&h)?;
+ for n in notes {
+ println!(" {} {n}", ui::warn("note:"));
+ }
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-26 23:34 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
6706bdcfb63ea62675f92c26046956565ff87b2019fcd17a1e93031be7746d85- Depends on
- 135212de5370 23b217d75848 252e8cb23fbc 614541c3cebd 982864068a59 c07e0e7d2d85 fce44ae9ad29 ff0af1ee299d
- Depended on by
- 2c9ddac3c387 631fc8970bd0 98a1620a3840 75167672ebcd 728da0f92161 94084087a876 28fc23d7706a
- 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
- 53 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/oxy-core/src/repo/history.rs +18 −10
| 1 | 1 | //! History editing (spec §8.4): describe, amend, abandon. |
|
| 2 | 2 | //! |
|
| 3 | 3 | //! `describe` and `abandon` work on any change of the bookmark: dependents |
|
| 4 | - | //! are rewritten through `crate::rewrite`, with their references translated. |
|
| 4 | + | //! are rewritten through `crate::rewrite`, with their references translated |
|
| 5 | + | //! and orphaned references reified as re-anchoring, reported as notes. |
|
| 5 | 6 | //! `amend` works on heads, where the amended change is rebuilt from the files |
|
| 6 | 7 | //! on disk. A rewritten change keeps its ChangeId and gets a new hash; old |
|
| 7 | 8 | //! objects stay in the store until garbage collection. Rewritten changes by |
|
| … | 70 unchanged lines |
||
| 78 | 79 | /// rewriting every dependent, including the working copy. Returns the |
|
| 79 | 80 | /// new hash of `old`, if any. |
|
| 80 | 81 | fn rewrite_change(&self, old: &ChangeHash, new: Option<&Change>, corr: &Correspondence, command: &str) -> Result<Option<ChangeHash>> { |
|
| 82 | + | self.rewrite_change_noted(old, new, corr, command).map(|(h, _)| h) |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | /// Like `rewrite_change`, also returning notes about reified references. |
|
| 86 | + | fn rewrite_change_noted(&self, old: &ChangeHash, new: Option<&Change>, corr: &Correspondence, command: &str) -> Result<(Option<ChangeHash>, Vec<String>)> { |
|
| 81 | 87 | let (op_ref, op) = self.head_op()?; |
|
| 82 | 88 | let bookmark = self.config.bookmark.clone(); |
|
| 83 | 89 | let heads = self.heads_of(&op.view, &bookmark)?; |
|
| … | 9 unchanged lines |
||
| 93 | 99 | return fail("that is the working copy; commit it instead"); |
|
| 94 | 100 | } |
|
| 95 | 101 | let load = |h: &ChangeHash| self.store.get_change(h).ok(); |
|
| 96 | - | let rewritten = propagate(&state, *old, new, corr, &load).map_err(|e| RepoError::Msg(format!("{e}; rewriting through such references is not implemented yet")))?; |
|
| 102 | + | let propagation = propagate(&state, *old, new, corr, &load).map_err(|e| RepoError::Msg(e.to_string()))?; |
|
| 103 | + | let rewritten = propagation.changes; |
|
| 97 | 104 | ||
| 98 | 105 | let mut mapping: HashMap<ChangeHash, ChangeHash> = HashMap::new(); |
|
| 99 | 106 | let new_hash = match new { |
|
| … | 29 unchanged lines |
||
| 129 | 136 | view.workspaces.insert(self.workspace, new_wc); |
|
| 130 | 137 | self.record_op(&[op_ref], command, view)?; |
|
| 131 | 138 | self.drop_manifest(); |
|
| 132 | - | Ok(new_hash) |
|
| 139 | + | Ok((new_hash, propagation.notes)) |
|
| 133 | 140 | } |
|
| 134 | 141 | ||
| 135 | 142 | /// Change the message of any change of the bookmark; dependents are rewritten. |
|
| … | 57 unchanged lines |
||
| 193 | 200 | Ok(self.replace_head(&head, Some(&new), &format!("oxy amend {}", old.change_id.short(8)))?.unwrap()) |
|
| 194 | 201 | } |
|
| 195 | 202 | ||
| 196 | - | /// Drop a change from the bookmark. Dependents that merely depend on it |
|
| 197 | - | /// inherit its dependencies; one that references its atoms or placements |
|
| 198 | - | /// cannot be rewritten yet. A dropped head's content stays on disk as |
|
| 199 | - | /// uncommitted work if the working copy sat on it. |
|
| 200 | - | pub fn abandon(&self, hash: &ChangeHash) -> Result<()> { |
|
| 203 | + | /// Drop a change from the bookmark. Dependents inherit its dependencies; |
|
| 204 | + | /// references into it are reified by re-anchoring (spec §8.4) and the |
|
| 205 | + | /// adjustments are returned as notes. A dropped head's content stays on |
|
| 206 | + | /// disk as uncommitted work if the working copy sat on it. |
|
| 207 | + | pub fn abandon(&self, hash: &ChangeHash) -> Result<Vec<String>> { |
|
| 201 | 208 | let c = self.store.get_change(hash)?; |
|
| 202 | 209 | if c.kind == ChangeKind::WorkingCopy { |
|
| 203 | 210 | return fail("that is the working copy; commit it instead"); |
|
| … | 1 unchanged lines |
||
| 205 | 212 | let heads = self.bookmark_heads()?; |
|
| 206 | 213 | if heads.contains(hash) { |
|
| 207 | 214 | self.replace_head(hash, None, &format!("oxy abandon {}", c.change_id.short(8)))?; |
|
| 215 | + | Ok(vec![]) |
|
| 208 | 216 | } else { |
|
| 209 | - | self.rewrite_change(hash, None, &Correspondence::default(), &format!("oxy abandon {}", c.change_id.short(8)))?; |
|
| 217 | + | let (_, notes) = self.rewrite_change_noted(hash, None, &Correspondence::default(), &format!("oxy abandon {}", c.change_id.short(8)))?; |
|
| 218 | + | Ok(notes) |
|
| 210 | 219 | } |
|
| 211 | - | Ok(()) |
|
| 212 | 220 | } |
|
| 213 | 221 | } |
|
| 214 | 222 | ||
| … | 263 unchanged lines |
modified crates/oxy-core/src/repo/tests.rs +10 −3
| … | 881 unchanged lines |
||
| 882 | 882 | repo.undo().unwrap(); |
|
| 883 | 883 | assert_eq!(repo.bookmark_heads().unwrap(), vec![h2d]); |
|
| 884 | 884 | ||
| 885 | - | // Abandoning a non-head whose dependent references its atoms is refused; |
|
| 886 | - | // one that is only an explicit dependency can go. |
|
| 885 | + | // Abandoning a non-head whose dependent references its atoms reifies |
|
| 886 | + | // the references (here: a.txt vanishes with it, so the dependent's edit |
|
| 887 | + | // of a.txt is dropped with a note) instead of refusing; undo restores it. |
|
| 887 | 888 | let base = repo.bookmark_heads().unwrap()[0]; |
|
| 888 | 889 | let base_change = repo.store().get_change(&base).unwrap(); |
|
| 889 | 890 | let h1_now = base_change.deps[0]; |
|
| 890 | - | assert!(repo.abandon(&h1_now).unwrap_err().to_string().contains("not implemented")); |
|
| 891 | + | let notes = repo.abandon(&h1_now).unwrap(); |
|
| 892 | + | assert!(!notes.is_empty(), "the dependent's edit of a.txt was reified"); |
|
| 893 | + | let heads = repo.bookmark_heads().unwrap(); |
|
| 894 | + | let after = repo.load_state(&heads).unwrap().materialize(); |
|
| 895 | + | assert!(!after.files.contains_key("a.txt") && after.files.contains_key("b.txt"), "{:?}", after.files.keys()); |
|
| 896 | + | repo.undo().unwrap(); |
|
| 897 | + | assert_eq!(repo.bookmark_heads().unwrap(), vec![base]); |
|
| 891 | 898 | write(root, "c.txt", b"c\n"); |
|
| 892 | 899 | let hc = repo.commit("c", &[]).unwrap(); |
|
| 893 | 900 | write(root, "d.txt", b"d\n"); |
|
| … | 698 unchanged lines |
modified crates/oxy-core/src/rewrite.rs +204 −61
| 1 | 1 | //! Rewrite propagation (spec §8.4): when change `old` is replaced by `new`, |
|
| 2 | 2 | //! every change that depends on `old` is rewritten with its references |
|
| 3 | 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. |
|
| 4 | + | //! to `new`'s. Rewritten changes keep their ChangeIds. |
|
| 5 | + | //! |
|
| 6 | + | //! Where a reference has no counterpart it is reified rather than refused: |
|
| 7 | + | //! an insert anchor moves to the nearest surviving atom of the same insert |
|
| 8 | + | //! chain in `old`, else to that chain's own anchors (an order conflict then |
|
| 9 | + | //! arises naturally if `new` placed other content there); deletions of |
|
| 10 | + | //! vanished atoms are dropped; operations and edits on nodes that vanished |
|
| 11 | + | //! are dropped. Every such adjustment is reported as a note. |
|
| 7 | 12 | ||
| 8 | 13 | use crate::hash::ChangeHash; |
|
| 9 | 14 | use crate::objects::{AtomRange, AtomRef, Change, Edit, OpRef, TreeOp}; |
|
| … | 37 unchanged lines |
||
| 47 | 52 | ||
| 48 | 53 | #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] |
|
| 49 | 54 | pub enum RewriteError { |
|
| 50 | - | #[error("change {change} references {what} of the rewritten change, which no longer exists")] |
|
| 51 | - | Orphaned { change: ChangeHash, what: String }, |
|
| 52 | 55 | #[error("change {0} is not in the state")] |
|
| 53 | 56 | Missing(ChangeHash), |
|
| 54 | 57 | } |
|
| 55 | 58 | ||
| 59 | + | /// The result of propagating a rewrite. |
|
| 60 | + | #[derive(Clone, Debug, Default)] |
|
| 61 | + | pub struct Propagation { |
|
| 62 | + | /// `(old hash, rewritten change)` in dependency order. |
|
| 63 | + | pub changes: Vec<(ChangeHash, Change)>, |
|
| 64 | + | /// Human-readable descriptions of reified orphaned references. |
|
| 65 | + | pub notes: Vec<String>, |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | /// Where each atom of a change was inserted: the chain it belongs to. |
|
| 69 | + | struct Chain { |
|
| 70 | + | first: u32, |
|
| 71 | + | last: u32, |
|
| 72 | + | after: AtomRef, |
|
| 73 | + | before: AtomRef, |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | fn chains_of(c: &Change) -> Vec<Chain> { |
|
| 77 | + | let mut per_node: HashMap<crate::ids::NodeId, u32> = HashMap::new(); |
|
| 78 | + | let mut out = Vec::new(); |
|
| 79 | + | for e in &c.edits { |
|
| 80 | + | if let Edit::Insert { node, after, before, lines } = e { |
|
| 81 | + | let idx = per_node.entry(*node).or_default(); |
|
| 82 | + | out.push(Chain { first: *idx, last: *idx + lines.len() as u32 - 1, after: *after, before: *before }); |
|
| 83 | + | *idx += lines.len() as u32; |
|
| 84 | + | } |
|
| 85 | + | } |
|
| 86 | + | out |
|
| 87 | + | } |
|
| 88 | + | ||
| 56 | 89 | /// What a rewritten change was replaced by. |
|
| 57 | 90 | #[derive(Clone, Debug)] |
|
| 58 | 91 | enum Replacement { |
|
| … | 4 unchanged lines |
||
| 63 | 96 | ||
| 64 | 97 | /// Rewrite every change in `state` that depends on `old`, given `new` (or |
|
| 65 | 98 | /// `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. |
|
| 99 | + | /// The result lists `(old hash, rewritten change)` pairs in dependency |
|
| 100 | + | /// order, not including `old` → `new` itself, plus notes about reified |
|
| 101 | + | /// orphaned references. |
|
| 68 | 102 | /// |
|
| 69 | 103 | /// `load` fetches a change's full object (a state keeps only metadata). |
|
| 70 | - | pub fn propagate(state: &State, old: ChangeHash, new: Option<&Change>, corr: &Correspondence, load: &dyn Fn(&ChangeHash) -> Option<Change>) -> Result<Vec<(ChangeHash, Change)>, RewriteError> { |
|
| 71 | - | let old_change = state.get(&old).ok_or(RewriteError::Missing(old))?; |
|
| 104 | + | pub fn propagate(state: &State, old: ChangeHash, new: Option<&Change>, corr: &Correspondence, load: &dyn Fn(&ChangeHash) -> Option<Change>) -> Result<Propagation, RewriteError> { |
|
| 105 | + | let old_meta = state.get(&old).ok_or(RewriteError::Missing(old))?; |
|
| 106 | + | let old_full = load(&old); |
|
| 72 | 107 | let mut mapping: HashMap<ChangeHash, Replacement> = HashMap::new(); |
|
| 73 | 108 | mapping.insert(old, match new { |
|
| 74 | 109 | Some(n) => Replacement::Change(n.hash()), |
|
| 75 | - | None => Replacement::Removed(old_change.deps.clone()), |
|
| 110 | + | None => Replacement::Removed(old_meta.deps.clone()), |
|
| 76 | 111 | }); |
|
| 77 | 112 | let mut corrs: HashMap<ChangeHash, Correspondence> = HashMap::new(); |
|
| 78 | 113 | corrs.insert(old, corr.clone()); |
|
| 114 | + | // Chains of the rewritten change, for re-anchoring; vanished nodes. |
|
| 115 | + | let chains: HashMap<ChangeHash, Vec<Chain>> = old_full.as_ref().map(|c| (old, chains_of(c))).into_iter().collect(); |
|
| 116 | + | let mut vanished_nodes: BTreeSet<crate::ids::NodeId> = BTreeSet::new(); |
|
| 117 | + | if let Some(c) = &old_full { |
|
| 118 | + | for (i, op) in c.tree_ops.iter().enumerate() { |
|
| 119 | + | if let TreeOp::AddNode { node, .. } = op { |
|
| 120 | + | let survives = new.is_some() && corr.ops.contains_key(&(i as u32)); |
|
| 121 | + | if !survives { |
|
| 122 | + | vanished_nodes.insert(*node); |
|
| 123 | + | } |
|
| 124 | + | } |
|
| 125 | + | } |
|
| 126 | + | } |
|
| 127 | + | let mut notes = Vec::new(); |
|
| 79 | 128 | ||
| 80 | 129 | // Dependents in topological order: every change whose closure has `old`, |
|
| 81 | 130 | // sorted so that dependencies come first. |
|
| … | 3 unchanged lines |
||
| 85 | 134 | let mut out = Vec::new(); |
|
| 86 | 135 | for h in dependents { |
|
| 87 | 136 | let c = load(&h).ok_or(RewriteError::Missing(h))?; |
|
| 88 | - | let rewritten = rewrite_one(&h, &c, &mapping, &corrs)?; |
|
| 137 | + | let rewritten = rewrite_one(&h, &c, &mapping, &corrs, &chains, &vanished_nodes, &mut notes); |
|
| 89 | 138 | let rh = rewritten.hash(); |
|
| 90 | 139 | corrs.insert(h, Correspondence::identity(&rewritten)); |
|
| 91 | 140 | mapping.insert(h, Replacement::Change(rh)); |
|
| 92 | 141 | out.push((h, rewritten)); |
|
| 93 | 142 | } |
|
| 94 | - | Ok(out) |
|
| 143 | + | Ok(Propagation { changes: out, notes }) |
|
| 95 | 144 | } |
|
| 96 | 145 | ||
| 97 | - | fn rewrite_one(hash: &ChangeHash, c: &Change, mapping: &HashMap<ChangeHash, Replacement>, corrs: &HashMap<ChangeHash, Correspondence>) -> Result<Change, RewriteError> { |
|
| 98 | - | let orphan = |what: String| RewriteError::Orphaned { change: *hash, what }; |
|
| 99 | - | let target = |change: &ChangeHash, what: &str| -> Result<Option<ChangeHash>, RewriteError> { |
|
| 100 | - | match mapping.get(change) { |
|
| 101 | - | Some(Replacement::Change(nh)) => Ok(Some(*nh)), |
|
| 102 | - | Some(Replacement::Removed(_)) => Err(orphan(what.to_string())), |
|
| 103 | - | None => Ok(None), |
|
| 146 | + | /// Re-anchor a reference to a vanished atom of `change`: the nearest |
|
| 147 | + | /// surviving atom of its chain (before it for `after` anchors, after it for |
|
| 148 | + | /// `before` anchors), else the chain's own anchor. |
|
| 149 | + | fn reanchor(change: &ChangeHash, index: u32, want_after: bool, mapping: &HashMap<ChangeHash, Replacement>, corrs: &HashMap<ChangeHash, Correspondence>, chains: &HashMap<ChangeHash, Vec<Chain>>) -> AtomRef { |
|
| 150 | + | let corr = corrs.get(change); |
|
| 151 | + | let chain = chains.get(change).and_then(|cs| cs.iter().find(|ch| ch.first <= index && index <= ch.last)); |
|
| 152 | + | if let (Some(Replacement::Change(nh)), Some(corr), Some(ch)) = (mapping.get(change), corr, chain) { |
|
| 153 | + | let candidates: Box<dyn Iterator<Item = u32>> = if want_after { Box::new((ch.first..index).rev()) } else { Box::new(index + 1..=ch.last) }; |
|
| 154 | + | for j in candidates { |
|
| 155 | + | if let Some(nj) = corr.atoms.get(&j) { |
|
| 156 | + | return AtomRef::Atom { change: *nh, index: *nj }; |
|
| 157 | + | } |
|
| 104 | 158 | } |
|
| 105 | - | }; |
|
| 106 | - | let map_atom = |r: AtomRef| -> Result<AtomRef, RewriteError> { |
|
| 159 | + | } |
|
| 160 | + | match chain { |
|
| 161 | + | Some(ch) => if want_after { ch.after } else { ch.before }, |
|
| 162 | + | None => if want_after { AtomRef::Start } else { AtomRef::End }, |
|
| 163 | + | } |
|
| 164 | + | } |
|
| 165 | + | ||
| 166 | + | fn rewrite_one( |
|
| 167 | + | hash: &ChangeHash, |
|
| 168 | + | c: &Change, |
|
| 169 | + | mapping: &HashMap<ChangeHash, Replacement>, |
|
| 170 | + | corrs: &HashMap<ChangeHash, Correspondence>, |
|
| 171 | + | chains: &HashMap<ChangeHash, Vec<Chain>>, |
|
| 172 | + | vanished_nodes: &BTreeSet<crate::ids::NodeId>, |
|
| 173 | + | notes: &mut Vec<String>, |
|
| 174 | + | ) -> Change { |
|
| 175 | + | let short = hash.short(12); |
|
| 176 | + | // An anchor: translate, or re-anchor when its counterpart is gone. |
|
| 177 | + | let map_anchor = |r: AtomRef, want_after: bool, notes: &mut Vec<String>| -> AtomRef { |
|
| 107 | 178 | match r { |
|
| 108 | - | AtomRef::Atom { change, index } => match target(&change, &format!("atom {index}"))? { |
|
| 109 | - | Some(nh) => { |
|
| 110 | - | let idx = corrs[&change].atoms.get(&index).ok_or_else(|| orphan(format!("atom {index}")))?; |
|
| 111 | - | Ok(AtomRef::Atom { change: nh, index: *idx }) |
|
| 179 | + | AtomRef::Atom { change, index } => match mapping.get(&change) { |
|
| 180 | + | Some(Replacement::Change(nh)) => match corrs[&change].atoms.get(&index) { |
|
| 181 | + | Some(idx) => AtomRef::Atom { change: *nh, index: *idx }, |
|
| 182 | + | None => { |
|
| 183 | + | notes.push(format!("change {short}: an anchor line of the rewritten change vanished; re-anchored to its nearest neighbour")); |
|
| 184 | + | reanchor(&change, index, want_after, mapping, corrs, chains) |
|
| 185 | + | } |
|
| 186 | + | }, |
|
| 187 | + | Some(Replacement::Removed(_)) => { |
|
| 188 | + | notes.push(format!("change {short}: an anchor line of the removed change vanished; re-anchored to its neighbours")); |
|
| 189 | + | reanchor(&change, index, want_after, mapping, corrs, chains) |
|
| 112 | 190 | } |
|
| 113 | - | None => Ok(r), |
|
| 191 | + | None => r, |
|
| 114 | 192 | }, |
|
| 115 | - | other => Ok(other), |
|
| 193 | + | other => other, |
|
| 116 | 194 | } |
|
| 117 | 195 | }; |
|
| 118 | - | let map_op = |r: OpRef| -> Result<OpRef, RewriteError> { |
|
| 119 | - | match target(&r.change, &format!("tree operation {}", r.index))? { |
|
| 120 | - | Some(nh) => { |
|
| 121 | - | let idx = corrs[&r.change].ops.get(&r.index).ok_or_else(|| orphan(format!("tree operation {}", r.index)))?; |
|
| 122 | - | Ok(OpRef { change: nh, index: *idx }) |
|
| 123 | - | } |
|
| 124 | - | None => Ok(r), |
|
| 196 | + | // A tree-operation reference: translate, or `None` when gone. |
|
| 197 | + | let map_op = |r: OpRef| -> Option<OpRef> { |
|
| 198 | + | match mapping.get(&r.change) { |
|
| 199 | + | Some(Replacement::Change(nh)) => corrs[&r.change].ops.get(&r.index).map(|idx| OpRef { change: *nh, index: *idx }), |
|
| 200 | + | Some(Replacement::Removed(_)) => None, |
|
| 201 | + | None => Some(r), |
|
| 125 | 202 | } |
|
| 126 | 203 | }; |
|
| 127 | 204 | let mut out = c.clone(); |
|
| … | 10 unchanged lines |
||
| 138 | 215 | } |
|
| 139 | 216 | } |
|
| 140 | 217 | out.deps = deps.into_iter().collect(); |
|
| 141 | - | for op in &mut out.tree_ops { |
|
| 218 | + | let mut ops = Vec::new(); |
|
| 219 | + | for op in &out.tree_ops { |
|
| 220 | + | let node = op.node(); |
|
| 221 | + | if vanished_nodes.contains(&node) || matches!(op, TreeOp::AddNode { parent, .. } if vanished_nodes.contains(parent)) { |
|
| 222 | + | notes.push(format!("change {short}: an operation on a file that vanished with the rewritten change was dropped")); |
|
| 223 | + | continue; |
|
| 224 | + | } |
|
| 142 | 225 | match op { |
|
| 143 | - | TreeOp::RemoveNode { placement, .. } => *placement = map_op(*placement)?, |
|
| 144 | - | TreeOp::Move { old, .. } => *old = map_op(*old)?, |
|
| 145 | - | TreeOp::SetMode { old, .. } => *old = map_op(*old)?, |
|
| 146 | - | TreeOp::AddNode { .. } => {} |
|
| 226 | + | TreeOp::RemoveNode { node, placement } => match map_op(*placement) { |
|
| 227 | + | Some(p) => ops.push(TreeOp::RemoveNode { node: *node, placement: p }), |
|
| 228 | + | None => notes.push(format!("change {short}: removal of a placement that vanished was dropped")), |
|
| 229 | + | }, |
|
| 230 | + | TreeOp::Move { node, old, parent, name } => match map_op(*old) { |
|
| 231 | + | Some(o) => ops.push(TreeOp::Move { node: *node, old: o, parent: *parent, name: name.clone() }), |
|
| 232 | + | None => notes.push(format!("change {short}: a move of {name} lost its origin and was dropped")), |
|
| 233 | + | }, |
|
| 234 | + | TreeOp::SetMode { node, old, mode } => match map_op(*old) { |
|
| 235 | + | Some(o) => ops.push(TreeOp::SetMode { node: *node, old: o, mode: *mode }), |
|
| 236 | + | None => notes.push(format!("change {short}: a mode change lost its origin and was dropped")), |
|
| 237 | + | }, |
|
| 238 | + | add @ TreeOp::AddNode { .. } => ops.push(add.clone()), |
|
| 147 | 239 | } |
|
| 148 | 240 | } |
|
| 149 | - | for e in &mut out.edits { |
|
| 241 | + | out.tree_ops = ops; |
|
| 242 | + | let mut edits = Vec::new(); |
|
| 243 | + | for e in &out.edits { |
|
| 244 | + | if vanished_nodes.contains(&e.node()) { |
|
| 245 | + | notes.push(format!("change {short}: an edit of a file that vanished with the rewritten change was dropped")); |
|
| 246 | + | continue; |
|
| 247 | + | } |
|
| 150 | 248 | match e { |
|
| 151 | - | Edit::Insert { after, before, .. } => { |
|
| 152 | - | *after = map_atom(*after)?; |
|
| 153 | - | *before = map_atom(*before)?; |
|
| 154 | - | } |
|
| 155 | - | Edit::Delete { atoms, .. } => { |
|
| 249 | + | Edit::Insert { node, after, before, lines } => edits.push(Edit::Insert { |
|
| 250 | + | node: *node, |
|
| 251 | + | after: map_anchor(*after, true, notes), |
|
| 252 | + | before: map_anchor(*before, false, notes), |
|
| 253 | + | lines: lines.clone(), |
|
| 254 | + | }), |
|
| 255 | + | Edit::Delete { node, atoms } => { |
|
| 156 | 256 | let mut mapped: Vec<(ChangeHash, u32)> = Vec::new(); |
|
| 257 | + | let mut dropped = 0; |
|
| 157 | 258 | for r in atoms.iter() { |
|
| 158 | 259 | for i in r.from..r.to { |
|
| 159 | - | match map_atom(AtomRef::Atom { change: r.change, index: i })? { |
|
| 160 | - | AtomRef::Atom { change, index } => mapped.push((change, index)), |
|
| 161 | - | _ => unreachable!(), |
|
| 260 | + | match mapping.get(&r.change) { |
|
| 261 | + | Some(Replacement::Change(nh)) => match corrs[&r.change].atoms.get(&i) { |
|
| 262 | + | Some(idx) => mapped.push((*nh, *idx)), |
|
| 263 | + | None => dropped += 1, |
|
| 264 | + | }, |
|
| 265 | + | Some(Replacement::Removed(_)) => dropped += 1, |
|
| 266 | + | None => mapped.push((r.change, i)), |
|
| 162 | 267 | } |
|
| 163 | 268 | } |
|
| 164 | 269 | } |
|
| 270 | + | if dropped > 0 { |
|
| 271 | + | notes.push(format!("change {short}: {dropped} deletion(s) of lines that vanished were dropped")); |
|
| 272 | + | } |
|
| 165 | 273 | let mut ranges: Vec<AtomRange> = Vec::new(); |
|
| 166 | 274 | for (ch, i) in mapped { |
|
| 167 | 275 | match ranges.last_mut() { |
|
| … | 1 unchanged lines |
||
| 169 | 277 | _ => ranges.push(AtomRange { change: ch, from: i, to: i + 1 }), |
|
| 170 | 278 | } |
|
| 171 | 279 | } |
|
| 172 | - | *atoms = ranges; |
|
| 280 | + | if !ranges.is_empty() { |
|
| 281 | + | edits.push(Edit::Delete { node: *node, atoms: ranges }); |
|
| 282 | + | } |
|
| 173 | 283 | } |
|
| 174 | - | Edit::Replace { .. } => {} |
|
| 284 | + | other => edits.push(other.clone()), |
|
| 175 | 285 | } |
|
| 176 | 286 | } |
|
| 287 | + | out.edits = edits; |
|
| 177 | 288 | out.normalize(); |
|
| 178 | - | Ok(out) |
|
| 289 | + | out |
|
| 179 | 290 | } |
|
| 180 | 291 | ||
| 181 | 292 | #[cfg(test)] |
|
| … | 27 unchanged lines |
||
| 209 | 320 | n1.message = "one, described".into(); |
|
| 210 | 321 | let all: HashMap<ChangeHash, Change> = [(h1, c1.clone()), (h2, c2.clone()), (h3, c3.clone())].into_iter().collect(); |
|
| 211 | 322 | let load = |h: &ChangeHash| all.get(h).cloned(); |
|
| 212 | - | let rewritten = propagate(&st, h1, Some(&n1), &Correspondence::identity(&c1), &load).unwrap(); |
|
| 323 | + | let rewritten = propagate(&st, h1, Some(&n1), &Correspondence::identity(&c1), &load).unwrap().changes; |
|
| 213 | 324 | assert_eq!(rewritten.iter().map(|(h, _)| *h).collect::<Vec<_>>(), vec![h2, h3]); |
|
| 214 | 325 | let mut st2 = State::new(); |
|
| 215 | 326 | st2.add(n1.clone()).unwrap(); |
|
| … | 16 unchanged lines |
||
| 232 | 343 | } |
|
| 233 | 344 | ||
| 234 | 345 | #[test] |
|
| 235 | - | fn missing_counterpart_is_reported() { |
|
| 346 | + | fn missing_counterpart_is_reanchored() { |
|
| 236 | 347 | let mut st = State::new(); |
|
| 237 | 348 | let f = NodeId::random(); |
|
| 238 | 349 | 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"); |
|
| … | 2 unchanged lines |
||
| 241 | 352 | let h2 = st.add(c2.clone()).unwrap(); |
|
| 242 | 353 | let all: HashMap<ChangeHash, Change> = [(h1, c1.clone()), (h2, c2)].into_iter().collect(); |
|
| 243 | 354 | let load = |h: &ChangeHash| all.get(h).cloned(); |
|
| 244 | - | // The rewritten c1 drops line "b": atom 1 has no counterpart. |
|
| 355 | + | // The rewritten c1 drops line "b": atom 1 has no counterpart, so c2's |
|
| 356 | + | // insert after "b" is re-anchored after "a", the nearest survivor. |
|
| 245 | 357 | let mut n1 = c1.clone(); |
|
| 246 | 358 | n1.edits = vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"a\n".to_vec()] }]; |
|
| 247 | 359 | let corr = Correspondence { atoms: [(0, 0)].into_iter().collect(), ops: [(0, 0)].into_iter().collect() }; |
|
| 248 | - | let err = propagate(&st, h1, Some(&n1), &corr, &load).unwrap_err(); |
|
| 249 | - | assert_eq!(err, RewriteError::Orphaned { change: h2, what: "atom 1".into() }); |
|
| 360 | + | let p = propagate(&st, h1, Some(&n1), &corr, &load).unwrap(); |
|
| 361 | + | assert_eq!(p.notes.len(), 1, "{:?}", p.notes); |
|
| 362 | + | let n1h = n1.hash(); |
|
| 363 | + | assert!(matches!(p.changes[0].1.edits[0], Edit::Insert { after: AtomRef::Atom { change, index: 0 }, before: AtomRef::End, .. } if change == n1h)); |
|
| 364 | + | let mut st2 = State::new(); |
|
| 365 | + | st2.add(n1.clone()).unwrap(); |
|
| 366 | + | st2.add(p.changes[0].1.clone()).unwrap(); |
|
| 367 | + | let m = st2.materialize(); |
|
| 368 | + | assert!(!m.is_conflicted()); |
|
| 369 | + | assert!(matches!(&m.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n")); |
|
| 250 | 370 | assert_eq!(propagate(&st, ObjectRef([9; 32]), Some(&n1), &corr, &load).unwrap_err(), RewriteError::Missing(ObjectRef([9; 32]))); |
|
| 371 | + | ||
| 372 | + | // Removing c1 altogether: f vanishes with it, so c2's edit is dropped. |
|
| 373 | + | let p = propagate(&st, h1, None, &Correspondence::default(), &load).unwrap(); |
|
| 374 | + | assert_eq!(p.notes.len(), 1, "{:?}", p.notes); |
|
| 375 | + | assert!(p.changes[0].1.edits.is_empty() && p.changes[0].1.deps.is_empty()); |
|
| 376 | + | ||
| 377 | + | // Rewriting c1 to keep the file but none of its lines: c2's line goes |
|
| 378 | + | // to c1's chain anchors (START, END). |
|
| 379 | + | let mut n2 = c1.clone(); |
|
| 380 | + | n2.edits = vec![]; |
|
| 381 | + | let corr = Correspondence { atoms: HashMap::new(), ops: [(0, 0)].into_iter().collect() }; |
|
| 382 | + | let p = propagate(&st, h1, Some(&n2), &corr, &load).unwrap(); |
|
| 383 | + | assert!(matches!(p.changes[0].1.edits[0], Edit::Insert { after: AtomRef::Start, before: AtomRef::End, .. })); |
|
| 384 | + | let mut st3 = State::new(); |
|
| 385 | + | st3.add(n2.clone()).unwrap(); |
|
| 386 | + | st3.add(p.changes[0].1.clone()).unwrap(); |
|
| 387 | + | let m = st3.materialize(); |
|
| 388 | + | assert!(!m.is_conflicted()); |
|
| 389 | + | assert!(matches!(&m.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"c\n")); |
|
| 251 | 390 | } |
|
| 252 | 391 | ||
| 253 | 392 | #[test] |
|
| … | 12 unchanged lines |
||
| 266 | 405 | let load_all = all.clone(); |
|
| 267 | 406 | let load = move |h: &ChangeHash| load_all.get(h).cloned(); |
|
| 268 | 407 | // Removing c2 (an explicit-only dependency of nothing that references it) works: c3 inherits h1. |
|
| 269 | - | let out = propagate(&st, h2, None, &Correspondence::default(), &load).unwrap(); |
|
| 408 | + | let out = propagate(&st, h2, None, &Correspondence::default(), &load).unwrap().changes; |
|
| 270 | 409 | assert_eq!(out.len(), 1); |
|
| 271 | 410 | assert_eq!(out[0].0, h3); |
|
| 272 | 411 | assert_eq!(out[0].1.deps, vec![h1]); |
|
| 273 | 412 | // Removing c1 fails: c2 does not reference c1 (only an explicit dep), but |
|
| 274 | 413 | // nothing else does either, so it succeeds and c2 inherits no deps. |
|
| 275 | - | let out = propagate(&st, h1, None, &Correspondence::default(), &load).unwrap(); |
|
| 414 | + | let out = propagate(&st, h1, None, &Correspondence::default(), &load).unwrap().changes; |
|
| 276 | 415 | assert!(out.iter().any(|(h, c)| *h == h2 && c.deps.is_empty())); |
|
| 277 | - | // Removing c2 when c3 references its placement fails. |
|
| 416 | + | // Removing c2, which created g: c3's edit of g and c4's removal of g's |
|
| 417 | + | // placement are dropped with notes, since the file no longer exists. |
|
| 278 | 418 | let c4 = change(vec![h2], vec![TreeOp::RemoveNode { node: g, placement: OpRef { change: h2, index: 0 } }], vec![], "four"); |
|
| 279 | 419 | let h4 = st.add(c4.clone()).unwrap(); |
|
| 280 | 420 | all.insert(h4, c4); |
|
| 281 | 421 | let load2 = |h: &ChangeHash| all.get(h).cloned(); |
|
| 282 | - | let err = propagate(&st, h2, None, &Correspondence::default(), &load2).unwrap_err(); |
|
| 283 | - | assert!(matches!(err, RewriteError::Orphaned { change, .. } if change == h4 || change == h3), "{err:?}"); |
|
| 422 | + | let p = propagate(&st, h2, None, &Correspondence::default(), &load2).unwrap(); |
|
| 423 | + | assert_eq!(p.notes.len(), 2, "{:?}", p.notes); |
|
| 424 | + | for (_, c) in &p.changes { |
|
| 425 | + | assert!(c.edits.is_empty() && c.tree_ops.is_empty()); |
|
| 426 | + | } |
|
| 284 | 427 | } |
|
| 285 | 428 | } |
modified docs/spec.md +5 −3
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.6l | |
|
| 5 | + | | **Status** | Draft 0.6m | |
|
| 6 | 6 | | **Format version** | 1 | |
|
| 7 | 7 | | **Date** | 2026-09-26 | |
|
| 8 | 8 | | **Audience** | Implementers, security reviewers, and contributors | |
|
| … | 308 unchanged lines |
||
| 317 | 317 | ||
| 318 | 318 | ### 8.4 Rewriting |
|
| 319 | 319 | ||
| 320 | - | Rewriting a change `C` to `C'` (amend, rebase, split, squash) produces a new object with the same ChangeId. Amending an older change with the working copy's edits (`oxy amend --into C`) is allowed when every atom, placement, and node those edits reference exists in `C`'s closure; the edits are appended to `C` with references to `C` turned into self references, so `C`'s own atoms keep their indices and dependents are rewritten with an identity correspondence. Splitting a head (`oxy split C PATH..`) rebuilds it as a first change holding its effect on the named paths and a second holding the rest under the original ChangeId. Any change `D` with `hash(C) ∈ deps(D)` is orphaned relative to `C'`. `oxy` MUST automatically rewrite orphaned descendants by translating their AtomRefs and PlacementRefs from `C` to `C'` where a correspondence exists, and MUST record a conflict (§9) where it does not. Until the shape of that conflict is specified, an implementation MAY instead refuse the rewrite, naming the orphaned reference. Removing `C` altogether rewires each dependent to depend on `deps(C)`, and is refused where a dependent references `C`'s atoms or placements. Changes that do not depend on `C` are untouched, and a rewritten change by another author is stored without that author's signature, which only they can renew. Rewriting is recorded in the operation log (§12) and is undoable. |
|
| 320 | + | Rewriting a change `C` to `C'` (amend, rebase, split, squash) produces a new object with the same ChangeId. Amending an older change with the working copy's edits (`oxy amend --into C`) is allowed when every atom, placement, and node those edits reference exists in `C`'s closure; the edits are appended to `C` with references to `C` turned into self references, so `C`'s own atoms keep their indices and dependents are rewritten with an identity correspondence. Splitting a head (`oxy split C PATH..`) rebuilds it as a first change holding its effect on the named paths and a second holding the rest under the original ChangeId. Any change `D` with `hash(C) ∈ deps(D)` is orphaned relative to `C'`. `oxy` MUST automatically rewrite orphaned descendants by translating their AtomRefs and PlacementRefs from `C` to `C'` where a correspondence exists. Where none exists the reference is **reified by re-anchoring**: an insert anchored on a vanished atom is anchored instead on the nearest surviving atom of the same insert chain of `C` (the preceding one for `after`, the following one for `before`), or on that chain's own anchors when none survives, so that an order conflict (§9.1) arises exactly when `C'` placed other content there; a deletion of a vanished atom is dropped; an operation or edit on a node that vanished with `C` is dropped. Each adjustment MUST be reported to the user. Removing `C` altogether rewires each dependent to depend on `deps(C)` and reifies references into `C` the same way. Changes that do not depend on `C` are untouched, and a rewritten change by another author is stored without that author's signature, which only they can renew. Rewriting is recorded in the operation log (§12) and is undoable. |
|
| 321 | 321 | ||
| 322 | 322 | ## 9. Conflicts |
|
| 323 | 323 | ||
| … | 518 unchanged lines |
||
| 842 | 842 | ||
| 843 | 843 | --- |
|
| 844 | 844 | ||
| 845 | - | *Changelog.* Draft 0.6l (2026-09-27): amend into older changes and split (§8.4). |
|
| 845 | + | *Changelog.* Draft 0.6m (2026-09-27): orphaned references on rewrite are reified by re-anchoring rather than refused (§8.4). |
|
| 846 | + | ||
| 847 | + | Draft 0.6l (2026-09-27): amend into older changes and split (§8.4). |
|
| 846 | 848 | ||
| 847 | 849 | Draft 0.6k (2026-09-27): hook execution model and JSON payloads (§20). |
|
| 848 | 850 | ||
| … | 37 unchanged lines |
modified src/cli/workspace.rs +4 −1
| … | 264 unchanged lines |
||
| 265 | 265 | let repo = open()?; |
|
| 266 | 266 | let h = repo.resolve_change(change)?; |
|
| 267 | 267 | let c = repo.store().get_change(&h)?; |
|
| 268 | - | repo.abandon(&h)?; |
|
| 268 | + | let notes = repo.abandon(&h)?; |
|
| 269 | 269 | println!("Abandoned {} #{}; its content remains on disk as uncommitted work if the working copy sat on it.", short_id(&c), h.short(12)); |
|
| 270 | + | for n in notes { |
|
| 271 | + | println!(" {} {n}", ui::warn("note:")); |
|
| 272 | + | } |
|
| 270 | 273 | Ok(0) |
|
| 271 | 274 | } |
|
| 272 | 275 | ||
| … | 154 unchanged lines |