OXYGENHUB

Rewrite propagation: describe and abandon on any change, dependents rewritten with translated references c07e0e7d2d85

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 19:37 UTC
Signature
✔ signed by Andy McCall
Hash
c07e0e7d2d8537a07ead0a19ffaab76899ddedc1259d19538d9a07443eb0ed73
Depends on
614541c3cebd 982864068a59 ff0af1ee299d
Depended on by
47ce8b8e2d4b 5b4c51307b14 01a47cd7f0c8 57eaac9107df 6caacfff1d4f 6f8aeb996558 75167672ebcd 728da0f92161 6706bdcfb63e 23b217d75848 135212de5370 252e8cb23fbc
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
19 atoms, 1 tree operation, 5 files

Browse the tree at this change

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