Skip to main content

mkit_cli/commands/
revert.rs

1//! `mkit revert <commit> | --continue | --abort` — create a new commit
2//! that undoes a previous commit, with the resolvable-conflict workflow.
3//!
4//! Revert is the inverse of cherry-pick (it applies the *reverse* of the
5//! target's diff) and a normal **forward** commit — it does not rewrite
6//! history, so the reverted commit stays reachable and it is not gated on
7//! gc/recovery. On a clean revert we commit the reversed tree with a
8//! generated `Revert "<subject>"` message. On conflict we materialise the
9//! conflict material, persist `REVERT_HEAD`/`REVERT_MSG`/`ORIG_HEAD` + the
10//! `mkit-conflicts` sidecar, and exit non-zero; the user resolves,
11//! `mkit add`s, then runs `mkit revert --continue` (or `--abort`).
12
13use std::io::Write;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use clap::Parser;
17use mkit_core::hash::Hash;
18use mkit_core::object::{Commit, Object};
19use mkit_core::ops::conflict_state::{
20    self, RevertState, in_progress_op_name, is_revert_in_progress,
21};
22use mkit_core::ops::revert::revert as revert_tree;
23use mkit_core::refs::{self, Head};
24use mkit_core::serialize;
25use mkit_core::store::ObjectStore;
26use mkit_core::worktree;
27
28use crate::clap_shim;
29use crate::config;
30use crate::exit;
31use crate::format;
32
33#[derive(Debug, Parser)]
34#[command(
35    name = "mkit revert",
36    about = "Create a new commit that undoes a previous commit."
37)]
38struct RevertOpts {
39    /// Continue an in-progress revert after resolving conflicts.
40    #[arg(long = "continue", conflicts_with_all = ["abort", "commit"])]
41    cont: bool,
42    /// Abort the in-progress revert and restore the original HEAD.
43    #[arg(long, conflicts_with_all = ["cont", "commit"])]
44    abort: bool,
45    /// Stage the reverted tree in the index + worktree without creating a
46    /// commit (like `git revert --no-commit`). Applies to a clean revert;
47    /// if the revert conflicts, resolve it with `--continue` / `--abort`.
48    #[arg(short = 'n', long = "no-commit", conflicts_with_all = ["cont", "abort"])]
49    no_commit: bool,
50    /// Commit to revert: a ref, full/short hash, or `HEAD~n` revspec.
51    commit: Option<String>,
52}
53
54#[must_use]
55pub fn run(args: &[String]) -> u8 {
56    let opts = match clap_shim::parse::<RevertOpts>("mkit revert", args) {
57        Ok(o) => o,
58        Err(code) => return code,
59    };
60    let cwd = match std::env::current_dir() {
61        Ok(p) => p,
62        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
63    };
64    let store = match ObjectStore::open(&cwd) {
65        Ok(s) => s,
66        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
67    };
68    let mkit_dir = cwd.join(mkit_core::MKIT_DIR);
69    let _lock = match super::acquire_worktree_lock(&cwd) {
70        Ok(l) => l,
71        Err(code) => return code,
72    };
73
74    if opts.abort {
75        abort(&cwd, &mkit_dir, &store)
76    } else if opts.cont {
77        cont(&cwd, &mkit_dir, &store)
78    } else if let Some(hex) = opts.commit.as_deref() {
79        start(&cwd, &mkit_dir, &store, hex, opts.no_commit)
80    } else {
81        super::usage_error("usage: mkit revert <commit> | --continue | --abort")
82    }
83}
84
85fn start(
86    cwd: &std::path::Path,
87    mkit_dir: &std::path::Path,
88    store: &ObjectStore,
89    hex: &str,
90    no_commit: bool,
91) -> u8 {
92    if let Some(op) = in_progress_op_name(mkit_dir) {
93        return emit_err(
94            &format!("a {op} is already in progress (use --continue or --abort)"),
95            exit::GENERAL_ERROR,
96        );
97    }
98    let target: Hash = match super::revspec::resolve_revision(store, mkit_dir, hex) {
99        Ok(h) => h,
100        Err(e) => return emit_err(&format!("bad commit: {e}"), exit::DATAERR),
101    };
102    let ours = match refs::resolve_head(mkit_dir) {
103        Ok(Some(h)) => h,
104        Ok(None) => return emit_err("no commits on current branch", exit::GENERAL_ERROR),
105        Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
106    };
107    let ours_tree = match store.read_object(&ours) {
108        Ok(Object::Commit(c)) => c.tree_hash,
109        Ok(_) => return emit_err("HEAD is not a commit", exit::DATAERR),
110        Err(e) => return emit_err(&format!("read HEAD: {e}"), exit::GENERAL_ERROR),
111    };
112
113    let result = match revert_tree(store, target, ours_tree) {
114        Ok(r) => r,
115        Err(e) => return emit_err(&format!("revert: {e}"), exit::GENERAL_ERROR),
116    };
117
118    if result.has_conflicts() {
119        if let Err(e) = super::ensure_restore_safe(cwd, store, result.tree_hash) {
120            return emit_err(&e, exit::GENERAL_ERROR);
121        }
122        let records = match super::conflict::materialize_conflicts(
123            cwd,
124            store,
125            result.tree_hash,
126            &result.conflicts,
127        ) {
128            Ok(r) => r,
129            Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
130        };
131        let state = RevertState {
132            revert_head: target,
133            orig_head: ours,
134            message: result.message.clone(),
135        };
136        if let Err(e) = conflict_state::write_revert_state(mkit_dir, &state, &records) {
137            return emit_err(&format!("write revert state: {e}"), exit::CANTCREAT);
138        }
139        let mut stderr = std::io::stderr().lock();
140        let _ = writeln!(
141            stderr,
142            "revert conflict; resolve the files above, `mkit add` them, then run \
143             `mkit revert --continue` (or `mkit revert --abort`)"
144        );
145        return exit::GENERAL_ERROR;
146    }
147
148    if let Err(e) = super::ensure_restore_safe(cwd, store, result.tree_hash) {
149        return emit_err(&e, exit::GENERAL_ERROR);
150    }
151
152    // --no-commit: apply the reverted tree to the index + worktree but do
153    // not create a commit or move HEAD. The user commits when ready.
154    if no_commit {
155        if let Err(e) = super::restore_worktree_and_index(cwd, store, result.tree_hash) {
156            return emit_err(&e, exit::GENERAL_ERROR);
157        }
158        let mut stderr = std::io::stderr().lock();
159        let _ = writeln!(
160            stderr,
161            "staged revert of {} (no commit; run `mkit commit` when ready)",
162            format::short_hash(&target, 8),
163        );
164        return exit::OK;
165    }
166
167    let commit_hash = match create_commit(cwd, store, result.tree_hash, ours, &result.message) {
168        Ok(h) => h,
169        Err(code) => return code,
170    };
171    if let Err(e) = super::restore_worktree_and_index(cwd, store, result.tree_hash) {
172        return emit_err(&e, exit::GENERAL_ERROR);
173    }
174    if let Err(e) = advance_head(mkit_dir, &commit_hash) {
175        return emit_err(&e, exit::CANTCREAT);
176    }
177    let mut stderr = std::io::stderr().lock();
178    let _ = writeln!(
179        stderr,
180        "reverted {} as {}",
181        format::short_hash(&target, 8),
182        format::short_hash(&commit_hash, 8),
183    );
184    exit::OK
185}
186
187fn cont(cwd: &std::path::Path, mkit_dir: &std::path::Path, store: &ObjectStore) -> u8 {
188    let state = match conflict_state::read_revert_state(mkit_dir) {
189        Ok(Some(s)) => s,
190        Ok(None) => return emit_err("no revert in progress", exit::GENERAL_ERROR),
191        Err(e) => return emit_err(&format!("read revert state: {e}"), exit::GENERAL_ERROR),
192    };
193    let records = match conflict_state::read_conflicts(mkit_dir) {
194        Ok(r) => r,
195        Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
196    };
197    match super::conflict::first_unresolved_marker(cwd, &records) {
198        Ok(Some(path)) => {
199            return emit_err(
200                &format!(
201                    "unresolved conflict markers remain in '{path}'; resolve and `mkit add` it"
202                ),
203                exit::GENERAL_ERROR,
204            );
205        }
206        Ok(None) => {}
207        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
208    }
209    if let Err(e) = super::conflict::ensure_conflict_paths_staged(cwd, store, &records) {
210        return emit_err(&e, exit::GENERAL_ERROR);
211    }
212
213    let idx = match super::read_or_seed_index_from_head(cwd, store) {
214        Ok(i) => i,
215        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
216    };
217    let tree_hash = match worktree::build_tree_from_index(store, &idx) {
218        Ok(t) => t,
219        Err(e) => return emit_err(&format!("build tree from index: {e}"), exit::GENERAL_ERROR),
220    };
221    let parent = match refs::resolve_head(mkit_dir) {
222        Ok(Some(h)) => h,
223        Ok(None) => state.orig_head,
224        Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
225    };
226    let commit_hash = match create_commit(cwd, store, tree_hash, parent, &state.message) {
227        Ok(h) => h,
228        Err(code) => return code,
229    };
230    if let Err(e) = super::restore_worktree_and_index(cwd, store, tree_hash) {
231        return emit_err(&e, exit::GENERAL_ERROR);
232    }
233    if let Err(e) = advance_head(mkit_dir, &commit_hash) {
234        return emit_err(&e, exit::CANTCREAT);
235    }
236    if let Err(e) = conflict_state::clear_revert_state(mkit_dir) {
237        return emit_err(&format!("clear revert state: {e}"), exit::GENERAL_ERROR);
238    }
239    let mut stderr = std::io::stderr().lock();
240    let _ = writeln!(
241        stderr,
242        "reverted {} as {}",
243        format::short_hash(&state.revert_head, 8),
244        format::short_hash(&commit_hash, 8),
245    );
246    exit::OK
247}
248
249fn abort(cwd: &std::path::Path, mkit_dir: &std::path::Path, store: &ObjectStore) -> u8 {
250    if !is_revert_in_progress(mkit_dir) {
251        return emit_err("no revert in progress", exit::GENERAL_ERROR);
252    }
253    let state = match conflict_state::read_revert_state(mkit_dir) {
254        Ok(Some(s)) => s,
255        Ok(None) => return emit_err("no revert in progress", exit::GENERAL_ERROR),
256        Err(e) => return emit_err(&format!("read revert state: {e}"), exit::GENERAL_ERROR),
257    };
258    let records = match conflict_state::read_conflicts(mkit_dir) {
259        Ok(r) => r,
260        Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
261    };
262    if let Err(code) = restore_to(cwd, mkit_dir, store, state.orig_head, &records) {
263        return code;
264    }
265    if let Err(e) = conflict_state::clear_revert_state(mkit_dir) {
266        return emit_err(&format!("clear revert state: {e}"), exit::GENERAL_ERROR);
267    }
268    let mut stderr = std::io::stderr().lock();
269    let _ = writeln!(stderr, "revert aborted; HEAD restored");
270    exit::OK
271}
272
273fn restore_to(
274    cwd: &std::path::Path,
275    mkit_dir: &std::path::Path,
276    store: &ObjectStore,
277    target: Hash,
278    records: &[mkit_core::ops::conflict_state::ConflictRecord],
279) -> Result<(), u8> {
280    let target_tree = load_tree_hash(store, target)?;
281    if let Err(e) = super::conflict::ensure_abort_safe(cwd, store, records, target_tree) {
282        return Err(emit_err(&e, exit::GENERAL_ERROR));
283    }
284    if let Err(e) = super::conflict::reset_conflict_paths(cwd, store, records, target_tree) {
285        return Err(emit_err(&e, exit::GENERAL_ERROR));
286    }
287    if let Err(e) = super::ensure_restore_safe(cwd, store, target_tree) {
288        return Err(emit_err(&e, exit::GENERAL_ERROR));
289    }
290    if let Err(e) = super::restore_worktree_and_index(cwd, store, target_tree) {
291        return Err(emit_err(&e, exit::GENERAL_ERROR));
292    }
293    let head = refs::read_head(mkit_dir).unwrap_or(Head::Branch("main".to_string()));
294    match head {
295        Head::Branch(name) => super::write_ref_recording_history(
296            mkit_dir,
297            &name,
298            refs::RefWriteCondition::Any,
299            &target,
300        )
301        .map_err(|e| emit_err(&format!("restore ref: {e}"), exit::CANTCREAT)),
302        Head::Detached(_) => refs::write_head_detached(mkit_dir, &target)
303            .map_err(|e| emit_err(&format!("restore HEAD: {e}"), exit::CANTCREAT)),
304    }
305}
306
307fn create_commit(
308    cwd: &std::path::Path,
309    store: &ObjectStore,
310    tree_hash: Hash,
311    parent: Hash,
312    message: &[u8],
313) -> Result<Hash, u8> {
314    let cfg = config::read_or_default(cwd)
315        .map_err(|e| emit_err(&format!("config: {e}"), exit::CONFIG_ERROR))?;
316    let mut signer =
317        super::commit::load_commit_signer(cwd, &cfg).map_err(|(msg, code)| emit_err(&msg, code))?;
318    let signer_public = signer
319        .public_key()
320        .map_err(|(msg, code)| emit_err(&msg, code))?;
321    let author = super::commit::resolve_author(None, &cfg.user_identity, &signer_public)
322        .map_err(|e| emit_err(&format!("author: {e}"), exit::CONFIG_ERROR))?;
323    let timestamp = SystemTime::now()
324        .duration_since(UNIX_EPOCH)
325        .map_or(0, |d| d.as_secs());
326    let mut unsigned = Commit::new_unannotated(
327        tree_hash,
328        vec![parent],
329        author,
330        signer_public,
331        message.to_vec(),
332        timestamp,
333        [0u8; 64],
334    );
335    let sig = signer
336        .sign_commit(&unsigned)
337        .map_err(|(msg, code)| emit_err(&msg, code))?;
338    unsigned.signature = sig;
339    let bytes = serialize::serialize(&Object::Commit(unsigned))
340        .map_err(|e| emit_err(&format!("serialize: {e}"), exit::DATAERR))?;
341    store
342        .write(&bytes)
343        .map_err(|e| emit_err(&format!("store commit: {e}"), exit::CANTCREAT))
344}
345
346fn load_tree_hash(store: &ObjectStore, commit_hash: Hash) -> Result<Hash, u8> {
347    match store.read_object(&commit_hash) {
348        Ok(Object::Commit(c)) => Ok(c.tree_hash),
349        Ok(_) => Err(emit_err("object is not a commit", exit::DATAERR)),
350        Err(e) => Err(emit_err(&format!("read commit: {e}"), exit::GENERAL_ERROR)),
351    }
352}
353
354fn advance_head(mkit_dir: &std::path::Path, new_head: &Hash) -> Result<(), String> {
355    let head = refs::read_head(mkit_dir).unwrap_or(Head::Branch("main".to_string()));
356    match head {
357        Head::Branch(name) => super::write_ref_recording_history(
358            mkit_dir,
359            &name,
360            refs::RefWriteCondition::Any,
361            new_head,
362        )
363        .map_err(|e| format!("write ref: {e}")),
364        Head::Detached(_) => {
365            refs::write_head_detached(mkit_dir, new_head).map_err(|e| format!("update HEAD: {e}"))
366        }
367    }
368}
369
370fn emit_err(msg: &str, code: u8) -> u8 {
371    let mut stderr = std::io::stderr().lock();
372    let _ = writeln!(stderr, "error: {msg}");
373    code
374}