OXYGENHUB

History editing: oxy split works on any change, splitting a non-head by node with dependents rewritten exactly b837d2b55853

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 12:56 UTC
Signature
✔ signed by Andy McCall
Hash
b837d2b5585370b6f6545313c20e0d2e307f159d3c966709d5718730feae0e01
Depends on
23b217d75848 614541c3cebd 982864068a59 9ac1fbe211b4
Depended on by
57eaac9107df 6f8aeb996558 75167672ebcd 7b9809e67c3d
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
9 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified crates/oxy-core/src/repo/history.rs +109 −12
…
16 unchanged lines
17 17
    fn rewritable_head(&self, hash: &ChangeHash) -> Result<Change> {
18 18
        let heads = self.bookmark_heads()?;
19 19
        if !heads.contains(hash) {
20 -
            return fail(format!(
21 -
                "change #{} is not a head of {}; rewriting changes that others depend on is not implemented yet",
22 -
                hash.short(12),
23 -
                self.config.bookmark
24 -
            ));
20 +
            return fail(format!("change #{} is not a head of {}; amend works on heads only (describe, split, and abandon work anywhere)", hash.short(12), self.config.bookmark));
25 21
        }
26 22
        let c = self.store.get_change(hash)?;
27 23
        if c.kind == ChangeKind::WorkingCopy {
…
356 unchanged lines
384 380
        Ok(new_hash)
385 381
    }
386 382
387 -
    /// Split a head into two changes: one holding the head's effect on
388 -
    /// `paths`, the other the rest (keeping the head's ChangeId). The
389 -
    /// working copy is unaffected.
383 +
    /// Split a change into two: one holding its effect on `paths`, the
384 +
    /// other the rest (keeping the ChangeId). A head is rebuilt from its
385 +
    /// materialized files; any other change is split by node, which is
386 +
    /// exact, and is refused when a dependent refers to the split-off
387 +
    /// nodes. The working copy is unaffected.
390 388
    pub fn split(&self, hash: &ChangeHash, paths: &[String]) -> Result<(ChangeHash, ChangeHash)> {
391 -
        let old = self.rewritable_head(hash)?;
392 389
        if paths.is_empty() {
393 390
            return fail("name at least one path to split out");
394 391
        }
392 +
        if !self.bookmark_heads()?.contains(hash) {
393 +
            return self.split_by_node(hash, paths);
394 +
        }
395 +
        let old = self.rewritable_head(hash)?;
395 396
        let beneath = self.load_state(&old.deps)?;
396 397
        let beneath_snapshot = beneath.materialize();
397 398
        let full = self.load_state(&[*hash])?;
…
65 unchanged lines
463 464
        c2.deps = d2.into_iter().collect();
464 465
        c2.normalize();
465 466
        let h2 = self.replace_head(hash, Some(&c2), &format!("oxy split {} {}", old.change_id.short(8), paths.join(" ")))?.unwrap();
466 -
        // c1 may be a head of its own if c2 does not depend on it.
467 +
        self.add_to_bookmark(h1)?;
468 +
        Ok((h1, h2))
469 +
    }
470 +
471 +
    /// Put `h` into the bookmark's state (a head unless something already
472 +
    /// depends on it) and under the working copy.
473 +
    fn add_to_bookmark(&self, h: ChangeHash) -> Result<()> {
467 474
        let (op_ref, op) = self.head_op()?;
468 475
        let bookmark = self.config.bookmark.clone();
469 476
        let mut heads = self.heads_of(&op.view, &bookmark)?;
470 -
        heads.push(h1);
477 +
        heads.push(h);
471 478
        let st = self.load_state(&heads)?;
472 479
        let heads = st.maximal(&heads);
473 480
        let mut view = op.view.clone();
474 481
        view.bookmarks.insert(bookmark, self.store.put_state(&StateObject::new(heads.clone()))?);
475 482
        let (_, wc) = self.working_copy()?;
476 483
        let mut base = wc.deps.clone();
477 -
        base.push(h1);
484 +
        base.push(h);
478 485
        let wc_base = self.load_state(&base)?.maximal(&base);
479 486
        let fresh = self.new_working_copy(wc_base, &wc.message);
480 487
        view.workspaces.insert(self.workspace, self.store.put_change(&fresh)?);
481 488
        self.record_op(&[op_ref], "oxy split (heads)", view)?;
482 489
        self.drop_manifest();
490 +
        Ok(())
491 +
    }
492 +
493 +
    /// Split a non-head change by node: the operations and edits on nodes
494 +
    /// under `paths` become a new change, the rest keeps the ChangeId with
495 +
    /// every remaining atom at its old index, so dependents are rewritten
496 +
    /// exactly. Refused when a dependent touches a split-off node.
497 +
    fn split_by_node(&self, hash: &ChangeHash, paths: &[String]) -> Result<(ChangeHash, ChangeHash)> {
498 +
        let old = self.store.get_change(hash)?;
499 +
        if old.kind == ChangeKind::WorkingCopy {
500 +
            return fail("that is the working copy; commit it instead");
501 +
        }
502 +
        let selected = |p: &str| paths.iter().any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))));
503 +
        let beneath = self.load_state(&old.deps)?;
504 +
        let full = self.load_state(&[*hash])?;
505 +
        let mut removed: BTreeSet<NodeId> = BTreeSet::new();
506 +
        for snap in [beneath.materialize(), full.materialize()] {
507 +
            for (p, f) in &snap.files {
508 +
                if selected(p) {
509 +
                    removed.insert(f.node);
510 +
                }
511 +
            }
512 +
        }
513 +
        // Nodes the change itself adds under a removed directory.
514 +
        for op in &old.tree_ops {
515 +
            if let TreeOp::AddNode { node, parent, .. } = op {
516 +
                if removed.contains(parent) {
517 +
                    removed.insert(*node);
518 +
                }
519 +
            }
520 +
        }
521 +
        if removed.is_empty() {
522 +
            return fail("the named paths are not touched by that change");
523 +
        }
524 +
        // Dependents must not touch the split-off nodes: their references
525 +
        // would have to move to the new change, which a rewrite cannot express.
526 +
        let heads = self.bookmark_heads()?;
527 +
        let (wc_hash, _) = self.working_copy()?;
528 +
        let mut all = heads.clone();
529 +
        all.push(wc_hash);
530 +
        let state = self.load_state(&all)?;
531 +
        for (h, meta) in state.changes() {
532 +
            if !meta.deps.contains(hash) {
533 +
                continue;
534 +
            }
535 +
            let d = self.store.get_change(h)?;
536 +
            if d.tree_ops.iter().any(|op| removed.contains(&op.node())) || d.edits.iter().any(|e| removed.contains(&e.node())) {
537 +
                return fail(format!("change {} depends on the split-off paths of {}; split it first", d.change_id.short(8), old.change_id.short(8)));
538 +
            }
539 +
        }
540 +
        let mut c1 = Change {
541 +
            change_id: ChangeId::random(),
542 +
            deps: old.deps.clone(),
543 +
            author: old.author,
544 +
            hlc: Hlc::now(),
545 +
            message: format!("{} (part 1: {})", old.message.lines().next().unwrap_or(""), paths.join(", ")),
546 +
            tree_ops: old.tree_ops.iter().filter(|op| removed.contains(&op.node())).cloned().collect(),
547 +
            edits: old.edits.iter().filter(|e| removed.contains(&e.node())).cloned().collect(),
548 +
            kind: ChangeKind::Normal,
549 +
        };
550 +
        let mut c2 = old.clone();
551 +
        c2.hlc = Hlc::now();
552 +
        let mut ops_map: HashMap<u32, u32> = HashMap::new();
553 +
        let mut kept_ops = Vec::new();
554 +
        for (i, op) in old.tree_ops.iter().enumerate() {
555 +
            if !removed.contains(&op.node()) {
556 +
                ops_map.insert(i as u32, kept_ops.len() as u32);
557 +
                kept_ops.push(op.clone());
558 +
            }
559 +
        }
560 +
        c2.tree_ops = kept_ops;
561 +
        c2.edits.retain(|e| !removed.contains(&e.node()));
562 +
        if c1.tree_ops.is_empty() && c1.edits.is_empty() {
563 +
            return fail("the named paths are not touched by that change");
564 +
        }
565 +
        if c2.tree_ops.is_empty() && c2.edits.is_empty() {
566 +
            return fail("everything in that change is under the named paths; use `oxy abandon` instead");
567 +
        }
568 +
        let mut d1: BTreeSet<ChangeHash> = referenced_changes(&c1, &beneath);
569 +
        d1.extend(old.deps.iter().copied());
570 +
        c1.deps = d1.into_iter().collect();
571 +
        c1.normalize();
572 +
        let mut d2: BTreeSet<ChangeHash> = referenced_changes(&c2, &beneath);
573 +
        d2.extend(old.deps.iter().copied());
574 +
        c2.deps = d2.into_iter().collect();
575 +
        c2.normalize();
576 +
        let corr = Correspondence { atoms: Correspondence::identity(&old).atoms, ops: ops_map };
577 +
        let h2 = self.rewrite_change(hash, Some(&c2), &corr, &format!("oxy split {} {}", old.change_id.short(8), paths.join(" ")))?.unwrap();
578 +
        let h1 = self.store_signed(&c1)?;
579 +
        self.add_to_bookmark(h1)?;
483 580
        Ok((h1, h2))
484 581
    }
485 582
}
modified crates/oxy-core/src/repo/tests.rs +43 −0
…
2055 unchanged lines
2056 2056
    let _ = h1;
2057 2057
}
2058 2058
2059 +
#[test]
2060 +
fn split_works_on_non_heads_by_node() {
2061 +
    hermetic();
2062 +
    let dir = tempfile::tempdir().unwrap();
2063 +
    let root = dir.path();
2064 +
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
2065 +
    write(root, "a.txt", b"one\n");
2066 +
    write(root, "junk/x.lock", b"lock\n");
2067 +
    let h1 = repo.commit("work plus junk", &[]).unwrap();
2068 +
    write(root, "a.txt", b"one\ntwo\n");
2069 +
    let h2 = repo.commit("more work", &[]).unwrap();
2070 +
    assert_eq!(repo.store().get_change(&h2).unwrap().deps, vec![h1]);
2071 +
    let id1 = repo.store().get_change(&h1).unwrap().change_id;
2072 +
    // h1 is not a head: split it by node.
2073 +
    let (hj, h1b) = repo.split(&h1, &["junk".into()]).unwrap();
2074 +
    assert_ne!(h1b, h1);
2075 +
    assert_eq!(repo.store().get_change(&h1b).unwrap().change_id, id1, "the rest keeps the ChangeId");
2076 +
    let junk = repo.store().get_change(&hj).unwrap();
2077 +
    assert!(junk.tree_ops.iter().all(|op| matches!(op, TreeOp::AddNode { name, .. } if name == "junk" || name == "x.lock")));
2078 +
    let heads = repo.bookmark_heads().unwrap();
2079 +
    assert!(heads.contains(&hj), "the split-off part is a head of its own");
2080 +
    let snap = repo.load_state(&heads).unwrap().materialize();
2081 +
    assert!(matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n"), "the dependent was rewritten onto the new hash");
2082 +
    assert!(snap.files.contains_key("junk/x.lock"));
2083 +
    let h2b = heads.iter().find(|h| repo.store().get_change(h).unwrap().message == "more work").copied().unwrap();
2084 +
    assert_eq!(repo.store().get_change(&h2b).unwrap().deps, vec![h1b]);
2085 +
    assert!(!repo.snapshot("status").unwrap().changed);
2086 +
    // Abandoning the split-off head removes the junk from history.
2087 +
    repo.abandon(&hj).unwrap();
2088 +
    let heads = repo.bookmark_heads().unwrap();
2089 +
    let snap = repo.load_state(&heads).unwrap().materialize();
2090 +
    assert!(!snap.files.contains_key("junk/x.lock") && !snap.files.contains_key("junk"));
2091 +
    assert!(snap.files.contains_key("a.txt"));
2092 +
    // A dependent that touches the split-off paths blocks the split.
2093 +
    write(root, "junk/y.lock", b"y\n");
2094 +
    let hb = repo.commit("junk base", &[]).unwrap();
2095 +
    write(root, "junk/y.lock", b"yy\n");
2096 +
    write(root, "b.txt", b"b\n");
2097 +
    repo.commit("touches junk", &[]).unwrap();
2098 +
    let err = repo.split(&hb, &["junk".into()]).unwrap_err().to_string();
2099 +
    assert!(err.contains("depends on the split-off paths"), "{err}");
2100 +
}
2101 +