OXYGENHUB
# b4fa2bc1ca6a oxygen / crates / oxy-core / src / repo / history.rs
23.1 KiB · Rust · last changed 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) 1 month ago source blame history raw
1
//! History editing (spec §8.4): describe, amend, abandon.
2
//!
3
//! `describe` and `abandon` work on any change of the bookmark: dependents
4
//! are rewritten through `crate::rewrite`, with their references translated
5
//! and orphaned references reified as re-anchoring, reported as notes.
6
//! `amend` works on heads, where the amended change is rebuilt from the files
7
//! on disk. A rewritten change keeps its ChangeId and gets a new hash; old
8
//! objects stay in the store until garbage collection. Rewritten changes by
9
//! other authors lose their signatures, as with any rewrite of others' work.
10
11
use super::*;
12
use crate::objects::{AtomRange, AtomRef};
13
use crate::rewrite::{propagate, Correspondence};
14
15
impl Repo {
16
    /// The bookmark head `hash`, or an error explaining why it cannot be rewritten.
17
    fn rewritable_head(&self, hash: &ChangeHash) -> Result<Change> {
18
        let heads = self.bookmark_heads()?;
19
        if !heads.contains(hash) {
20
            return fail(format!(
21
                "change #{} is not a head of {}; rewriting changes that others depend on is not implemented yet",
22
                hash.short(12),
23
                self.config.bookmark
24
            ));
25
        }
26
        let c = self.store.get_change(hash)?;
27
        if c.kind == ChangeKind::WorkingCopy {
28
            return fail("that is the working copy; commit it instead");
29
        }
30
        Ok(c)
31
    }
32
33
    /// Replace head `old` with `new` (or with nothing) on the bookmark and
34
    /// restart the working copy on the resulting base. Files on disk are not
35
    /// touched: the next snapshot diffs them against the new base.
36
    fn replace_head(&self, old: &ChangeHash, new: Option<&Change>, command: &str) -> Result<Option<ChangeHash>> {
37
        let new_hash = match new {
38
            Some(c) => Some(self.store_signed(c)?),
39
            None => None,
40
        };
41
        let old_change = self.store.get_change(old)?;
42
        let (op_ref, op) = self.head_op()?;
43
        let bookmark = self.config.bookmark.clone();
44
        let heads = self.heads_of(&op.view, &bookmark)?;
45
        let replace = |set: &[ChangeHash]| -> Vec<ChangeHash> {
46
            let mut out: Vec<ChangeHash> = set.iter().filter(|h| *h != old).copied().collect();
47
            match new_hash {
48
                Some(n) => out.push(n),
49
                None => out.extend(old_change.deps.iter().copied()),
50
            }
51
            out
52
        };
53
        let candidates = replace(&heads);
54
        let st = self.load_state(&candidates)?;
55
        let new_heads = st.maximal(&candidates);
56
57
        let (_, wc) = self.working_copy()?;
58
        let base = self.load_state(&wc.deps)?;
59
        let wc_base = base.maximal(&wc.deps);
60
        let wc_base = if wc_base.contains(old) {
61
            let c = replace(&wc_base);
62
            let st = self.load_state(&c)?;
63
            st.maximal(&c)
64
        } else {
65
            wc_base
66
        };
67
        let fresh = self.new_working_copy(wc_base, &wc.message);
68
        let wc_hash = self.store.put_change(&fresh)?;
69
70
        let mut view = op.view.clone();
71
        view.bookmarks.insert(bookmark, self.store.put_state(&StateObject::new(new_heads))?);
72
        view.workspaces.insert(self.workspace, wc_hash);
73
        self.record_op(&[op_ref], command, view)?;
74
        self.drop_manifest();
75
        Ok(new_hash)
76
    }
77
78
    /// Replace `old` by `new` (or remove it) anywhere in the bookmark's state,
79
    /// rewriting every dependent, including the working copy. Returns the
80
    /// new hash of `old`, if any.
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>)> {
87
        let (op_ref, op) = self.head_op()?;
88
        let bookmark = self.config.bookmark.clone();
89
        let heads = self.heads_of(&op.view, &bookmark)?;
90
        let (wc_hash, _) = self.working_copy()?;
91
        let mut all = heads.clone();
92
        all.push(wc_hash);
93
        let state = self.load_state(&all)?;
94
        if !state.closure(&wc_hash).contains(old) && !heads.contains(old) && !state.heads().contains(old) && !state.contains(old) {
95
            return fail(format!("change #{} is not in {} or under the working copy", old.short(12), bookmark));
96
        }
97
        let old_change = self.store.get_change(old)?;
98
        if old_change.kind == ChangeKind::WorkingCopy {
99
            return fail("that is the working copy; commit it instead");
100
        }
101
        let load = |h: &ChangeHash| self.store.get_change(h).ok();
102
        let propagation = propagate(&state, *old, new, corr, &load).map_err(|e| RepoError::Msg(e.to_string()))?;
103
        let rewritten = propagation.changes;
104
105
        let mut mapping: HashMap<ChangeHash, ChangeHash> = HashMap::new();
106
        let new_hash = match new {
107
            Some(c) => {
108
                let h = self.store_signed(c)?;
109
                mapping.insert(*old, h);
110
                Some(h)
111
            }
112
            None => None,
113
        };
114
        for (h, c) in &rewritten {
115
            let nh = if c.kind == ChangeKind::WorkingCopy { self.store.put_change(c)? } else { self.store_signed(c)? };
116
            mapping.insert(*h, nh);
117
        }
118
        let remap = |set: &[ChangeHash]| -> Vec<ChangeHash> {
119
            let mut out = Vec::new();
120
            for h in set {
121
                if h == old && new_hash.is_none() {
122
                    out.extend(old_change.deps.iter().copied());
123
                } else {
124
                    out.push(*mapping.get(h).unwrap_or(h));
125
                }
126
            }
127
            out
128
        };
129
        let candidates = remap(&heads);
130
        let st = self.load_state(&candidates)?;
131
        let new_heads = st.maximal(&candidates);
132
        let new_wc = mapping.get(&wc_hash).copied().unwrap_or(wc_hash);
133
134
        let mut view = op.view.clone();
135
        view.bookmarks.insert(bookmark, self.store.put_state(&StateObject::new(new_heads))?);
136
        view.workspaces.insert(self.workspace, new_wc);
137
        self.record_op(&[op_ref], command, view)?;
138
        self.drop_manifest();
139
        Ok((new_hash, propagation.notes))
140
    }
141
142
    /// Change the message of any change of the bookmark; dependents are rewritten.
143
    pub fn describe(&self, hash: &ChangeHash, message: &str) -> Result<ChangeHash> {
144
        if message.trim().is_empty() {
145
            return fail("a message is required");
146
        }
147
        let mut c = self.store.get_change(hash)?;
148
        if c.kind == ChangeKind::WorkingCopy {
149
            return fail("that is the working copy; commit it instead");
150
        }
151
        if c.message == message.trim_end() {
152
            return Ok(*hash);
153
        }
154
        let corr = Correspondence::identity(&c);
155
        c.message = message.trim_end().to_string();
156
        c.hlc = Hlc::now();
157
        let id = c.change_id;
158
        Ok(self.rewrite_change(hash, Some(&c), &corr, &format!("oxy describe {} -m {message:?}", id.short(8)))?.unwrap())
159
    }
160
161
    /// Fold the working copy into the head it sits on: the head is rebuilt
162
    /// as the difference between the state beneath it and the files on disk.
163
    pub fn amend(&self, message: Option<&str>) -> Result<ChangeHash> {
164
        let snap = self.snapshot("oxy amend")?;
165
        if snap.statuses.iter().any(|s| s.status == ChangeStatus::ConflictEdited) {
166
            return fail("a file still contains conflict markers");
167
        }
168
        let base_heads = snap.base.maximal(&snap.wc.deps);
169
        let [head] = base_heads.as_slice() else {
170
            return fail(format!("the working copy sits on {} heads; check out the single change to amend", base_heads.len()));
171
        };
172
        let head = *head;
173
        let old = self.rewritable_head(&head)?;
174
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() && message.is_none() {
175
            return fail("nothing to amend: the working copy is clean and no new message was given");
176
        }
177
        // Rebuild against the state beneath the head.
178
        let beneath = self.load_state(&old.deps)?;
179
        let beneath_snapshot = beneath.materialize();
180
        let reuse = added_node_paths(&old, &beneath_snapshot);
181
        let built = self.build_against(&beneath, &beneath_snapshot, reuse)?;
182
        if built.tree_ops.is_empty() && built.edits.is_empty() {
183
            return fail("amending would leave the change empty; use `oxy abandon` to drop it");
184
        }
185
        let mut deps: BTreeSet<ChangeHash> = old.deps.iter().copied().collect();
186
        deps.extend(built.contributors.iter().copied());
187
        let mut new = Change {
188
            change_id: old.change_id,
189
            deps: vec![],
190
            author: self.author(),
191
            hlc: Hlc::now(),
192
            message: message.map(|m| m.trim_end().to_string()).unwrap_or(old.message.clone()),
193
            tree_ops: built.tree_ops,
194
            edits: built.edits,
195
            kind: if built.resolves || old.kind == ChangeKind::Resolution { ChangeKind::Resolution } else { ChangeKind::Normal },
196
        };
197
        deps.extend(referenced_changes(&new, &beneath));
198
        new.deps = deps.into_iter().collect();
199
        new.normalize();
200
        Ok(self.replace_head(&head, Some(&new), &format!("oxy amend {}", old.change_id.short(8)))?.unwrap())
201
    }
202
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>> {
208
        let c = self.store.get_change(hash)?;
209
        if c.kind == ChangeKind::WorkingCopy {
210
            return fail("that is the working copy; commit it instead");
211
        }
212
        let heads = self.bookmark_heads()?;
213
        if heads.contains(hash) {
214
            self.replace_head(hash, None, &format!("oxy abandon {}", c.change_id.short(8)))?;
215
            Ok(vec![])
216
        } else {
217
            let (_, notes) = self.rewrite_change_noted(hash, None, &Correspondence::default(), &format!("oxy abandon {}", c.change_id.short(8)))?;
218
            Ok(notes)
219
        }
220
    }
221
}
222
223
/// Translate a reference to change `target`'s own atom or op into a self
224
/// reference, and a working-copy self reference into one offset past the
225
/// target's own count; other references pass through.
226
fn fold_atom(r: AtomRef, target: &ChangeHash, wc_offset: &HashMap<NodeId, u32>, node: &NodeId) -> Option<AtomRef> {
227
    match r {
228
        AtomRef::Atom { change, index } if change == *target => Some(AtomRef::Atom { change: ObjectRef::ZERO, index }),
229
        AtomRef::Atom { change, index } if change.is_zero() => Some(AtomRef::Atom { change: ObjectRef::ZERO, index: index + wc_offset.get(node).copied().unwrap_or(0) }),
230
        // References into the target's closure were validated by the caller and stay as they are.
231
        other => Some(other),
232
    }
233
}
234
235
impl Repo {
236
    /// Fold the working copy's uncommitted edits into any change of the
237
    /// bookmark (spec §8.4). The edits must reference only atoms, placements
238
    /// and nodes that exist in the target's own closure; dependents are
239
    /// rewritten with an identity correspondence, since the target's own
240
    /// atoms keep their indices.
241
    pub fn amend_into(&self, target: &ChangeHash, message: Option<&str>) -> Result<ChangeHash> {
242
        let snap = self.snapshot("oxy amend")?;
243
        if snap.statuses.iter().any(|s| s.status == ChangeStatus::ConflictEdited) {
244
            return fail("a file still contains conflict markers");
245
        }
246
        let wc = &snap.wc;
247
        if wc.tree_ops.is_empty() && wc.edits.is_empty() && message.is_none() {
248
            return fail("nothing to amend: the working copy is clean and no new message was given");
249
        }
250
        let old = self.store.get_change(target)?;
251
        if old.kind == ChangeKind::WorkingCopy {
252
            return fail("that is the working copy; commit it instead");
253
        }
254
        // Every reference must live in the target's closure (or be the target).
255
        let closure = self.load_state(&[*target])?;
256
        let ok_change = |h: &ChangeHash| *h == *target || closure.contains(h);
257
        let ok_node = |n: &NodeId| n.is_root() || closure.tree().introducer(n).is_some();
258
        let too_new = |what: &str| RepoError::Msg(format!("{what} was introduced after change #{}; amend into a later change instead", target.short(12)));
259
        for op in &wc.tree_ops {
260
            match op {
261
                TreeOp::AddNode { parent, .. } => {
262
                    if !ok_node(parent) {
263
                        return Err(too_new("the parent directory"));
264
                    }
265
                }
266
                TreeOp::RemoveNode { node, placement } => {
267
                    if !ok_node(node) || !ok_change(&placement.change) {
268
                        return Err(too_new("a removed file"));
269
                    }
270
                }
271
                TreeOp::Move { node, old, parent, .. } => {
272
                    if !ok_node(node) || !ok_change(&old.change) || !ok_node(parent) {
273
                        return Err(too_new("a moved file"));
274
                    }
275
                }
276
                TreeOp::SetMode { node, old, .. } => {
277
                    if !ok_node(node) || !ok_change(&old.change) {
278
                        return Err(too_new("a file whose mode changed"));
279
                    }
280
                }
281
            }
282
        }
283
        let wc_added: BTreeSet<NodeId> = wc.tree_ops.iter().filter_map(|o| if let TreeOp::AddNode { node, .. } = o { Some(*node) } else { None }).collect();
284
        for e in &wc.edits {
285
            let n = e.node();
286
            if !wc_added.contains(&n) && !ok_node(&n) {
287
                return Err(too_new("an edited file"));
288
            }
289
            let refs: Vec<ChangeHash> = match e {
290
                Edit::Insert { after, before, .. } => [after, before].iter().filter_map(|r| if let AtomRef::Atom { change, .. } = r { Some(*change) } else { None }).collect(),
291
                Edit::Delete { atoms, .. } => atoms.iter().map(|r| r.change).collect(),
292
                Edit::Replace { .. } => vec![],
293
            };
294
            if refs.iter().any(|h| !h.is_zero() && !ok_change(h)) {
295
                return Err(too_new("an edited line"));
296
            }
297
        }
298
299
        // Compose: the target's operations first, then the working copy's
300
        // with references to the target turned into self references and its
301
        // own atom indices shifted past the target's.
302
        let mut per_node: HashMap<NodeId, u32> = HashMap::new();
303
        for e in &old.edits {
304
            if let Edit::Insert { node, lines, .. } = e {
305
                *per_node.entry(*node).or_default() += lines.len() as u32;
306
            }
307
        }
308
        let op_offset = old.tree_ops.len() as u32;
309
        let fold_op = |r: OpRef| -> OpRef {
310
            if r.change == *target {
311
                OpRef { change: ObjectRef::ZERO, index: r.index }
312
            } else if r.change.is_zero() {
313
                OpRef { change: ObjectRef::ZERO, index: r.index + op_offset }
314
            } else {
315
                r
316
            }
317
        };
318
        let mut tree_ops = old.tree_ops.clone();
319
        for op in &wc.tree_ops {
320
            tree_ops.push(match op {
321
                TreeOp::RemoveNode { node, placement } => TreeOp::RemoveNode { node: *node, placement: fold_op(*placement) },
322
                TreeOp::Move { node, old, parent, name } => TreeOp::Move { node: *node, old: fold_op(*old), parent: *parent, name: name.clone() },
323
                TreeOp::SetMode { node, old, mode } => TreeOp::SetMode { node: *node, old: fold_op(*old), mode: *mode },
324
                add => add.clone(),
325
            });
326
        }
327
        let mut edits = old.edits.clone();
328
        for e in &wc.edits {
329
            edits.push(match e {
330
                Edit::Insert { node, after, before, lines } => Edit::Insert {
331
                    node: *node,
332
                    after: fold_atom(*after, target, &per_node, node).ok_or_else(|| too_new("an anchor line"))?,
333
                    before: fold_atom(*before, target, &per_node, node).ok_or_else(|| too_new("an anchor line"))?,
334
                    lines: lines.clone(),
335
                },
336
                Edit::Delete { node, atoms } => Edit::Delete {
337
                    node: *node,
338
                    atoms: atoms
339
                        .iter()
340
                        .map(|r| {
341
                            if r.change == *target {
342
                                AtomRange { change: ObjectRef::ZERO, from: r.from, to: r.to }
343
                            } else if r.change.is_zero() {
344
                                let off = per_node.get(node).copied().unwrap_or(0);
345
                                AtomRange { change: ObjectRef::ZERO, from: r.from + off, to: r.to + off }
346
                            } else {
347
                                *r
348
                            }
349
                        })
350
                        .collect(),
351
                },
352
                other => other.clone(),
353
            });
354
        }
355
        let mut new = Change {
356
            change_id: old.change_id,
357
            deps: vec![],
358
            author: self.author(),
359
            hlc: Hlc::now(),
360
            message: message.map(|m| m.trim_end().to_string()).unwrap_or(old.message.clone()),
361
            tree_ops,
362
            edits,
363
            kind: old.kind,
364
        };
365
        let beneath = self.load_state(&old.deps)?;
366
        let mut deps: BTreeSet<ChangeHash> = old.deps.iter().copied().collect();
367
        deps.extend(referenced_changes(&new, &beneath).into_iter().filter(|h| *h != *target));
368
        deps.extend(wc.deps.iter().filter(|d| closure.contains(d) && **d != *target).copied());
369
        new.deps = deps.into_iter().collect();
370
        new.normalize();
371
372
        // Rewrite dependents (identity correspondence), then start a fresh,
373
        // empty working copy: its edits now live in the amended change.
374
        let corr = Correspondence::identity(&old);
375
        let new_hash = self.rewrite_change(target, Some(&new), &corr, &format!("oxy amend --into {}", old.change_id.short(8)))?.unwrap();
376
        let (op_ref, op) = self.head_op()?;
377
        let heads = self.heads_of(&op.view, &self.config.bookmark)?;
378
        let fresh = self.new_working_copy(heads, "");
379
        let wc_hash = self.store.put_change(&fresh)?;
380
        let mut view = op.view.clone();
381
        view.workspaces.insert(self.workspace, wc_hash);
382
        self.record_op(&[op_ref], "oxy amend (working copy)", view)?;
383
        self.drop_manifest();
384
        Ok(new_hash)
385
    }
386
387
    /// Split a head into two changes: one holding the head's effect on
388
    /// `paths`, the other the rest (keeping the head's ChangeId). The
389
    /// working copy is unaffected.
390
    pub fn split(&self, hash: &ChangeHash, paths: &[String]) -> Result<(ChangeHash, ChangeHash)> {
391
        let old = self.rewritable_head(hash)?;
392
        if paths.is_empty() {
393
            return fail("name at least one path to split out");
394
        }
395
        let beneath = self.load_state(&old.deps)?;
396
        let beneath_snapshot = beneath.materialize();
397
        let full = self.load_state(&[*hash])?;
398
        let full_snapshot = full.materialize();
399
        let selected = |p: &str| paths.iter().any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))));
400
        // Entries of the intermediate state: selected paths as the head
401
        // leaves them, everything else as beneath.
402
        let entries_of = |snap: &Snapshot, keep_from_full: &dyn Fn(&str) -> bool| -> Result<BTreeMap<String, FsEntry>> {
403
            let mut out = BTreeMap::new();
404
            for (p, _) in snap.files.iter().chain(full_snapshot.files.iter()) {
405
                let take_full = keep_from_full(p);
406
                let src = if take_full { full_snapshot.files.get(p) } else { snap.files.get(p) };
407
                let Some(src) = src else { continue };
408
                if out.contains_key(p) {
409
                    continue;
410
                }
411
                let content = match &src.content {
412
                    FileContent::Dir => continue,
413
                    FileContent::Text { bytes, .. } => bytes.clone(),
414
                    FileContent::Opaque { blobs } => match blobs.first() {
415
                        Some(b) => self.read_blob(b)?,
416
                        None => continue,
417
                    },
418
                };
419
                out.insert(p.clone(), FsEntry { kind: src.kind, exec: src.mode.exec, content });
420
            }
421
            Ok(out)
422
        };
423
        let mid_entries = entries_of(&beneath_snapshot, &|p| selected(p))?;
424
        let first = self.build_from_entries(&beneath, &beneath_snapshot, HashMap::new(), &mid_entries)?;
425
        if first.tree_ops.is_empty() && first.edits.is_empty() {
426
            return fail("the named paths are not touched by that change");
427
        }
428
        let mut c1 = Change {
429
            change_id: ChangeId::random(),
430
            deps: old.deps.clone(),
431
            author: old.author,
432
            hlc: Hlc::now(),
433
            message: format!("{} (part 1: {})", old.message.lines().next().unwrap_or(""), paths.join(", ")),
434
            tree_ops: first.tree_ops,
435
            edits: first.edits,
436
            kind: ChangeKind::Normal,
437
        };
438
        let mut d1: BTreeSet<ChangeHash> = referenced_changes(&c1, &beneath);
439
        d1.extend(old.deps.iter().copied());
440
        c1.deps = d1.into_iter().collect();
441
        c1.normalize();
442
        let h1 = self.store_signed(&c1)?;
443
        let mut mid = beneath.clone();
444
        mid.add(c1)?;
445
        let mid_snapshot = mid.materialize();
446
        let rest_entries = entries_of(&full_snapshot, &|_| true)?;
447
        let rest = self.build_from_entries(&mid, &mid_snapshot, HashMap::new(), &rest_entries)?;
448
        if rest.tree_ops.is_empty() && rest.edits.is_empty() {
449
            return fail("everything in that change is under the named paths; nothing would remain");
450
        }
451
        let mut c2 = Change {
452
            change_id: old.change_id,
453
            deps: vec![],
454
            author: old.author,
455
            hlc: Hlc::now(),
456
            message: old.message.clone(),
457
            tree_ops: rest.tree_ops,
458
            edits: rest.edits,
459
            kind: ChangeKind::Normal,
460
        };
461
        let mut d2: BTreeSet<ChangeHash> = referenced_changes(&c2, &mid);
462
        d2.extend(old.deps.iter().copied());
463
        c2.deps = d2.into_iter().collect();
464
        c2.normalize();
465
        let h2 = self.replace_head(hash, Some(&c2), &format!("oxy split {} {}", old.change_id.short(8), paths.join(" ")))?.unwrap();
466
        // c1 may be a head of its own if c2 does not depend on it.
467
        let (op_ref, op) = self.head_op()?;
468
        let bookmark = self.config.bookmark.clone();
469
        let mut heads = self.heads_of(&op.view, &bookmark)?;
470
        heads.push(h1);
471
        let st = self.load_state(&heads)?;
472
        let heads = st.maximal(&heads);
473
        let mut view = op.view.clone();
474
        view.bookmarks.insert(bookmark, self.store.put_state(&StateObject::new(heads.clone()))?);
475
        let (_, wc) = self.working_copy()?;
476
        let mut base = wc.deps.clone();
477
        base.push(h1);
478
        let wc_base = self.load_state(&base)?.maximal(&base);
479
        let fresh = self.new_working_copy(wc_base, &wc.message);
480
        view.workspaces.insert(self.workspace, self.store.put_change(&fresh)?);
481
        self.record_op(&[op_ref], "oxy split (heads)", view)?;
482
        self.drop_manifest();
483
        Ok((h1, h2))
484
    }
485
}