OXYGENHUB

History editing: oxy amend --into folds the working copy into an older change with dependents rewritten; oxy split; graph: delete/insert conflicts only between commuting changes (spec 0.6l) 23b217d75848

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 23:00 UTC
Signature
✔ signed by Andy McCall
Hash
23b217d75848570eb0753a85385da746cc57077444160127927890fec64b1607
Depends on
614541c3cebd 6d3eefbce2f0 982864068a59 c07e0e7d2d85 cdb5beba7f05 f77698833f9f fce44ae9ad29 ff0af1ee299d
Depended on by
69953b507188 530a0fc650a2 2c9ddac3c387 631fc8970bd0 57eaac9107df 6caacfff1d4f 6f8aeb996558 6a81dbab471d d42369abf180 75167672ebcd 728da0f92161 7b9809e67c3d b837d2b55853 b7f102fcbb17 6706bdcfb63e 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
20 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/oxy-core/src/graph.rs +29 −3
…
139 unchanged lines
140 140
    }
141 141
142 142
    fn is_ghost(&self, k: &AtomKey, info: &AtomInfo, deps: &dyn DepOracle) -> bool {
143 -
        // A deleted atom is a ghost when a live direct successor was inserted by
144 -
        // a change that no deleter depends on (spec §9.1, delete/insert).
143 +
        // A deleted atom is a ghost when a live direct successor was inserted
144 +
        // by a change that commutes with every deleter: neither depends on
145 +
        // the other (spec §9.1, delete/insert). If the inserter came after the
146 +
        // deletion, or the deleter saw the insertion, there is no conflict.
145 147
        self.succ.get(&Vertex::Atom(*k)).is_some_and(|ss| {
146 148
            ss.iter().any(|s| match s {
147 -
                Vertex::Atom(y) if self.is_live(y) => !info.deleted_by.iter().any(|d| deps.depends_on(d, &y.0)),
149 +
                Vertex::Atom(y) if self.is_live(y) => !info.deleted_by.iter().any(|d| deps.depends_on(d, &y.0) || deps.depends_on(&y.0, d)),
148 150
                _ => false,
149 151
            })
150 152
        })
…
443 unchanged lines
594 596
    }
595 597
596 598
    #[test]
599 +
    fn insert_after_a_line_deleted_by_a_dependency_is_not_a_conflict() {
600 +
        // 1 inserts a,b; 2 deletes b (depends on 1); 3 depends on 2 yet anchors
601 +
        // after the dead b (a rewritten change may do this): no conflict.
602 +
        let mut g = FileGraph::new();
603 +
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n"]));
604 +
        g.delete(h(2), &AtomRange { change: h(1), from: 1, to: 2 });
605 +
        g.insert(h(3), 0, Vertex::Atom((h(1), 1)), Vertex::End, &lines(&["c\n"]));
606 +
        let deps = Deps([(h(2), vec![h(1)]), (h(3), vec![h(2)])].into_iter().collect());
607 +
        let m = g.materialize(&deps);
608 +
        assert!(!m.is_conflicted(), "{:?}", m.segments);
609 +
        assert_eq!(text(&g, &m), "a\nc\n");
610 +
        // A change that deletes a line it introduced itself, with a dependent
611 +
        // anchoring after it, is likewise fine.
612 +
        let mut g = FileGraph::new();
613 +
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["x\n"]));
614 +
        g.delete(h(1), &AtomRange { change: h(1), from: 0, to: 1 });
615 +
        g.insert(h(2), 0, Vertex::Atom((h(1), 0)), Vertex::End, &lines(&["y\n"]));
616 +
        let deps = Deps([(h(2), vec![h(1)])].into_iter().collect());
617 +
        let m = g.materialize(&deps);
618 +
        assert!(!m.is_conflicted());
619 +
        assert_eq!(text(&g, &m), "y\n");
620 +
    }
621 +
622 +
    #[test]
597 623
    fn resolution_collapses_a_conflict() {
598 624
        let mut g = FileGraph::new();
599 625
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n"]));
…
77 unchanged lines
modified crates/oxy-core/src/repo/history.rs +265 −0
…
7 unchanged lines
8 8
//! other authors lose their signatures, as with any rewrite of others' work.
9 9
10 10
use super::*;
11 +
use crate::objects::{AtomRange, AtomRef};
11 12
use crate::rewrite::{propagate, Correspondence};
12 13
13 14
impl Repo {
…
196 unchanged lines
210 211
        Ok(())
211 212
    }
212 213
}
214 +
215 +
/// Translate a reference to change `target`'s own atom or op into a self
216 +
/// reference, and a working-copy self reference into one offset past the
217 +
/// target's own count; other references pass through.
218 +
fn fold_atom(r: AtomRef, target: &ChangeHash, wc_offset: &HashMap<NodeId, u32>, node: &NodeId) -> Option<AtomRef> {
219 +
    match r {
220 +
        AtomRef::Atom { change, index } if change == *target => Some(AtomRef::Atom { change: ObjectRef::ZERO, index }),
221 +
        AtomRef::Atom { change, index } if change.is_zero() => Some(AtomRef::Atom { change: ObjectRef::ZERO, index: index + wc_offset.get(node).copied().unwrap_or(0) }),
222 +
        // References into the target's closure were validated by the caller and stay as they are.
223 +
        other => Some(other),
224 +
    }
225 +
}
226 +
227 +
impl Repo {
228 +
    /// Fold the working copy's uncommitted edits into any change of the
229 +
    /// bookmark (spec §8.4). The edits must reference only atoms, placements
230 +
    /// and nodes that exist in the target's own closure; dependents are
231 +
    /// rewritten with an identity correspondence, since the target's own
232 +
    /// atoms keep their indices.
233 +
    pub fn amend_into(&self, target: &ChangeHash, message: Option<&str>) -> Result<ChangeHash> {
234 +
        let snap = self.snapshot("oxy amend")?;
235 +
        if snap.statuses.iter().any(|s| s.status == ChangeStatus::ConflictEdited) {
236 +
            return fail("a file still contains conflict markers");
237 +
        }
238 +
        let wc = &snap.wc;
239 +
        if wc.tree_ops.is_empty() && wc.edits.is_empty() && message.is_none() {
240 +
            return fail("nothing to amend: the working copy is clean and no new message was given");
241 +
        }
242 +
        let old = self.store.get_change(target)?;
243 +
        if old.kind == ChangeKind::WorkingCopy {
244 +
            return fail("that is the working copy; commit it instead");
245 +
        }
246 +
        // Every reference must live in the target's closure (or be the target).
247 +
        let closure = self.load_state(&[*target])?;
248 +
        let ok_change = |h: &ChangeHash| *h == *target || closure.contains(h);
249 +
        let ok_node = |n: &NodeId| n.is_root() || closure.tree().introducer(n).is_some();
250 +
        let too_new = |what: &str| RepoError::Msg(format!("{what} was introduced after change #{}; amend into a later change instead", target.short(12)));
251 +
        for op in &wc.tree_ops {
252 +
            match op {
253 +
                TreeOp::AddNode { parent, .. } => {
254 +
                    if !ok_node(parent) {
255 +
                        return Err(too_new("the parent directory"));
256 +
                    }
257 +
                }
258 +
                TreeOp::RemoveNode { node, placement } => {
259 +
                    if !ok_node(node) || !ok_change(&placement.change) {
260 +
                        return Err(too_new("a removed file"));
261 +
                    }
262 +
                }
263 +
                TreeOp::Move { node, old, parent, .. } => {
264 +
                    if !ok_node(node) || !ok_change(&old.change) || !ok_node(parent) {
265 +
                        return Err(too_new("a moved file"));
266 +
                    }
267 +
                }
268 +
                TreeOp::SetMode { node, old, .. } => {
269 +
                    if !ok_node(node) || !ok_change(&old.change) {
270 +
                        return Err(too_new("a file whose mode changed"));
271 +
                    }
272 +
                }
273 +
            }
274 +
        }
275 +
        let wc_added: BTreeSet<NodeId> = wc.tree_ops.iter().filter_map(|o| if let TreeOp::AddNode { node, .. } = o { Some(*node) } else { None }).collect();
276 +
        for e in &wc.edits {
277 +
            let n = e.node();
278 +
            if !wc_added.contains(&n) && !ok_node(&n) {
279 +
                return Err(too_new("an edited file"));
280 +
            }
281 +
            let refs: Vec<ChangeHash> = match e {
282 +
                Edit::Insert { after, before, .. } => [after, before].iter().filter_map(|r| if let AtomRef::Atom { change, .. } = r { Some(*change) } else { None }).collect(),
283 +
                Edit::Delete { atoms, .. } => atoms.iter().map(|r| r.change).collect(),
284 +
                Edit::Replace { .. } => vec![],
285 +
            };
286 +
            if refs.iter().any(|h| !h.is_zero() && !ok_change(h)) {
287 +
                return Err(too_new("an edited line"));
288 +
            }
289 +
        }
290 +
291 +
        // Compose: the target's operations first, then the working copy's
292 +
        // with references to the target turned into self references and its
293 +
        // own atom indices shifted past the target's.
294 +
        let mut per_node: HashMap<NodeId, u32> = HashMap::new();
295 +
        for e in &old.edits {
296 +
            if let Edit::Insert { node, lines, .. } = e {
297 +
                *per_node.entry(*node).or_default() += lines.len() as u32;
298 +
            }
299 +
        }
300 +
        let op_offset = old.tree_ops.len() as u32;
301 +
        let fold_op = |r: OpRef| -> OpRef {
302 +
            if r.change == *target {
303 +
                OpRef { change: ObjectRef::ZERO, index: r.index }
304 +
            } else if r.change.is_zero() {
305 +
                OpRef { change: ObjectRef::ZERO, index: r.index + op_offset }
306 +
            } else {
307 +
                r
308 +
            }
309 +
        };
310 +
        let mut tree_ops = old.tree_ops.clone();
311 +
        for op in &wc.tree_ops {
312 +
            tree_ops.push(match op {
313 +
                TreeOp::RemoveNode { node, placement } => TreeOp::RemoveNode { node: *node, placement: fold_op(*placement) },
314 +
                TreeOp::Move { node, old, parent, name } => TreeOp::Move { node: *node, old: fold_op(*old), parent: *parent, name: name.clone() },
315 +
                TreeOp::SetMode { node, old, mode } => TreeOp::SetMode { node: *node, old: fold_op(*old), mode: *mode },
316 +
                add => add.clone(),
317 +
            });
318 +
        }
319 +
        let mut edits = old.edits.clone();
320 +
        for e in &wc.edits {
321 +
            edits.push(match e {
322 +
                Edit::Insert { node, after, before, lines } => Edit::Insert {
323 +
                    node: *node,
324 +
                    after: fold_atom(*after, target, &per_node, node).ok_or_else(|| too_new("an anchor line"))?,
325 +
                    before: fold_atom(*before, target, &per_node, node).ok_or_else(|| too_new("an anchor line"))?,
326 +
                    lines: lines.clone(),
327 +
                },
328 +
                Edit::Delete { node, atoms } => Edit::Delete {
329 +
                    node: *node,
330 +
                    atoms: atoms
331 +
                        .iter()
332 +
                        .map(|r| {
333 +
                            if r.change == *target {
334 +
                                AtomRange { change: ObjectRef::ZERO, from: r.from, to: r.to }
335 +
                            } else if r.change.is_zero() {
336 +
                                let off = per_node.get(node).copied().unwrap_or(0);
337 +
                                AtomRange { change: ObjectRef::ZERO, from: r.from + off, to: r.to + off }
338 +
                            } else {
339 +
                                *r
340 +
                            }
341 +
                        })
342 +
                        .collect(),
343 +
                },
344 +
                other => other.clone(),
345 +
            });
346 +
        }
347 +
        let mut new = Change {
348 +
            change_id: old.change_id,
349 +
            deps: vec![],
350 +
            author: self.author(),
351 +
            hlc: Hlc::now(),
352 +
            message: message.map(|m| m.trim_end().to_string()).unwrap_or(old.message.clone()),
353 +
            tree_ops,
354 +
            edits,
355 +
            kind: old.kind,
356 +
        };
357 +
        let beneath = self.load_state(&old.deps)?;
358 +
        let mut deps: BTreeSet<ChangeHash> = old.deps.iter().copied().collect();
359 +
        deps.extend(referenced_changes(&new, &beneath).into_iter().filter(|h| *h != *target));
360 +
        deps.extend(wc.deps.iter().filter(|d| closure.contains(d) && **d != *target).copied());
361 +
        new.deps = deps.into_iter().collect();
362 +
        new.normalize();
363 +
364 +
        // Rewrite dependents (identity correspondence), then start a fresh,
365 +
        // empty working copy: its edits now live in the amended change.
366 +
        let corr = Correspondence::identity(&old);
367 +
        let new_hash = self.rewrite_change(target, Some(&new), &corr, &format!("oxy amend --into {}", old.change_id.short(8)))?.unwrap();
368 +
        let (op_ref, op) = self.head_op()?;
369 +
        let heads = self.heads_of(&op.view, &self.config.bookmark)?;
370 +
        let fresh = self.new_working_copy(heads, "");
371 +
        let wc_hash = self.store.put_change(&fresh)?;
372 +
        let mut view = op.view.clone();
373 +
        view.workspaces.insert(self.workspace, wc_hash);
374 +
        self.record_op(&[op_ref], "oxy amend (working copy)", view)?;
375 +
        self.drop_manifest();
376 +
        Ok(new_hash)
377 +
    }
378 +
379 +
    /// Split a head into two changes: one holding the head's effect on
380 +
    /// `paths`, the other the rest (keeping the head's ChangeId). The
381 +
    /// working copy is unaffected.
382 +
    pub fn split(&self, hash: &ChangeHash, paths: &[String]) -> Result<(ChangeHash, ChangeHash)> {
383 +
        let old = self.rewritable_head(hash)?;
384 +
        if paths.is_empty() {
385 +
            return fail("name at least one path to split out");
386 +
        }
387 +
        let beneath = self.load_state(&old.deps)?;
388 +
        let beneath_snapshot = beneath.materialize();
389 +
        let full = self.load_state(&[*hash])?;
390 +
        let full_snapshot = full.materialize();
391 +
        let selected = |p: &str| paths.iter().any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))));
392 +
        // Entries of the intermediate state: selected paths as the head
393 +
        // leaves them, everything else as beneath.
394 +
        let entries_of = |snap: &Snapshot, keep_from_full: &dyn Fn(&str) -> bool| -> Result<BTreeMap<String, FsEntry>> {
395 +
            let mut out = BTreeMap::new();
396 +
            for (p, _) in snap.files.iter().chain(full_snapshot.files.iter()) {
397 +
                let take_full = keep_from_full(p);
398 +
                let src = if take_full { full_snapshot.files.get(p) } else { snap.files.get(p) };
399 +
                let Some(src) = src else { continue };
400 +
                if out.contains_key(p) {
401 +
                    continue;
402 +
                }
403 +
                let content = match &src.content {
404 +
                    FileContent::Dir => continue,
405 +
                    FileContent::Text { bytes, .. } => bytes.clone(),
406 +
                    FileContent::Opaque { blobs } => match blobs.first() {
407 +
                        Some(b) => self.read_blob(b)?,
408 +
                        None => continue,
409 +
                    },
410 +
                };
411 +
                out.insert(p.clone(), FsEntry { kind: src.kind, exec: src.mode.exec, content });
412 +
            }
413 +
            Ok(out)
414 +
        };
415 +
        let mid_entries = entries_of(&beneath_snapshot, &|p| selected(p))?;
416 +
        let first = self.build_from_entries(&beneath, &beneath_snapshot, HashMap::new(), &mid_entries)?;
417 +
        if first.tree_ops.is_empty() && first.edits.is_empty() {
418 +
            return fail("the named paths are not touched by that change");
419 +
        }
420 +
        let mut c1 = Change {
421 +
            change_id: ChangeId::random(),
422 +
            deps: old.deps.clone(),
423 +
            author: old.author,
424 +
            hlc: Hlc::now(),
425 +
            message: format!("{} (part 1: {})", old.message.lines().next().unwrap_or(""), paths.join(", ")),
426 +
            tree_ops: first.tree_ops,
427 +
            edits: first.edits,
428 +
            kind: ChangeKind::Normal,
429 +
        };
430 +
        let mut d1: BTreeSet<ChangeHash> = referenced_changes(&c1, &beneath);
431 +
        d1.extend(old.deps.iter().copied());
432 +
        c1.deps = d1.into_iter().collect();
433 +
        c1.normalize();
434 +
        let h1 = self.store_signed(&c1)?;
435 +
        let mut mid = beneath.clone();
436 +
        mid.add(c1)?;
437 +
        let mid_snapshot = mid.materialize();
438 +
        let rest_entries = entries_of(&full_snapshot, &|_| true)?;
439 +
        let rest = self.build_from_entries(&mid, &mid_snapshot, HashMap::new(), &rest_entries)?;
440 +
        if rest.tree_ops.is_empty() && rest.edits.is_empty() {
441 +
            return fail("everything in that change is under the named paths; nothing would remain");
442 +
        }
443 +
        let mut c2 = Change {
444 +
            change_id: old.change_id,
445 +
            deps: vec![],
446 +
            author: old.author,
447 +
            hlc: Hlc::now(),
448 +
            message: old.message.clone(),
449 +
            tree_ops: rest.tree_ops,
450 +
            edits: rest.edits,
451 +
            kind: ChangeKind::Normal,
452 +
        };
453 +
        let mut d2: BTreeSet<ChangeHash> = referenced_changes(&c2, &mid);
454 +
        d2.extend(old.deps.iter().copied());
455 +
        c2.deps = d2.into_iter().collect();
456 +
        c2.normalize();
457 +
        let h2 = self.replace_head(hash, Some(&c2), &format!("oxy split {} {}", old.change_id.short(8), paths.join(" ")))?.unwrap();
458 +
        // c1 may be a head of its own if c2 does not depend on it.
459 +
        let (op_ref, op) = self.head_op()?;
460 +
        let bookmark = self.config.bookmark.clone();
461 +
        let mut heads = self.heads_of(&op.view, &bookmark)?;
462 +
        heads.push(h1);
463 +
        let st = self.load_state(&heads)?;
464 +
        let heads = st.maximal(&heads);
465 +
        let mut view = op.view.clone();
466 +
        view.bookmarks.insert(bookmark, self.store.put_state(&StateObject::new(heads.clone()))?);
467 +
        let (_, wc) = self.working_copy()?;
468 +
        let mut base = wc.deps.clone();
469 +
        base.push(h1);
470 +
        let wc_base = self.load_state(&base)?.maximal(&base);
471 +
        let fresh = self.new_working_copy(wc_base, &wc.message);
472 +
        view.workspaces.insert(self.workspace, self.store.put_change(&fresh)?);
473 +
        self.record_op(&[op_ref], "oxy split (heads)", view)?;
474 +
        self.drop_manifest();
475 +
        Ok((h1, h2))
476 +
    }
477 +
}
modified crates/oxy-core/src/repo/tests.rs +65 −0
…
1523 unchanged lines
1524 1524
    assert!(sem.contains("\"touched_paths\":[\"c.txt\"]") && sem.contains("candidate_deps"), "{sem}");
1525 1525
    assert_eq!(json_str("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\"");
1526 1526
}
1527 +
1528 +
#[test]
1529 +
fn amend_into_older_change_and_split() {
1530 +
    hermetic();
1531 +
    let dir = tempfile::tempdir().unwrap();
1532 +
    let root = dir.path();
1533 +
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
1534 +
    repo.create_identity("Ada", "a@b", IdentityScope::User).unwrap();
1535 +
    repo.set_user_config("core.sign", "change").unwrap();
1536 +
    write(root, "a.txt", b"one\ntwo\n");
1537 +
    let h0 = repo.commit("base", &[]).unwrap();
1538 +
    write(root, "a.txt", b"one\nTWO\n");
1539 +
    let h1 = repo.commit("edit two", &[]).unwrap();
1540 +
    write(root, "a.txt", b"one\nTWO\nthree\n");
1541 +
    let h2 = repo.commit("add three", &[]).unwrap();
1542 +
    let id1 = repo.store().get_change(&h1).unwrap().change_id;
1543 +
1544 +
    // Fixing the line h1 introduced folds into h1; h2 is rewritten on top.
1545 +
    write(root, "a.txt", b"one\nTWO!\nthree\n");
1546 +
    let h1b = repo.amend_into(&h1, None).unwrap();
1547 +
    assert_ne!(h1b, h1);
1548 +
    let c1b = repo.store().get_change(&h1b).unwrap();
1549 +
    assert_eq!(c1b.change_id, id1);
1550 +
    assert_eq!(c1b.deps, vec![h0]);
1551 +
    assert!(matches!(repo.verify_change(&h1b).unwrap(), SignatureStatus::Valid { .. }));
1552 +
    let heads = repo.bookmark_heads().unwrap();
1553 +
    assert_eq!(heads.len(), 1);
1554 +
    let top = repo.store().get_change(&heads[0]).unwrap();
1555 +
    assert_eq!(top.message, "add three");
1556 +
    assert_eq!(top.deps.iter().copied().collect::<BTreeSet<_>>(), [h0, h1b].into_iter().collect(), "depends on the file's creator and the rewritten line");
1557 +
    let snap = repo.load_state(&heads).unwrap().materialize();
1558 +
    assert!(matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\nthree\n"));
1559 +
    assert!(!repo.snapshot("status").unwrap().changed, "the working copy is clean after folding");
1560 +
    // Only h1b's own state shows the fix without "three".
1561 +
    let only = repo.load_state(&[h1b]).unwrap().materialize();
1562 +
    assert!(matches!(&only.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\n"));
1563 +
    assert_eq!(repo.log().unwrap().len(), 4);
1564 +
1565 +
    // An edit to a line introduced by h2 cannot fold into h1.
1566 +
    write(root, "a.txt", b"one\nTWO!\nthree!\n");
1567 +
    let err = repo.amend_into(&h1b, None).unwrap_err().to_string();
1568 +
    assert!(err.contains("introduced after"), "{err}");
1569 +
    assert!(repo.snapshot("status").unwrap().statuses.len() == 1, "nothing was changed by the refusal");
1570 +
    write(root, "a.txt", b"one\nTWO!\nthree\n");
1571 +
1572 +
    // Split the head that touches two files.
1573 +
    write(root, "b.txt", b"b\n");
1574 +
    write(root, "c.txt", b"c\n");
1575 +
    let h3 = repo.commit("b and c", &[]).unwrap();
1576 +
    let id3 = repo.store().get_change(&h3).unwrap().change_id;
1577 +
    let (p1, p2) = repo.split(&h3, &["b.txt".into()]).unwrap();
1578 +
    let c_1 = repo.store().get_change(&p1).unwrap();
1579 +
    let c_2 = repo.store().get_change(&p2).unwrap();
1580 +
    assert!(c_1.message.contains("part 1: b.txt"));
1581 +
    assert_eq!(c_2.change_id, id3);
1582 +
    let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
1583 +
    assert!(heads.contains(&p1) && heads.contains(&p2), "independent parts are both heads");
1584 +
    let snap = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize();
1585 +
    assert!(snap.files.contains_key("b.txt") && snap.files.contains_key("c.txt"));
1586 +
    let only1 = repo.load_state(&[p1]).unwrap().materialize();
1587 +
    assert!(only1.files.contains_key("b.txt") && !only1.files.contains_key("c.txt"));
1588 +
    assert!(!repo.snapshot("status").unwrap().changed);
1589 +
    assert!(repo.split(&p2, &["nope.txt".into()]).is_err());
1590 +
    let _ = h2;
1591 +
}
modified docs/spec.md +5 −3
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.6k |
5 +
| **Status** | Draft 0.6l |
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. 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, 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.
321 321
322 322
## 9. Conflicts
323 323
…
518 unchanged lines
842 842
843 843
---
844 844
845 -
*Changelog.* Draft 0.6k (2026-09-27): hook execution model and JSON payloads (§20).
845 +
*Changelog.* Draft 0.6l (2026-09-27): amend into older changes and split (§8.4).
846 +
847 +
Draft 0.6k (2026-09-27): hook execution model and JSON payloads (§20).
846 848
847 849
Draft 0.6j (2026-09-27): `.oxyattributes` semantics (§19).
848 850
…
35 unchanged lines
modified src/cli/mod.rs +11 −2
…
73 unchanged lines
74 74
        message: String,
75 75
        change: Option<String>,
76 76
    },
77 -
    /// Fold the working copy into the head it sits on
77 +
    /// Fold the working copy into the head it sits on, or into CHANGE with --into
78 78
    Amend {
79 79
        #[arg(short, long)]
80 80
        message: Option<String>,
81 +
        /// Amend an older change; the edits must touch only lines it already knows
82 +
        #[arg(long)]
83 +
        into: Option<String>,
81 84
    },
85 +
    /// Split a head: the named paths become a separate first change
86 +
    Split {
87 +
        change: String,
88 +
        paths: Vec<String>,
89 +
    },
82 90
    /// Drop a head from the bookmark; its content stays on disk as uncommitted work
83 91
    Abandon { change: String },
84 92
    /// Show one change (ChangeId prefix, #hash prefix, or @)
…
195 unchanged lines
280 288
        Cmd::Log => workspace::log(),
281 289
        Cmd::Diff => workspace::diff(),
282 290
        Cmd::Describe { message, change } => workspace::describe(&message, change.as_deref()),
283 -
        Cmd::Amend { message } => workspace::amend(message.as_deref()),
291 +
        Cmd::Amend { message, into } => workspace::amend(message.as_deref(), into.as_deref()),
292 +
        Cmd::Split { change, paths } => workspace::split(&change, &paths),
284 293
        Cmd::Abandon { change } => workspace::abandon(&change),
285 294
        Cmd::Show { change } => workspace::show(&change),
286 295
        Cmd::Checkout { changes, discard } => workspace::checkout(&changes, discard),
…
45 unchanged lines
modified src/cli/workspace.rs +18 −2
…
246 unchanged lines
247 247
    Ok(0)
248 248
}
249 249
250 -
pub fn amend(message: Option<&str>) -> Result<i32> {
250 +
pub fn amend(message: Option<&str>, into: Option<&str>) -> Result<i32> {
251 251
    let repo = open()?;
252 -
    let new = repo.amend(message)?;
252 +
    let new = match into {
253 +
        Some(spec) => {
254 +
            let target = repo.resolve_change(spec)?;
255 +
            repo.amend_into(&target, message)?
256 +
        }
257 +
        None => repo.amend(message)?,
258 +
    };
253 259
    let c = repo.store().get_change(&new)?;
254 260
    println!("{} {} {} {}", ui::ok("Amended"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), c.message.lines().next().unwrap_or(""));
255 261
    Ok(0)
…
152 unchanged lines
408 414
    println!("{} {}", ui::ok("Unlocked"), ui::path(path));
409 415
    Ok(0)
410 416
}
417 +
418 +
pub fn split(change: &str, paths: &[String]) -> Result<i32> {
419 +
    let repo = open()?;
420 +
    let h = repo.resolve_change(change)?;
421 +
    let (h1, h2) = repo.split(&h, paths)?;
422 +
    let c1 = repo.store().get_change(&h1)?;
423 +
    let c2 = repo.store().get_change(&h2)?;
424 +
    println!("{} into {} {} and {} {}", ui::ok("Split"), ui::id(short_id(&c1)), ui::hash(format!("#{}", h1.short(12))), ui::id(short_id(&c2)), ui::hash(format!("#{}", h2.short(12))));
425 +
    Ok(0)
426 +
}