OXYGENHUB

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