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 | + |