OXYGENHUB

History editing for heads: oxy describe, oxy amend, oxy abandon (spec §8.4, heads only) 614541c3cebd

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 19:31 UTC
Signature
✔ signed by Andy McCall
Hash
614541c3cebd7eeeeea33a12de1a9c05d5c2c6574b87cf502d5f1171b922d012
Depends on
982864068a59 d02e545dc265 fce44ae9ad29
Depended on by
157d02e95586 69953b507188 530a0fc650a2 2c9ddac3c387 d8d9367878a6 631fc8970bd0 5b4c51307b14 98a1620a3840 e266b7d4065c f9466021286a 06d16dc2bc13 2306a4d24779 a87c7b9d563a 01a47cd7f0c8 57eaac9107df 6caacfff1d4f 6f8aeb996558 6a81dbab471d cc7a00565161 0bdc3f91a39f d42369abf180 3e5772ccf9da 387335ff7f4d 75167672ebcd c6aa5bd9d1c8 8b678b298fee 728da0f92161 4dee277c327a b837d2b55853 9ac1fbe211b4 b7f102fcbb17 8f10a07a1dbe 6706bdcfb63e 94084087a876 23b217d75848 6d3eefbce2f0 cdb5beba7f05 4e88e774777a 135212de5370 252e8cb23fbc c3069537d7c5 79ab8528ff62 ba160f60219e c07e0e7d2d85 79d03f826ecd
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
16 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/oxy-core/src/repo/history.rs +136 −0
1 +
//! History editing (spec §8.4): describe, amend, abandon.
2 +
//!
3 +
//! Only heads can be rewritten so far. A head has no dependents, so
4 +
//! rewriting it needs no propagation; rewriting a change with dependents
5 +
//! (translating their atom and placement references) is not implemented.
6 +
//! The rewritten change keeps its ChangeId and gets a new hash; the old
7 +
//! object stays in the store until garbage collection.
8 +
9 +
use super::*;
10 +
11 +
impl Repo {
12 +
    /// The bookmark head `hash`, or an error explaining why it cannot be rewritten.
13 +
    fn rewritable_head(&self, hash: &ChangeHash) -> Result<Change> {
14 +
        let heads = self.bookmark_heads()?;
15 +
        if !heads.contains(hash) {
16 +
            return fail(format!(
17 +
                "change #{} is not a head of {}; rewriting changes that others depend on is not implemented yet",
18 +
                hash.short(12),
19 +
                self.config.bookmark
20 +
            ));
21 +
        }
22 +
        let c = self.store.get_change(hash)?;
23 +
        if c.kind == ChangeKind::WorkingCopy {
24 +
            return fail("that is the working copy; commit it instead");
25 +
        }
26 +
        Ok(c)
27 +
    }
28 +
29 +
    /// Replace head `old` with `new` (or with nothing) on the bookmark and
30 +
    /// restart the working copy on the resulting base. Files on disk are not
31 +
    /// touched: the next snapshot diffs them against the new base.
32 +
    fn replace_head(&self, old: &ChangeHash, new: Option<&Change>, command: &str) -> Result<Option<ChangeHash>> {
33 +
        let new_hash = match new {
34 +
            Some(c) => Some(self.store_signed(c)?),
35 +
            None => None,
36 +
        };
37 +
        let old_change = self.store.get_change(old)?;
38 +
        let (op_ref, op) = self.head_op()?;
39 +
        let bookmark = self.config.bookmark.clone();
40 +
        let heads = self.heads_of(&op.view, &bookmark)?;
41 +
        let replace = |set: &[ChangeHash]| -> Vec<ChangeHash> {
42 +
            let mut out: Vec<ChangeHash> = set.iter().filter(|h| *h != old).copied().collect();
43 +
            match new_hash {
44 +
                Some(n) => out.push(n),
45 +
                None => out.extend(old_change.deps.iter().copied()),
46 +
            }
47 +
            out
48 +
        };
49 +
        let candidates = replace(&heads);
50 +
        let st = self.load_state(&candidates)?;
51 +
        let new_heads = st.maximal(&candidates);
52 +
53 +
        let (_, wc) = self.working_copy()?;
54 +
        let base = self.load_state(&wc.deps)?;
55 +
        let wc_base = base.maximal(&wc.deps);
56 +
        let wc_base = if wc_base.contains(old) {
57 +
            let c = replace(&wc_base);
58 +
            let st = self.load_state(&c)?;
59 +
            st.maximal(&c)
60 +
        } else {
61 +
            wc_base
62 +
        };
63 +
        let fresh = self.new_working_copy(wc_base, &wc.message);
64 +
        let wc_hash = self.store.put_change(&fresh)?;
65 +
66 +
        let mut view = op.view.clone();
67 +
        view.bookmarks.insert(bookmark, self.store.put_state(&StateObject::new(new_heads))?);
68 +
        view.workspaces.insert(self.workspace, wc_hash);
69 +
        self.record_op(&[op_ref], command, view)?;
70 +
        Ok(new_hash)
71 +
    }
72 +
73 +
    /// Change the message of a head.
74 +
    pub fn describe(&self, hash: &ChangeHash, message: &str) -> Result<ChangeHash> {
75 +
        if message.trim().is_empty() {
76 +
            return fail("a message is required");
77 +
        }
78 +
        let mut c = self.rewritable_head(hash)?;
79 +
        if c.message == message.trim_end() {
80 +
            return Ok(*hash);
81 +
        }
82 +
        c.message = message.trim_end().to_string();
83 +
        c.hlc = Hlc::now();
84 +
        Ok(self.replace_head(hash, Some(&c), &format!("oxy describe {} -m {message:?}", c.change_id.short(8)))?.unwrap())
85 +
    }
86 +
87 +
    /// Fold the working copy into the head it sits on: the head is rebuilt
88 +
    /// as the difference between the state beneath it and the files on disk.
89 +
    pub fn amend(&self, message: Option<&str>) -> Result<ChangeHash> {
90 +
        let snap = self.snapshot("oxy amend")?;
91 +
        if snap.statuses.iter().any(|s| s.status == ChangeStatus::ConflictEdited) {
92 +
            return fail("a file still contains conflict markers");
93 +
        }
94 +
        let base_heads = snap.base.maximal(&snap.wc.deps);
95 +
        let [head] = base_heads.as_slice() else {
96 +
            return fail(format!("the working copy sits on {} heads; check out the single change to amend", base_heads.len()));
97 +
        };
98 +
        let head = *head;
99 +
        let old = self.rewritable_head(&head)?;
100 +
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() && message.is_none() {
101 +
            return fail("nothing to amend: the working copy is clean and no new message was given");
102 +
        }
103 +
        // Rebuild against the state beneath the head.
104 +
        let beneath = self.load_state(&old.deps)?;
105 +
        let beneath_snapshot = beneath.materialize();
106 +
        let reuse = added_node_paths(&old, &beneath_snapshot);
107 +
        let built = self.build_against(&beneath, &beneath_snapshot, reuse)?;
108 +
        if built.tree_ops.is_empty() && built.edits.is_empty() {
109 +
            return fail("amending would leave the change empty; use `oxy abandon` to drop it");
110 +
        }
111 +
        let mut deps: BTreeSet<ChangeHash> = old.deps.iter().copied().collect();
112 +
        deps.extend(built.contributors.iter().copied());
113 +
        let mut new = Change {
114 +
            change_id: old.change_id,
115 +
            deps: vec![],
116 +
            author: self.author(),
117 +
            hlc: Hlc::now(),
118 +
            message: message.map(|m| m.trim_end().to_string()).unwrap_or(old.message.clone()),
119 +
            tree_ops: built.tree_ops,
120 +
            edits: built.edits,
121 +
            kind: if built.resolves || old.kind == ChangeKind::Resolution { ChangeKind::Resolution } else { ChangeKind::Normal },
122 +
        };
123 +
        deps.extend(referenced_changes(&new, &beneath));
124 +
        new.deps = deps.into_iter().collect();
125 +
        new.normalize();
126 +
        Ok(self.replace_head(&head, Some(&new), &format!("oxy amend {}", old.change_id.short(8)))?.unwrap())
127 +
    }
128 +
129 +
    /// Drop a head from the bookmark. Its content stays on disk as
130 +
    /// uncommitted work if the working copy sat on it.
131 +
    pub fn abandon(&self, hash: &ChangeHash) -> Result<()> {
132 +
        let c = self.rewritable_head(hash)?;
133 +
        self.replace_head(hash, None, &format!("oxy abandon {}", c.change_id.short(8)))?;
134 +
        Ok(())
135 +
    }
136 +
}
modified crates/oxy-core/src/repo/mod.rs +1 −0
…
52 unchanged lines
53 53
54 54
mod checkout;
55 55
mod config;
56 +
mod history;
56 57
mod identity;
57 58
mod policy;
58 59
mod snapshot;
…
518 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +49 −26
…
26 unchanged lines
27 27
    pub status: ChangeStatus,
28 28
}
29 29
30 +
/// Operations of a change built from the working directory against a base.
31 +
pub(crate) struct BuiltChange {
32 +
    pub tree_ops: Vec<TreeOp>,
33 +
    pub edits: Vec<Edit>,
34 +
    pub contributors: Vec<ChangeHash>,
35 +
    pub resolves: bool,
36 +
    pub statuses: Vec<PathStatus>,
37 +
}
38 +
30 39
/// Result of snapshotting the working copy.
31 40
pub struct SnapshotResult {
32 41
    pub wc_hash: ChangeHash,
…
15 unchanged lines
48 57
}
49 58
50 59
impl Repo {
51 -
    /// Snapshot the working copy into the working-copy change (spec §12.3).
52 -
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
53 -
        let (op_ref, op) = self.head_op()?;
54 -
        let (old_hash, old_wc) = self.working_copy()?;
55 -
        let base = self.load_state(&old_wc.deps)?;
56 -
        let base_snapshot = base.materialize();
60 +
    /// Diff the files on disk against `base` and build the operations of a
61 +
    /// change from `base` to the working directory. `reuse` maps paths to the
62 +
    /// NodeIds an earlier version of the same change assigned to new paths.
63 +
    pub(crate) fn build_against(&self, base: &State, base_snapshot: &Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Result<BuiltChange> {
57 64
        let fs_entries = self.walk_working_copy()?;
58 -
59 -
        // Reuse the NodeIds the previous working-copy version assigned to new
60 -
        // paths, so an unchanged working copy hashes identically (spec §12.3).
61 -
        let reuse = added_node_paths(&old_wc, &base_snapshot);
62 -
        let mut b = ChangeBuilder::new(&base, &base_snapshot, reuse);
65 +
        let mut b = ChangeBuilder::new(base, base_snapshot, reuse);
63 66
        for (path, fs_entry) in &fs_entries {
64 67
            match base_snapshot.files.get(path) {
65 68
                None => b.add_path(self, path, fs_entry)?,
…
9 unchanged lines
75 78
                b.remove_path(path, expected);
76 79
            }
77 80
        }
81 +
        let mut statuses = b.statuses;
82 +
        statuses.sort_by(|a, b| a.path.cmp(&b.path));
83 +
        Ok(BuiltChange { tree_ops: b.tree_ops, edits: b.edits, contributors: b.contributors.into_iter().collect(), resolves: b.resolves, statuses })
84 +
    }
78 85
86 +
    /// Store a change and sign it when `core.sign = change`.
87 +
    pub(crate) fn store_signed(&self, change: &Change) -> Result<ChangeHash> {
88 +
        if self.config.sign_changes && self.secret_key()?.is_none() {
89 +
            return fail("core.sign is \"change\" but no secret key is available for author.key");
90 +
        }
91 +
        let hash = self.store.put_change(change)?;
92 +
        if self.config.sign_changes {
93 +
            self.sign_change(&hash)?;
94 +
        }
95 +
        Ok(hash)
96 +
    }
97 +
98 +
    /// Snapshot the working copy into the working-copy change (spec §12.3).
99 +
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
100 +
        let (op_ref, op) = self.head_op()?;
101 +
        let (old_hash, old_wc) = self.working_copy()?;
102 +
        let base = self.load_state(&old_wc.deps)?;
103 +
        let base_snapshot = base.materialize();
104 +
105 +
        // Reuse the NodeIds the previous working-copy version assigned to new
106 +
        // paths, so an unchanged working copy hashes identically (spec §12.3).
107 +
        let reuse = added_node_paths(&old_wc, &base_snapshot);
108 +
        let built = self.build_against(&base, &base_snapshot, reuse)?;
109 +
79 110
        let mut wc = Change {
80 111
            change_id: old_wc.change_id,
81 112
            deps: vec![],
82 113
            author: self.author(),
83 114
            hlc: old_wc.hlc,
84 115
            message: old_wc.message.clone(),
85 -
            tree_ops: b.tree_ops,
86 -
            edits: b.edits,
116 +
            tree_ops: built.tree_ops,
117 +
            edits: built.edits,
87 118
            kind: ChangeKind::WorkingCopy,
88 119
        };
89 120
        let base_heads = base.maximal(&old_wc.deps);
90 121
        let mut deps: BTreeSet<ChangeHash> = base_heads.into_iter().collect();
91 122
        deps.extend(referenced_changes(&wc, &base));
92 -
        deps.extend(b.contributors.iter().copied());
123 +
        deps.extend(built.contributors.iter().copied());
93 124
        wc.deps = deps.into_iter().collect();
94 125
        wc.normalize();
95 -
        let resolves = b.resolves;
96 -
        let contributors: Vec<ChangeHash> = b.contributors.iter().copied().collect();
97 -
98 -
        let mut statuses = b.statuses;
99 -
        statuses.sort_by(|a, b| a.path.cmp(&b.path));
126 +
        let resolves = built.resolves;
127 +
        let contributors = built.contributors;
128 +
        let statuses = built.statuses;
100 129
        // Empty snapshot: hash the candidate before storing; only record an op on change.
101 130
        let new_hash = wc.hash();
102 131
        let changed = new_hash != old_hash;
…
30 unchanged lines
133 162
            }
134 163
            deps.insert(*d);
135 164
        }
136 -
        if self.config.sign_changes && self.secret_key()?.is_none() {
137 -
            return fail("core.sign is \"change\" but no secret key is available for author.key");
138 -
        }
139 165
        let mut change = Change {
140 166
            change_id: snap.wc.change_id,
141 167
            deps: deps.into_iter().collect(),
…
5 unchanged lines
147 173
            kind: if snap.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
148 174
        };
149 175
        change.normalize();
150 -
        let hash = self.store.put_change(&change)?;
151 -
        if self.config.sign_changes {
152 -
            self.sign_change(&hash)?;
153 -
        }
176 +
        let hash = self.store_signed(&change)?;
154 177
155 178
        // New bookmark heads: maximal elements of old heads plus the new change.
156 179
        // The working copy continues on its own base plus the new change, which
…
401 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +59 −0
…
785 unchanged lines
786 786
    assert_eq!(repo.gc().unwrap().objects, repo.store().list().unwrap().len() - before.len());
787 787
    assert_eq!(repo.store().pack_count(), 2);
788 788
}
789 +
790 +
#[test]
791 +
fn describe_amend_abandon_heads() {
792 +
    hermetic();
793 +
    let dir = tempfile::tempdir().unwrap();
794 +
    let root = dir.path();
795 +
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
796 +
    repo.create_identity("Ada", "a@b", IdentityScope::User).unwrap();
797 +
    repo.set_user_config("core.sign", "change").unwrap();
798 +
    write(root, "a.txt", b"one\n");
799 +
    let h1 = repo.commit("first", &[]).unwrap();
800 +
    write(root, "a.txt", b"one\ntwo\n");
801 +
    let h2 = repo.commit("secnod", &[]).unwrap();
802 +
    let id2 = repo.store().get_change(&h2).unwrap().change_id;
803 +
804 +
    // describe: same ChangeId, new hash, bookmark and working copy follow.
805 +
    let h2b = repo.describe(&h2, "second").unwrap();
806 +
    assert_ne!(h2b, h2);
807 +
    let c = repo.store().get_change(&h2b).unwrap();
808 +
    assert_eq!((c.change_id, c.message.as_str(), c.deps.clone()), (id2, "second", vec![h1]));
809 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2b]);
810 +
    assert!(matches!(repo.verify_change(&h2b).unwrap(), SignatureStatus::Valid { .. }));
811 +
    let s = repo.snapshot("status").unwrap();
812 +
    assert!(s.statuses.is_empty(), "{:?}", s.statuses);
813 +
    assert_eq!(repo.resolve_change(&id2.short(8)).unwrap(), h2b, "the old object is no longer reachable from the log");
814 +
    // Non-heads cannot be rewritten yet.
815 +
    assert!(repo.describe(&h1, "x").is_err());
816 +
817 +
    // amend: fold an edit into the head; the result is one change from h1.
818 +
    write(root, "a.txt", b"one\ntwo\nthree\n");
819 +
    write(root, "b.txt", b"b\n");
820 +
    let h2c = repo.amend(None).unwrap();
821 +
    let c = repo.store().get_change(&h2c).unwrap();
822 +
    assert_eq!(c.change_id, id2);
823 +
    assert_eq!(c.message, "second");
824 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2c]);
825 +
    assert_eq!(repo.log().unwrap().len(), 3, "working copy, amended head, first");
826 +
    let snap = repo.load_state(&[h2c]).unwrap().materialize();
827 +
    assert!(matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\nthree\n"));
828 +
    assert!(snap.files.contains_key("b.txt"));
829 +
    assert!(!repo.snapshot("status").unwrap().changed);
830 +
    assert!(repo.amend(None).is_err(), "clean working copy and no message");
831 +
    let h2d = repo.amend(Some("second, amended")).unwrap();
832 +
    assert_eq!(repo.store().get_change(&h2d).unwrap().message, "second, amended");
833 +
834 +
    // abandon: the head goes, its content stays as uncommitted work.
835 +
    repo.abandon(&h2d).unwrap();
836 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
837 +
    let s = repo.snapshot("status").unwrap();
838 +
    let paths: Vec<_> = s.statuses.iter().map(|p| (p.path.as_str(), p.status)).collect();
839 +
    assert_eq!(paths, vec![("a.txt", ChangeStatus::Modified), ("b.txt", ChangeStatus::Added)]);
840 +
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\ntwo\nthree\n");
841 +
    let h3 = repo.commit("recommitted", &[]).unwrap();
842 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h3]);
843 +
    // undo brings the abandoned head back.
844 +
    repo.undo().unwrap();
845 +
    repo.undo().unwrap();
846 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2d]);
847 +
}
modified src/cli/mod.rs +16 −0
…
36 unchanged lines
37 37
    Log,
38 38
    /// Show what the working copy changes against its base
39 39
    Diff,
40 +
    /// Change the message of a head (default: the head the working copy sits on)
41 +
    Describe {
42 +
        #[arg(short, long)]
43 +
        message: String,
44 +
        change: Option<String>,
45 +
    },
46 +
    /// Fold the working copy into the head it sits on
47 +
    Amend {
48 +
        #[arg(short, long)]
49 +
        message: Option<String>,
50 +
    },
51 +
    /// Drop a head from the bookmark; its content stays on disk as uncommitted work
52 +
    Abandon { change: String },
40 53
    /// Show one change (ChangeId prefix, #hash prefix, or @)
41 54
    Show { change: String },
42 55
    /// Move the working copy to the state formed by CHANGE... (default: the bookmark heads)
…
131 unchanged lines
174 187
        Cmd::Commit { message, depends_on } => workspace::commit(&message, &depends_on),
175 188
        Cmd::Log => workspace::log(),
176 189
        Cmd::Diff => workspace::diff(),
190 +
        Cmd::Describe { message, change } => workspace::describe(&message, change.as_deref()),
191 +
        Cmd::Amend { message } => workspace::amend(message.as_deref()),
192 +
        Cmd::Abandon { change } => workspace::abandon(&change),
177 193
        Cmd::Show { change } => workspace::show(&change),
178 194
        Cmd::Checkout { changes, discard } => workspace::checkout(&changes, discard),
179 195
        Cmd::Resolve => workspace::resolve(),
…
28 unchanged lines
modified src/cli/workspace.rs +39 −0
…
200 unchanged lines
201 201
    }
202 202
    Ok(0)
203 203
}
204 +
205 +
/// The single head the working copy sits on, for commands that default to it.
206 +
fn current_head(repo: &Repo) -> Result<oxy_core::hash::ChangeHash> {
207 +
    let snap = repo.snapshot("oxy")?;
208 +
    let base = snap.base.maximal(&snap.wc.deps);
209 +
    match base.as_slice() {
210 +
        [h] => Ok(*h),
211 +
        _ => anyhow::bail!("the working copy sits on {} heads; name the change explicitly", base.len()),
212 +
    }
213 +
}
214 +
215 +
pub fn describe(message: &str, change: Option<&str>) -> Result<i32> {
216 +
    let repo = open()?;
217 +
    let h = match change {
218 +
        Some(c) => repo.resolve_change(c)?,
219 +
        None => current_head(&repo)?,
220 +
    };
221 +
    let new = repo.describe(&h, message)?;
222 +
    let c = repo.store().get_change(&new)?;
223 +
    println!("Described {} #{}: {}", short_id(&c), new.short(12), message.lines().next().unwrap_or(""));
224 +
    Ok(0)
225 +
}
226 +
227 +
pub fn amend(message: Option<&str>) -> Result<i32> {
228 +
    let repo = open()?;
229 +
    let new = repo.amend(message)?;
230 +
    let c = repo.store().get_change(&new)?;
231 +
    println!("Amended {} #{}: {}", short_id(&c), new.short(12), c.message.lines().next().unwrap_or(""));
232 +
    Ok(0)
233 +
}
234 +
235 +
pub fn abandon(change: &str) -> Result<i32> {
236 +
    let repo = open()?;
237 +
    let h = repo.resolve_change(change)?;
238 +
    let c = repo.store().get_change(&h)?;
239 +
    repo.abandon(&h)?;
240 +
    println!("Abandoned {} #{}; its content remains on disk as uncommitted work if the working copy sat on it.", short_id(&c), h.short(12));
241 +
    Ok(0)
242 +
}