OXYGENHUB

Squash and rebase (spec 0.8 section 8.5): squash folds a change into a dependency with dependents following its atoms; rebase re-derives a change against another base with content-matched correspondence; correspondences keyed by node; spec 0.8a and docs 728da0f92161

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 14:31 UTC
Signature
✔ signed by Andy McCall
Hash
728da0f92161d8b7e5e2ac74494c13e40f47a9c1ae95b81cc5bb25983cbd2901
Depends on
23b217d75848 30ea9c1e70fa 50a046e439e9 614541c3cebd 648724d8e0de 6706bdcfb63e 7b9809e67c3d 86fe29fd62ea 957fee30826e 96f7610fa666 982864068a59 c07e0e7d2d85 d02e545dc265 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
Depended on by
79b5a90ffd43 631fc8970bd0 98a1620a3840 e266b7d4065c 01a47cd7f0c8 57eaac9107df 6f8aeb996558 d42369abf180 75167672ebcd c792033af344 c6aa5bd9d1c8 8b678b298fee
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
27 atoms, 0 tree operations, 9 files

Browse the tree at this change

modified README.md +4 −0
…
60 unchanged lines
61 61
oxy push                                 # publishes the policy
62 62
```
63 63
64 +
Bookmarks (`oxy bookmark create|switch`, `oxy merge`), submodules (`oxy submodule add|update`),
65 +
partial commits (`oxy commit PATH..`, `oxy commit -i`), `oxy blame`, `oxy bisect`, `oxy squash` and
66 +
`oxy rebase` cover the everyday Git toolbox; see the guide.
67 +
64 68
Working alongside Git: `oxy git import <repo> <dest>` converts a Git history, `oxy git fetch <url>`
65 69
brings new commits of a Git remote into the bookmark, `oxy git push <url>` publishes the bookmark as
66 70
commits on top of them, and `oxy git export <dir>` writes a plain Git mirror.
…
22 unchanged lines
modified crates/oxy-core/src/repo/history.rs +254 −0
…
579 unchanged lines
580 580
        Ok((h1, h2))
581 581
    }
582 582
}
583 +
584 +
impl Repo {
585 +
    /// Fold change `c` into its dependency `into` (spec §8.5): the result
586 +
    /// keeps `into`'s ChangeId, dependents of both are rewritten, and `c`
587 +
    /// leaves the state.
588 +
    pub fn squash(&self, c: &ChangeHash, into: &ChangeHash) -> Result<ChangeHash> {
589 +
        if c == into {
590 +
            return fail("a change cannot be squashed into itself");
591 +
        }
592 +
        let src = self.store.get_change(c)?;
593 +
        let dst = self.store.get_change(into)?;
594 +
        if src.kind == ChangeKind::WorkingCopy {
595 +
            return fail("that is the working copy; use `oxy amend --into`");
596 +
        }
597 +
        if dst.kind == ChangeKind::WorkingCopy {
598 +
            return fail("cannot squash into the working copy");
599 +
        }
600 +
        let closure = self.load_state(&[*into])?;
601 +
        let outside: Vec<String> = src.deps.iter().filter(|d| *d != into && !closure.contains(d)).map(|d| d.short(12)).collect();
602 +
        if !outside.is_empty() {
603 +
            return fail(format!("{} depends on {} which #{} does not; squash those first or squash into a later change", src.change_id.short(8), outside.join(", "), into.short(12)));
604 +
        }
605 +
        let ok_change = |h: &ChangeHash| *h == *into || closure.contains(h);
606 +
        let ok_node = |n: &NodeId| n.is_root() || closure.tree().introducer(n).is_some();
607 +
        let too_new = |what: &str| RepoError::Msg(format!("{what} was introduced after change #{}; squash into a later change instead", into.short(12)));
608 +
        let src_added: BTreeSet<NodeId> = src.tree_ops.iter().filter_map(|o| if let TreeOp::AddNode { node, .. } = o { Some(*node) } else { None }).collect();
609 +
        for op in &src.tree_ops {
610 +
            match op {
611 +
                TreeOp::AddNode { parent, .. } => {
612 +
                    if !ok_node(parent) && !src_added.contains(parent) {
613 +
                        return Err(too_new("the parent directory"));
614 +
                    }
615 +
                }
616 +
                TreeOp::RemoveNode { node, placement } => {
617 +
                    if !ok_node(node) || !ok_change(&placement.change) {
618 +
                        return Err(too_new("a removed file"));
619 +
                    }
620 +
                }
621 +
                TreeOp::Move { node, old, parent, .. } => {
622 +
                    if !ok_node(node) || !ok_change(&old.change) || (!ok_node(parent) && !src_added.contains(parent)) {
623 +
                        return Err(too_new("a moved file"));
624 +
                    }
625 +
                }
626 +
                TreeOp::SetMode { node, old, .. } => {
627 +
                    if !ok_node(node) || !ok_change(&old.change) {
628 +
                        return Err(too_new("a file whose mode changed"));
629 +
                    }
630 +
                }
631 +
            }
632 +
        }
633 +
        for e in &src.edits {
634 +
            let n = e.node();
635 +
            if !src_added.contains(&n) && !ok_node(&n) {
636 +
                return Err(too_new("an edited file"));
637 +
            }
638 +
            let refs: Vec<ChangeHash> = match e {
639 +
                Edit::Insert { after, before, .. } => [after, before].iter().filter_map(|r| if let AtomRef::Atom { change, .. } = r { Some(*change) } else { None }).collect(),
640 +
                Edit::Delete { atoms, .. } => atoms.iter().map(|r| r.change).collect(),
641 +
                Edit::Replace { .. } => vec![],
642 +
            };
643 +
            if refs.iter().any(|h| !h.is_zero() && !ok_change(h)) {
644 +
                return Err(too_new("an edited line"));
645 +
            }
646 +
        }
647 +
        // Compose dst then src, with src's references to dst turned into
648 +
        // self references and its own atoms shifted past dst's.
649 +
        let mut per_node: HashMap<NodeId, u32> = HashMap::new();
650 +
        for e in &dst.edits {
651 +
            if let Edit::Insert { node, lines, .. } = e {
652 +
                *per_node.entry(*node).or_default() += lines.len() as u32;
653 +
            }
654 +
        }
655 +
        let op_offset = dst.tree_ops.len() as u32;
656 +
        let fold_op = |r: OpRef| -> OpRef {
657 +
            if r.change == *into {
658 +
                OpRef { change: ObjectRef::ZERO, index: r.index }
659 +
            } else if r.change.is_zero() {
660 +
                OpRef { change: ObjectRef::ZERO, index: r.index + op_offset }
661 +
            } else {
662 +
                r
663 +
            }
664 +
        };
665 +
        let mut tree_ops = dst.tree_ops.clone();
666 +
        for op in &src.tree_ops {
667 +
            tree_ops.push(match op {
668 +
                TreeOp::RemoveNode { node, placement } => TreeOp::RemoveNode { node: *node, placement: fold_op(*placement) },
669 +
                TreeOp::Move { node, old, parent, name } => TreeOp::Move { node: *node, old: fold_op(*old), parent: *parent, name: name.clone() },
670 +
                TreeOp::SetMode { node, old, mode } => TreeOp::SetMode { node: *node, old: fold_op(*old), mode: *mode },
671 +
                add => add.clone(),
672 +
            });
673 +
        }
674 +
        let mut edits = dst.edits.clone();
675 +
        for e in &src.edits {
676 +
            edits.push(match e {
677 +
                Edit::Insert { node, after, before, lines } => Edit::Insert {
678 +
                    node: *node,
679 +
                    after: fold_atom(*after, into, &per_node, node).ok_or_else(|| too_new("an anchor line"))?,
680 +
                    before: fold_atom(*before, into, &per_node, node).ok_or_else(|| too_new("an anchor line"))?,
681 +
                    lines: lines.clone(),
682 +
                },
683 +
                Edit::Delete { node, atoms } => Edit::Delete {
684 +
                    node: *node,
685 +
                    atoms: atoms
686 +
                        .iter()
687 +
                        .map(|r| {
688 +
                            if r.change == *into {
689 +
                                AtomRange { change: ObjectRef::ZERO, from: r.from, to: r.to }
690 +
                            } else if r.change.is_zero() {
691 +
                                let off = per_node.get(node).copied().unwrap_or(0);
692 +
                                AtomRange { change: ObjectRef::ZERO, from: r.from + off, to: r.to + off }
693 +
                            } else {
694 +
                                *r
695 +
                            }
696 +
                        })
697 +
                        .collect(),
698 +
                },
699 +
                other => other.clone(),
700 +
            });
701 +
        }
702 +
        let mut new = Change {
703 +
            change_id: dst.change_id,
704 +
            deps: vec![],
705 +
            author: dst.author,
706 +
            hlc: Hlc::now(),
707 +
            message: dst.message.clone(),
708 +
            tree_ops,
709 +
            edits,
710 +
            kind: if dst.kind == ChangeKind::Resolution || src.kind == ChangeKind::Resolution { ChangeKind::Resolution } else { ChangeKind::Normal },
711 +
        };
712 +
        let beneath = self.load_state(&dst.deps)?;
713 +
        let mut deps: BTreeSet<ChangeHash> = dst.deps.iter().copied().collect();
714 +
        deps.extend(referenced_changes(&new, &beneath).into_iter().filter(|h| *h != *into && *h != *c));
715 +
        deps.extend(src.deps.iter().filter(|d| **d != *into).copied());
716 +
        new.deps = deps.into_iter().collect();
717 +
        new.normalize();
718 +
719 +
        // Dependents of dst keep their indices; dependents of src follow its
720 +
        // atoms and operations to their shifted places in the new change.
721 +
        let cmd = format!("oxy squash {} --into {}", src.change_id.short(8), dst.change_id.short(8));
722 +
        let dst_new = self.rewrite_change(into, Some(&new), &Correspondence::identity(&dst), &cmd)?.unwrap();
723 +
        // src was rewritten along with the other dependents of dst; find it by ChangeId.
724 +
        let (wc_hash, _) = self.working_copy()?;
725 +
        let mut all = self.bookmark_heads()?;
726 +
        all.push(wc_hash);
727 +
        let state = self.load_state(&all)?;
728 +
        let src_now = state.changes().find(|(_, m)| m.change_id == src.change_id).map(|(h, _)| *h).unwrap_or(*c);
729 +
        let src_change = self.store.get_change(&src_now)?;
730 +
        let mut corr = Correspondence { atoms: HashMap::new(), ops: HashMap::new() };
731 +
        let mut counts: HashMap<NodeId, u32> = HashMap::new();
732 +
        for e in &src_change.edits {
733 +
            if let Edit::Insert { node, lines, .. } = e {
734 +
                let off = per_node.get(node).copied().unwrap_or(0);
735 +
                let idx = counts.entry(*node).or_default();
736 +
                for _ in lines {
737 +
                    corr.atoms.insert((*node, *idx), *idx + off);
738 +
                    *idx += 1;
739 +
                }
740 +
            }
741 +
        }
742 +
        for i in 0..src_change.tree_ops.len() as u32 {
743 +
            corr.ops.insert(i, i + op_offset);
744 +
        }
745 +
        let final_new = self.store.get_change(&dst_new)?;
746 +
        self.rewrite_change(&src_now, Some(&final_new), &corr, &cmd)?;
747 +
        Ok(dst_new)
748 +
    }
749 +
750 +
    /// Re-express change `c` against the state `onto` names (spec §8.5): its
751 +
    /// effect on the files it touches is re-derived as edits against that
752 +
    /// state. Keeps the ChangeId; dependents follow by content.
753 +
    pub fn rebase(&self, c: &ChangeHash, onto: &[ChangeHash]) -> Result<ChangeHash> {
754 +
        let old = self.store.get_change(c)?;
755 +
        if old.kind == ChangeKind::WorkingCopy {
756 +
            return fail("that is the working copy; check out the new base instead");
757 +
        }
758 +
        let target = self.load_state(onto)?;
759 +
        if target.contains(c) {
760 +
            return fail(format!("#{} is already part of the target state", c.short(12)));
761 +
        }
762 +
        let onto = target.maximal(onto);
763 +
        let target_snapshot = target.materialize();
764 +
        let before = self.load_state(&old.deps)?.materialize();
765 +
        let after = self.load_state(&[*c])?.materialize();
766 +
        // The paths whose file differs between the state with and without c.
767 +
        let mut touched: BTreeSet<String> = BTreeSet::new();
768 +
        for p in before.files.keys().chain(after.files.keys()) {
769 +
            match (before.files.get(p), after.files.get(p)) {
770 +
                (Some(a), Some(b)) if a.kind == b.kind && a.mode == b.mode && a.content == b.content => {}
771 +
                _ => {
772 +
                    touched.insert(p.clone());
773 +
                }
774 +
            }
775 +
        }
776 +
        for p in &touched {
777 +
            if target_snapshot.files.get(p).is_some_and(|f| f.is_conflicted()) {
778 +
                return fail(format!("{p} is conflicted in the target state; resolve it first"));
779 +
            }
780 +
        }
781 +
        // Entries: the target's files, with touched paths as c leaves them.
782 +
        let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
783 +
        let mut reuse: HashMap<String, (NodeId, NodeKind)> = HashMap::new();
784 +
        let content_of = |f: &crate::state::FileState| -> Result<Option<Vec<u8>>> {
785 +
            Ok(match &f.content {
786 +
                FileContent::Dir => None,
787 +
                FileContent::Text { bytes, .. } => Some(bytes.clone()),
788 +
                FileContent::Opaque { blobs } => match blobs.first() {
789 +
                    Some(b) => Some(self.read_blob(b)?),
790 +
                    None => None,
791 +
                },
792 +
            })
793 +
        };
794 +
        for (p, f) in &target_snapshot.files {
795 +
            if touched.contains(p) {
796 +
                continue;
797 +
            }
798 +
            if let Some(content) = content_of(f)? {
799 +
                entries.insert(p.clone(), FsEntry::new(f.kind, f.mode.exec, content));
800 +
            }
801 +
        }
802 +
        for p in &touched {
803 +
            if let Some(f) = after.files.get(p) {
804 +
                if let Some(content) = content_of(f)? {
805 +
                    entries.insert(p.clone(), FsEntry::new(f.kind, f.mode.exec, content));
806 +
                }
807 +
                if !target_snapshot.files.contains_key(p) {
808 +
                    reuse.insert(p.clone(), (f.node, f.kind));
809 +
                }
810 +
            }
811 +
        }
812 +
        let built = self.build_from_entries(&target, &target_snapshot, reuse, &entries)?;
813 +
        if built.tree_ops.is_empty() && built.edits.is_empty() {
814 +
            return fail("rebasing would leave the change empty; the target already has its effect");
815 +
        }
816 +
        let mut new = Change {
817 +
            change_id: old.change_id,
818 +
            deps: vec![],
819 +
            author: old.author,
820 +
            hlc: Hlc::now(),
821 +
            message: old.message.clone(),
822 +
            tree_ops: built.tree_ops,
823 +
            edits: built.edits,
824 +
            kind: if built.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
825 +
        };
826 +
        let mut deps: BTreeSet<ChangeHash> = onto.iter().copied().collect();
827 +
        deps.extend(referenced_changes(&new, &target));
828 +
        deps.extend(built.contributors.iter().copied());
829 +
        new.deps = deps.into_iter().collect();
830 +
        new.normalize();
831 +
        let corr = Correspondence::by_content(&old, &new);
832 +
        let cmd = format!("oxy rebase {} --onto {}", old.change_id.short(8), onto.iter().map(|h| h.short(12)).collect::<Vec<_>>().join(" "));
833 +
        Ok(self.rewrite_change(c, Some(&new), &corr, &cmd)?.unwrap())
834 +
    }
835 +
}
836 +
modified crates/oxy-core/src/repo/tests.rs +80 −0
…
2452 unchanged lines
2453 2453
    repo.bisect_reset().unwrap();
2454 2454
}
2455 2455
2456 +
#[test]
2457 +
fn squash_folds_a_change_into_its_dependency() {
2458 +
    hermetic();
2459 +
    let dir = tempfile::tempdir().unwrap();
2460 +
    let root = dir.path();
2461 +
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
2462 +
    write(root, "a.txt", b"1\n");
2463 +
    let a = repo.commit("A", &[]).unwrap();
2464 +
    write(root, "a.txt", b"1\n2\n");
2465 +
    let c = repo.commit("C", &[]).unwrap();
2466 +
    write(root, "a.txt", b"1\n2\n3\n");
2467 +
    let e = repo.commit("E", &[]).unwrap();
2468 +
    write(root, "b.txt", b"b\n");
2469 +
    let b = repo.commit("B", &[]).unwrap();
2470 +
    let id_a = repo.store().get_change(&a).unwrap().change_id;
2471 +
    let id_e = repo.store().get_change(&e).unwrap().change_id;
2472 +
    // E depends on C which A lacks: refused, naming it.
2473 +
    let err = repo.squash(&e, &a).unwrap_err().to_string();
2474 +
    assert!(err.contains(&c.short(12)), "{err}");
2475 +
    // C into A: A' has both, C is gone, E follows and still reads the same.
2476 +
    let a2 = repo.squash(&c, &a).unwrap();
2477 +
    let a2c = repo.store().get_change(&a2).unwrap();
2478 +
    assert_eq!(a2c.change_id, id_a);
2479 +
    assert!(a2c.deps.is_empty());
2480 +
    let heads = repo.bookmark_heads().unwrap();
2481 +
    let st = repo.load_state(&heads).unwrap();
2482 +
    assert!(!st.contains(&c) && !st.contains(&a) && !st.contains(&e));
2483 +
    let e2 = st.changes().find(|(_, m)| m.change_id == id_e).map(|(h, _)| *h).unwrap();
2484 +
    assert_eq!(repo.store().get_change(&e2).unwrap().deps, vec![a2], "E now depends on A' alone");
2485 +
    let files = st.materialize().files;
2486 +
    assert!(matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"1\n2\n3\n"));
2487 +
    assert!(files.contains_key("b.txt"));
2488 +
    assert!(st.contains(&b));
2489 +
    assert!(!repo.snapshot("status").unwrap().changed);
2490 +
    assert_eq!(repo.log().unwrap().len(), 4, "working copy, B, E', A'");
2491 +
    // Undo restores the three original changes.
2492 +
    repo.undo().unwrap();
2493 +
    repo.undo().unwrap();
2494 +
    let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap();
2495 +
    assert!(st.contains(&c) && st.contains(&a) && st.contains(&e));
2496 +
}
2497 +
2498 +
#[test]
2499 +
fn rebase_re_expresses_a_change_against_another_base() {
2500 +
    hermetic();
2501 +
    let dir = tempfile::tempdir().unwrap();
2502 +
    let root = dir.path();
2503 +
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
2504 +
    write(root, "a.txt", b"1\n2\n");
2505 +
    let a = repo.commit("A", &[]).unwrap();
2506 +
    write(root, "a.txt", b"1\n2\n3\n");
2507 +
    let c = repo.commit("C: append 3", &[]).unwrap();
2508 +
    write(root, "a.txt", b"1\n2\n3\n4\n");
2509 +
    let e = repo.commit("E: append 4", &[]).unwrap();
2510 +
    // A different base: A plus a change that prepends a line.
2511 +
    repo.checkout(&[a], true, "checkout").unwrap();
2512 +
    write(root, "a.txt", b"0\n1\n2\n");
2513 +
    let d = repo.commit("D: prepend 0", &[]).unwrap();
2514 +
    let id_c = repo.store().get_change(&c).unwrap().change_id;
2515 +
    let id_e = repo.store().get_change(&e).unwrap().change_id;
2516 +
    // Rebase C onto D: its effect (append 3) re-derived against D's text.
2517 +
    let c2 = repo.rebase(&c, &[d]).unwrap();
2518 +
    let c2c = repo.store().get_change(&c2).unwrap();
2519 +
    assert_eq!(c2c.change_id, id_c);
2520 +
    assert!(c2c.deps.contains(&d), "{:?}", c2c.deps);
2521 +
    let heads = repo.bookmark_heads().unwrap();
2522 +
    let st = repo.load_state(&heads).unwrap();
2523 +
    assert!(!st.contains(&c) && st.contains(&d));
2524 +
    let e2 = st.changes().find(|(_, m)| m.change_id == id_e).map(|(h, _)| *h).unwrap();
2525 +
    let e2deps = repo.store().get_change(&e2).unwrap().deps;
2526 +
    assert!(e2deps.contains(&c2) && !e2deps.contains(&c) && e2deps.contains(&a), "E follows C by content and still depends on A, which introduced the file: {e2deps:?}");
2527 +
    let files = st.materialize().files;
2528 +
    assert!(matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"0\n1\n2\n3\n4\n"), "{:?}", files["a.txt"].content);
2529 +
    assert!(!files["a.txt"].is_conflicted());
2530 +
    // Rebasing onto a state that already contains the change is refused.
2531 +
    assert!(repo.rebase(&c2, &[c2]).is_err());
2532 +
    let err = repo.rebase(&e2, &[d]).unwrap_err().to_string();
2533 +
    assert!(err.contains("empty") || err.contains("already"), "{err}");
2534 +
}
2535 +
modified crates/oxy-core/src/rewrite.rs +50 −18
…
17 unchanged lines
18 18
/// How `old`'s atoms and tree operations map onto `new`'s.
19 19
#[derive(Clone, Debug, Default, PartialEq, Eq)]
20 20
pub struct Correspondence {
21 -
    /// Old atom index → new atom index, per node the old change edited.
22 -
    /// Absent means the atom no longer exists.
23 -
    pub atoms: HashMap<u32, u32>,
21 +
    /// `(node, old atom index)` → new atom index. Absent means the atom no
22 +
    /// longer exists.
23 +
    pub atoms: HashMap<(crate::ids::NodeId, u32), u32>,
24 24
    /// Old tree-op index → new tree-op index.
25 25
    pub ops: HashMap<u32, u32>,
26 26
}
…
7 unchanged lines
34 34
            if let Edit::Insert { node, lines, .. } = e {
35 35
                let idx = per_node.entry(*node).or_default();
36 36
                for _ in lines {
37 -
                    atoms.insert(*idx, *idx);
37 +
                    atoms.insert((*node, *idx), *idx);
38 38
                    *idx += 1;
39 39
                }
40 40
            }
41 41
        }
42 -
        // Atom indices are per node, so the identity map is index → index for
43 -
        // the largest index any node uses.
44 -
        let max = per_node.values().copied().max().unwrap_or(0);
45 -
        for i in 0..max {
46 -
            atoms.insert(i, i);
42 +
        let ops = (0..change.tree_ops.len() as u32).map(|i| (i, i)).collect();
43 +
        Correspondence { atoms, ops }
44 +
    }
45 +
46 +
    /// Match `old`'s atoms to `new`'s by content, per node, in order (the
47 +
    /// correspondence of a re-derived change, spec §8.5); operations match
48 +
    /// by node and variant.
49 +
    pub fn by_content(old: &Change, new: &Change) -> Correspondence {
50 +
        let lines_of = |c: &Change| -> HashMap<crate::ids::NodeId, Vec<Vec<u8>>> {
51 +
            let mut m: HashMap<crate::ids::NodeId, Vec<Vec<u8>>> = HashMap::new();
52 +
            for e in &c.edits {
53 +
                if let Edit::Insert { node, lines, .. } = e {
54 +
                    m.entry(*node).or_default().extend(lines.iter().cloned());
55 +
                }
56 +
            }
57 +
            m
58 +
        };
59 +
        let (a, b) = (lines_of(old), lines_of(new));
60 +
        let mut atoms = HashMap::new();
61 +
        for (node, olds) in &a {
62 +
            let Some(news) = b.get(node) else { continue };
63 +
            let mut j = 0usize;
64 +
            for (i, line) in olds.iter().enumerate() {
65 +
                while j < news.len() && news[j] != *line {
66 +
                    j += 1;
67 +
                }
68 +
                if j < news.len() {
69 +
                    atoms.insert((*node, i as u32), j as u32);
70 +
                    j += 1;
71 +
                }
72 +
            }
73 +
        }
74 +
        let mut ops = HashMap::new();
75 +
        for (i, op) in old.tree_ops.iter().enumerate() {
76 +
            if let Some(j) = new.tree_ops.iter().position(|n| n.node() == op.node() && std::mem::discriminant(n) == std::mem::discriminant(op)) {
77 +
                ops.insert(i as u32, j as u32);
78 +
            }
47 79
        }
48 -
        let ops = (0..change.tree_ops.len() as u32).map(|i| (i, i)).collect();
49 80
        Correspondence { atoms, ops }
50 81
    }
51 82
}
…
15 unchanged lines
67 98
68 99
/// Where each atom of a change was inserted: the chain it belongs to.
69 100
struct Chain {
101 +
    node: crate::ids::NodeId,
70 102
    first: u32,
71 103
    last: u32,
72 104
    after: AtomRef,
…
6 unchanged lines
79 111
    for e in &c.edits {
80 112
        if let Edit::Insert { node, after, before, lines } = e {
81 113
            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 });
114 +
            out.push(Chain { node: *node, first: *idx, last: *idx + lines.len() as u32 - 1, after: *after, before: *before });
83 115
            *idx += lines.len() as u32;
84 116
        }
85 117
    }
…
66 unchanged lines
152 184
    if let (Some(Replacement::Change(nh)), Some(corr), Some(ch)) = (mapping.get(change), corr, chain) {
153 185
        let candidates: Box<dyn Iterator<Item = u32>> = if want_after { Box::new((ch.first..index).rev()) } else { Box::new(index + 1..=ch.last) };
154 186
        for j in candidates {
155 -
            if let Some(nj) = corr.atoms.get(&j) {
187 +
            if let Some(nj) = corr.atoms.get(&(ch.node, j)) {
156 188
                return AtomRef::Atom { change: *nh, index: *nj };
157 189
            }
158 190
        }
…
15 unchanged lines
174 206
) -> Change {
175 207
    let short = hash.short(12);
176 208
    // 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 {
209 +
    let map_anchor = |r: AtomRef, node: &crate::ids::NodeId, want_after: bool, notes: &mut Vec<String>| -> AtomRef {
178 210
        match r {
179 211
            AtomRef::Atom { change, index } => match mapping.get(&change) {
180 -
                Some(Replacement::Change(nh)) => match corrs[&change].atoms.get(&index) {
212 +
                Some(Replacement::Change(nh)) => match corrs[&change].atoms.get(&(*node, index)) {
181 213
                    Some(idx) => AtomRef::Atom { change: *nh, index: *idx },
182 214
                    None => {
183 215
                        notes.push(format!("change {short}: an anchor line of the rewritten change vanished; re-anchored to its nearest neighbour"));
…
64 unchanged lines
248 280
        match e {
249 281
            Edit::Insert { node, after, before, lines } => edits.push(Edit::Insert {
250 282
                node: *node,
251 -
                after: map_anchor(*after, true, notes),
252 -
                before: map_anchor(*before, false, notes),
283 +
                after: map_anchor(*after, node, true, notes),
284 +
                before: map_anchor(*before, node, false, notes),
253 285
                lines: lines.clone(),
254 286
            }),
255 287
            Edit::Delete { node, atoms } => {
…
2 unchanged lines
258 290
                for r in atoms.iter() {
259 291
                    for i in r.from..r.to {
260 292
                        match mapping.get(&r.change) {
261 -
                            Some(Replacement::Change(nh)) => match corrs[&r.change].atoms.get(&i) {
293 +
                            Some(Replacement::Change(nh)) => match corrs[&r.change].atoms.get(&(*node, i)) {
262 294
                                Some(idx) => mapped.push((*nh, *idx)),
263 295
                                None => dropped += 1,
264 296
                            },
…
91 unchanged lines
356 388
        // insert after "b" is re-anchored after "a", the nearest survivor.
357 389
        let mut n1 = c1.clone();
358 390
        n1.edits = vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"a\n".to_vec()] }];
359 -
        let corr = Correspondence { atoms: [(0, 0)].into_iter().collect(), ops: [(0, 0)].into_iter().collect() };
391 +
        let corr = Correspondence { atoms: [((f, 0), 0)].into_iter().collect(), ops: [(0, 0)].into_iter().collect() };
360 392
        let p = propagate(&st, h1, Some(&n1), &corr, &load).unwrap();
361 393
        assert_eq!(p.notes.len(), 1, "{:?}", p.notes);
362 394
        let n1h = n1.hash();
…
66 unchanged lines
modified docs/guide.md +5 −1
…
114 unchanged lines
115 115
oxy amend --into 658990aa               # fold it into an older change
116 116
oxy split 658990aa src/parser.rs        # split a head: those paths become a separate first change
117 117
oxy abandon 658990aa                    # drop a change; its content stays on disk
118 +
oxy squash 658990aa --into 9df9ac29     # fold a change into one it depends on
119 +
oxy rebase 658990aa --onto feature      # re-express a change against another base
118 120
oxy undo                                # undo the last operation, whatever it was
119 121
oxy op log                              # the operation log
120 122
```
121 123
122 124
Rewriting a change that others depend on rewrites them too, keeping their
123 -
ChangeIds. Where a dependent referred to a line that no longer exists, the
125 +
ChangeIds. Because unrelated changes already coexist, `rebase` is rarely
126 +
needed; its job is to change what a change is said to build on, for instance
127 +
to drop a dependency on something you are about to abandon. Where a dependent referred to a line that no longer exists, the
124 128
reference is re-anchored to the nearest surviving line and `oxy abandon`
125 129
prints a note about it.
126 130
…
290 unchanged lines
modified docs/plan.md +3 −3
…
44 unchanged lines
45 45
- [x] Partial commits (spec §12.3): `oxy commit PATH..` and `oxy commit --interactive` (hunk = one edit or tree operation, dependent hunks pulled in), sharing the split machinery.
46 46
- [x] Blame (spec §27): `oxy blame PATH [--json]` from atom keys, with author and date; renames followed.
47 47
- [x] Bisect (spec §27): `oxy bisect start|good|bad|run|reset` over dependency-closed prefixes; state under `.oxy/bisect/`.
48 -
- [ ] Squash (spec §8.5): `oxy squash CHANGE --into DEST` generalising `amend --into`, with rewrite propagation.
49 -
- [ ] Rebase (spec §8.5): `oxy rebase CHANGE --onto HEADS..` re-deriving edits against a new base; refused on conflicted files.
50 -
- [ ] Guide, README and §26.1 updated for each; spec changelog per item.
48 +
- [x] Squash (spec §8.5): `oxy squash CHANGE --into DEST` generalising `amend --into`, with rewrite propagation.
49 +
- [x] Rebase (spec §8.5): `oxy rebase CHANGE --onto HEADS..` re-deriving edits against a new base; refused on conflicted files.
50 +
- [x] Guide, README and §26.1 updated for each; spec changelog per item.
51 51
52 52
## Log
53 53
…
20 unchanged lines
modified docs/spec.md +5 −3
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.8 |
5 +
| **Status** | Draft 0.8a |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
869 unchanged lines
878 878
|---|---|
879 879
| Objects, hashing, deterministic CBOR (§5) | Implemented; format 2 repositories, migration from format 1 |
880 880
| Change graph, commutation, conflicts and resolution (§6–§9) | Implemented, including order, delete/insert, tree, replace, and mode conflicts; workspace conflicts (§9.7) and exit status 3 are not detected |
881 -
| Rewrites (§8.4) | Implemented: describe, amend (also into older changes), split, abandon; orphaned references re-anchored |
881 +
| Rewrites (§8.4, §8.5) | Implemented: describe, amend (also into older changes), split (any change), abandon, squash, rebase; orphaned references re-anchored |
882 882
| Bookmarks, per-workspace current bookmark, merge by union (§10.1) | Implemented |
883 883
| Subrepositories with Oxygen and Git pins, `.oxymodules`, Git gitlink import and export (§7.8, §23) | Implemented |
884 884
| Partial and interactive commits (§12.3) | Implemented |
…
26 unchanged lines
911 911
912 912
---
913 913
914 -
*Changelog.* Draft 0.8 (2026-09-27): subrepositories with Oxygen and Git pins (§7.8, §23); squash and rebase defined as history edits (§8.5); multiple bookmarks per repository with a per-workspace current bookmark and `oxy merge` (§10.1); partial and interactive commits (§12.3); blame and bisect (§27); open questions moved to §28.
914 +
*Changelog.* Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23).
915 +
916 +
Draft 0.8 (2026-09-27): subrepositories with Oxygen and Git pins (§7.8, §23); squash and rebase defined as history edits (§8.5); multiple bookmarks per repository with a per-workspace current bookmark and `oxy merge` (§10.1); partial and interactive commits (§12.3); blame and bisect (§27); open questions moved to §28.
915 917
916 918
Draft 0.7 (2026-09-27): review pass. `oxy status --json` shape (§9.5); stat crawl required and file-system monitor recommended (§12.4); capabilities aligned with the implementation (§16.1); §24 lists the implemented command line and names the reserved commands; new §26.1 reference implementation status; §28 records the open workspace-conflict and lock items.
917 919
…
55 unchanged lines
modified src/cli/mod.rs +15 −0
…
78 unchanged lines
79 79
        #[arg(short, long)]
80 80
        interactive: bool,
81 81
    },
82 +
    /// Fold CHANGE into one of its dependencies (keeps that change's id)
83 +
    Squash {
84 +
        change: String,
85 +
        #[arg(long)]
86 +
        into: String,
87 +
    },
88 +
    /// Re-express CHANGE against another base state
89 +
    Rebase {
90 +
        change: String,
91 +
        /// The changes, bookmark or tag whose state becomes the new base
92 +
        #[arg(long, required = true, num_args = 1..)]
93 +
        onto: Vec<String>,
94 +
    },
82 95
    /// Show which change introduced each line of a file
83 96
    Blame {
84 97
        path: String,
…
402 unchanged lines
487 500
        Cmd::Status { json } => workspace::status(json),
488 501
        Cmd::Prompt { format, glyphs, color } => workspace::prompt(&format, glyphs, color.as_deref()),
489 502
        Cmd::Commit { message, depends_on, paths, interactive } => workspace::commit(&message, &depends_on, &paths, interactive),
503 +
        Cmd::Squash { change, into } => workspace::squash(&change, &into),
504 +
        Cmd::Rebase { change, onto } => workspace::rebase(&change, &onto),
490 505
        Cmd::Blame { path, json } => workspace::blame(&path, json),
491 506
        Cmd::Bisect { cmd } => match cmd {
492 507
            BisectCmd::Start => workspace::bisect_start(),
…
90 unchanged lines
modified src/cli/workspace.rs +25 −0
…
739 unchanged lines
740 740
    Ok(0)
741 741
}
742 742
743 +
pub fn squash(change: &str, into: &str) -> Result<i32> {
744 +
    let repo = open()?;
745 +
    let c = repo.resolve_change(change)?;
746 +
    let d = repo.resolve_change(into)?;
747 +
    let sp = ui::spinner("Squashing…");
748 +
    let h = repo.squash(&c, &d)?;
749 +
    let new = repo.store().get_change(&h)?;
750 +
    sp.done(&format!("{} into {} {}  {}", ui::done("Squashed"), ui::id(short_id(&new)), ui::hash(format!("#{}", h.short(12))), ui::subject(subject_of(&new.message))));
751 +
    Ok(0)
752 +
}
753 +
754 +
pub fn rebase(change: &str, onto: &[String]) -> Result<i32> {
755 +
    let repo = open()?;
756 +
    let c = repo.resolve_change(change)?;
757 +
    let mut heads = Vec::new();
758 +
    for o in onto {
759 +
        heads.extend(repo.resolve_heads(o)?);
760 +
    }
761 +
    let sp = ui::spinner("Rebasing…");
762 +
    let h = repo.rebase(&c, &heads)?;
763 +
    let new = repo.store().get_change(&h)?;
764 +
    sp.done(&format!("{} {} {}  {}", ui::done("Rebased"), ui::id(short_id(&new)), ui::hash(format!("#{}", h.short(12))), ui::dim(format!("now depends on {}", plural(new.deps.len(), "change")))));
765 +
    Ok(0)
766 +
}
767 +