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, ValueEnum};
17use mkit_core::hash::Hash;
18use mkit_core::layout::RepoLayout;
19use mkit_core::object::{Commit, Object};
20use mkit_core::ops::conflict_state::{
21    self, RevertState, in_progress_op_name, is_revert_in_progress,
22};
23use mkit_core::ops::revert::revert as revert_tree;
24use mkit_core::refs;
25use mkit_core::serialize;
26use mkit_core::store::ObjectStore;
27use mkit_core::worktree;
28
29use super::{advance_head, error as emit_err, load_tree_hash};
30use crate::clap_shim;
31use crate::config;
32use crate::exit;
33use crate::format::{self, JsonObject, json_string_array};
34
35#[derive(Debug, Clone, Copy, ValueEnum)]
36enum RevertFormat {
37    Default,
38    Json,
39}
40
41#[derive(Debug, Parser)]
42#[command(
43    name = "mkit revert",
44    about = "Create a new commit that undoes a previous commit."
45)]
46#[allow(clippy::struct_excessive_bools)] // clap option flags, not a state machine
47struct RevertOpts {
48    /// Continue an in-progress revert after resolving conflicts.
49    #[arg(long = "continue", conflicts_with_all = ["abort", "commit"])]
50    cont: bool,
51    /// Abort the in-progress revert and restore the original HEAD.
52    #[arg(long, conflicts_with_all = ["cont", "commit"])]
53    abort: bool,
54    /// Stage the reverted tree in the index + worktree without creating a
55    /// commit (like `git revert --no-commit`). Applies to a clean revert;
56    /// if the revert conflicts, resolve it with `--continue` / `--abort`.
57    #[arg(short = 'n', long = "no-commit", conflicts_with_all = ["cont", "abort"])]
58    no_commit: bool,
59    /// Accepted for git compatibility; mkit auto-generates the revert
60    /// message, so `--no-edit` is the default behavior (no-op).
61    #[arg(long = "no-edit")]
62    no_edit: bool,
63    /// Emit a machine-readable JSON result object to stdout describing
64    /// the outcome: a new commit, `--no-commit` staging, a conflict
65    /// pause (`"conflicts":[<path>,...]`), or an error.
66    #[arg(long, value_enum, default_value = "default")]
67    format: RevertFormat,
68    /// Commit to revert: a ref, full/short hash, or `HEAD~n` revspec.
69    commit: Option<String>,
70}
71
72/// `error(msg, code)` plus, when `json` is set, a `{"ok":false,...}`
73/// line on stdout.
74fn emit_err_json(msg: &str, code: u8, json: bool) -> u8 {
75    if json {
76        let mut obj = JsonObject::new();
77        obj.field_bool("ok", false).field_str("error", msg);
78        let mut stdout = std::io::stdout().lock();
79        let _ = writeln!(stdout, "{}", obj.finish());
80    }
81    emit_err(msg, code)
82}
83
84#[must_use]
85pub fn run(args: &[String]) -> u8 {
86    let opts = match clap_shim::parse::<RevertOpts>("mkit revert", args) {
87        Ok(o) => o,
88        Err(code) => return code,
89    };
90    let _ = opts.no_edit; // accepted no-op (mkit auto-generates the message)
91    let json = matches!(opts.format, RevertFormat::Json);
92    let cwd = match std::env::current_dir() {
93        Ok(p) => p,
94        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
95    };
96    let layout = match super::resolve_layout(&cwd) {
97        Ok(layout) => layout,
98        Err(code) => return code,
99    };
100    let store = match ObjectStore::open(&layout) {
101        Ok(s) => s,
102        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
103    };
104    let _lock = match super::acquire_worktree_lock(&layout) {
105        Ok(l) => l,
106        Err(code) => return code,
107    };
108
109    if opts.abort {
110        abort(&layout, &store, json)
111    } else if opts.cont {
112        cont(&layout, &store, json)
113    } else if let Some(hex) = opts.commit.as_deref() {
114        start(&layout, &store, hex, opts.no_commit, json)
115    } else {
116        super::usage_error("usage: mkit revert <commit> | --continue | --abort")
117    }
118}
119
120#[allow(clippy::too_many_lines)] // linear flow: apply + commit + report
121fn start(layout: &RepoLayout, store: &ObjectStore, hex: &str, no_commit: bool, json: bool) -> u8 {
122    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
123    if let Some(op) = in_progress_op_name(layout) {
124        return emit_err(
125            &format!("a {op} is already in progress (use --continue or --abort)"),
126            exit::GENERAL_ERROR,
127        );
128    }
129    let target: Hash = match super::revspec::resolve_revision(store, layout, hex) {
130        // Peel annotated/signed tags to their target commit so
131        // `mkit revert <annotated-tag>` works like git (a tag is a ref,
132        // which the doc comment advertises as acceptable). Mirrors
133        // `merge`'s behavior.
134        Ok(h) => super::log::peel_tags(store, h),
135        Err(e) => return emit_err(&format!("bad commit: {e}"), exit::DATAERR),
136    };
137    let ours = match refs::resolve_head(layout) {
138        Ok(Some(h)) => h,
139        Ok(None) => return emit_err("no commits on current branch", exit::GENERAL_ERROR),
140        Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
141    };
142    let ours_tree = match store.read_object(&ours) {
143        Ok(Object::Commit(c)) => c.tree_hash,
144        Ok(_) => return emit_err("HEAD is not a commit", exit::DATAERR),
145        Err(e) => return emit_err(&format!("read HEAD: {e}"), exit::GENERAL_ERROR),
146    };
147
148    let result = match revert_tree(store, target, ours_tree) {
149        Ok(r) => r,
150        Err(e) => return emit_err(&format!("revert: {e}"), exit::GENERAL_ERROR),
151    };
152
153    if result.has_conflicts() {
154        if let Err(e) = super::ensure_restore_safe(layout, store, result.tree_hash) {
155            return emit_err(&e, exit::GENERAL_ERROR);
156        }
157        let records = match super::conflict::materialize_conflicts(
158            layout,
159            store,
160            result.tree_hash,
161            &result.conflicts,
162        ) {
163            Ok(r) => r,
164            Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
165        };
166        let state = RevertState {
167            revert_head: target,
168            orig_head: ours,
169            message: result.message.clone(),
170        };
171        if let Err(e) = conflict_state::write_revert_state(layout, &state, &records) {
172            return emit_err(&format!("write revert state: {e}"), exit::CANTCREAT);
173        }
174        // Record the result tree so `--abort` treats the operation's clean
175        // hunks (not just conflict paths) as discardable.
176        if let Err(e) =
177            conflict_state::write_result_tree(layout.worktree_state_dir(), &result.tree_hash)
178        {
179            return emit_err(&format!("write revert state: {e}"), exit::CANTCREAT);
180        }
181        let mut stderr = std::io::stderr().lock();
182        let _ = writeln!(
183            stderr,
184            "revert conflict; resolve the files above, `mkit add` them, then run \
185             `mkit revert --continue` (or `mkit revert --abort`)"
186        );
187        drop(stderr);
188        if json {
189            let paths: Vec<&str> = records.iter().map(|r| r.path.as_str()).collect();
190            let mut obj = JsonObject::new();
191            obj.field_bool("ok", false)
192                .field_str("kind", "conflict")
193                .field_raw("conflicts", &json_string_array(&paths))
194                .field_str("error", "revert conflict; resolve and continue or abort");
195            let mut stdout = std::io::stdout().lock();
196            let _ = writeln!(stdout, "{}", obj.finish());
197        }
198        return exit::GENERAL_ERROR;
199    }
200
201    if let Err(e) = super::ensure_restore_safe(layout, store, result.tree_hash) {
202        return emit_err(&e, exit::GENERAL_ERROR);
203    }
204
205    // --no-commit: apply the reverted tree to the index + worktree but do
206    // not create a commit or move HEAD. The user commits when ready.
207    if no_commit {
208        if let Err(e) = super::restore_worktree_and_index(layout, store, result.tree_hash) {
209            return emit_err(&e, exit::GENERAL_ERROR);
210        }
211        // Restoring from a tree drops staged DELETIONS; re-stage them as
212        // tombstones so a revert that removes files stays staged and
213        // `mkit commit` records it.
214        if let Err(e) =
215            super::stage_removed_tombstones(layout, store, Some(ours_tree), result.tree_hash)
216        {
217            return emit_err(&e, exit::GENERAL_ERROR);
218        }
219        let mut stderr = std::io::stderr().lock();
220        let _ = writeln!(
221            stderr,
222            "staged revert of {} (no commit; run `mkit commit` when ready)",
223            format::short_hash(&target, 8),
224        );
225        drop(stderr);
226        if json {
227            let mut obj = JsonObject::new();
228            obj.field_bool("ok", true)
229                .field_str("kind", "no-commit")
230                .field_hash("reverted", &target)
231                .field_hash("tree", &result.tree_hash);
232            let mut stdout = std::io::stdout().lock();
233            let _ = writeln!(stdout, "{}", obj.finish());
234        }
235        return exit::OK;
236    }
237
238    let commit_hash = match create_commit(layout, store, result.tree_hash, ours, &result.message) {
239        Ok(h) => h,
240        Err(code) => return code,
241    };
242    if let Err(e) = super::restore_worktree_and_index(layout, store, result.tree_hash) {
243        return emit_err(&e, exit::GENERAL_ERROR);
244    }
245    if let Err(e) = advance_head(layout, &commit_hash) {
246        return emit_err(&e, exit::CANTCREAT);
247    }
248    // git-shaped summary: `[<branch> <hash>] Revert "<subject>"` + diffstat.
249    let subject = String::from_utf8_lossy(&result.message)
250        .lines()
251        .next()
252        .unwrap_or("")
253        .to_owned();
254    let branch_name = match mkit_core::refs::read_head(layout) {
255        Ok(mkit_core::refs::Head::Branch(b)) => Some(b),
256        _ => None,
257    };
258    let head_ref = match &branch_name {
259        Some(b) => super::summary::HeadRef::Branch(b),
260        None => super::summary::HeadRef::Detached,
261    };
262    let mut stderr = std::io::stderr().lock();
263    super::summary::print_commit_summary(
264        &mut stderr,
265        store,
266        &head_ref,
267        &commit_hash,
268        &subject,
269        false,
270        Some(ours_tree),
271        Some(result.tree_hash),
272    );
273    drop(stderr);
274    if json {
275        let mut obj = JsonObject::new();
276        obj.field_bool("ok", true)
277            .field_str("kind", "commit")
278            .field_hash("hash", &commit_hash)
279            .field_hash("reverted", &target)
280            .field_hash("tree", &result.tree_hash);
281        let mut stdout = std::io::stdout().lock();
282        let _ = writeln!(stdout, "{}", obj.finish());
283    }
284    exit::OK
285}
286
287fn cont(layout: &RepoLayout, store: &ObjectStore, json: bool) -> u8 {
288    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
289    let state = match conflict_state::read_revert_state(layout) {
290        Ok(Some(s)) => s,
291        Ok(None) => return emit_err("no revert in progress", exit::GENERAL_ERROR),
292        Err(e) => return emit_err(&format!("read revert state: {e}"), exit::GENERAL_ERROR),
293    };
294    let records = match conflict_state::read_conflicts(layout.worktree_state_dir()) {
295        Ok(r) => r,
296        Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
297    };
298    match super::conflict::first_unresolved_marker(layout.worktree_root(), &records) {
299        Ok(Some(path)) => {
300            return emit_err(
301                &format!(
302                    "unresolved conflict markers remain in '{path}'; resolve and `mkit add` it"
303                ),
304                exit::GENERAL_ERROR,
305            );
306        }
307        Ok(None) => {}
308        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
309    }
310    if let Err(e) = super::conflict::ensure_conflict_paths_staged(layout, store, &records) {
311        return emit_err(&e, exit::GENERAL_ERROR);
312    }
313
314    let idx = match super::read_or_seed_index_from_head(layout, store) {
315        Ok(i) => i,
316        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
317    };
318    let tree_hash = match worktree::build_tree_from_index(store, &idx) {
319        Ok(t) => t,
320        Err(e) => return emit_err(&format!("build tree from index: {e}"), exit::GENERAL_ERROR),
321    };
322    let parent = match refs::resolve_head(layout) {
323        Ok(Some(h)) => h,
324        Ok(None) => state.orig_head,
325        Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
326    };
327    let commit_hash = match create_commit(layout, store, tree_hash, parent, &state.message) {
328        Ok(h) => h,
329        Err(code) => return code,
330    };
331    // Sync the index to the committed tree WITHOUT rewriting the worktree:
332    // the tree was built from the index, so the worktree already holds the
333    // resolved content; restoring it would clobber unstaged edits made on a
334    // cleanly-applied path before `--continue`.
335    if let Err(e) = super::sync_index_to_tree(layout, store, tree_hash) {
336        return emit_err(&e, exit::GENERAL_ERROR);
337    }
338    if let Err(e) = advance_head(layout, &commit_hash) {
339        return emit_err(&e, exit::CANTCREAT);
340    }
341    if let Err(e) = conflict_state::clear_revert_state(layout) {
342        return emit_err(&format!("clear revert state: {e}"), exit::GENERAL_ERROR);
343    }
344    let mut stderr = std::io::stderr().lock();
345    let _ = writeln!(
346        stderr,
347        "reverted {} as {}",
348        format::short_hash(&state.revert_head, 8),
349        format::short_hash(&commit_hash, 8),
350    );
351    drop(stderr);
352    if json {
353        let mut obj = JsonObject::new();
354        obj.field_bool("ok", true)
355            .field_str("kind", "commit")
356            .field_hash("hash", &commit_hash)
357            .field_hash("reverted", &state.revert_head)
358            .field_hash("tree", &tree_hash);
359        let mut stdout = std::io::stdout().lock();
360        let _ = writeln!(stdout, "{}", obj.finish());
361    }
362    exit::OK
363}
364
365fn abort(layout: &RepoLayout, store: &ObjectStore, json: bool) -> u8 {
366    let emit_err = |msg: &str, code: u8| emit_err_json(msg, code, json);
367    if !is_revert_in_progress(layout) {
368        return emit_err("no revert in progress", exit::GENERAL_ERROR);
369    }
370    let state = match conflict_state::read_revert_state(layout) {
371        Ok(Some(s)) => s,
372        Ok(None) => return emit_err("no revert in progress", exit::GENERAL_ERROR),
373        Err(e) => return emit_err(&format!("read revert state: {e}"), exit::GENERAL_ERROR),
374    };
375    let records = match conflict_state::read_conflicts(layout.worktree_state_dir()) {
376        Ok(r) => r,
377        Err(e) => return emit_err(&format!("read conflicts: {e}"), exit::GENERAL_ERROR),
378    };
379    if let Err(code) = restore_to(layout, store, state.orig_head, &records) {
380        return code;
381    }
382    if let Err(e) = conflict_state::clear_revert_state(layout) {
383        return emit_err(&format!("clear revert state: {e}"), exit::GENERAL_ERROR);
384    }
385    let mut stderr = std::io::stderr().lock();
386    let _ = writeln!(stderr, "revert aborted; HEAD restored");
387    drop(stderr);
388    if json {
389        let mut obj = JsonObject::new();
390        obj.field_bool("ok", true)
391            .field_str("kind", "aborted")
392            .field_hash("hash", &state.orig_head);
393        let mut stdout = std::io::stdout().lock();
394        let _ = writeln!(stdout, "{}", obj.finish());
395    }
396    exit::OK
397}
398
399fn restore_to(
400    layout: &RepoLayout,
401    store: &ObjectStore,
402    target: Hash,
403    records: &[mkit_core::ops::conflict_state::ConflictRecord],
404) -> Result<(), u8> {
405    let target_tree = load_tree_hash(store, target)?;
406    // The operation's result tree lets the guards treat its clean hunks (not
407    // just conflict paths) as discardable.
408    let op_result = conflict_state::read_result_tree(layout.worktree_state_dir())
409        .ok()
410        .flatten();
411    if let Err(e) =
412        super::conflict::ensure_abort_safe(layout, store, records, target_tree, op_result)
413    {
414        return Err(emit_err(&e, exit::GENERAL_ERROR));
415    }
416    if let Err(e) =
417        super::conflict::reset_conflict_paths(layout, store, records, target_tree, op_result)
418    {
419        return Err(emit_err(&e, exit::GENERAL_ERROR));
420    }
421    if let Err(e) = super::ensure_restore_safe(layout, store, target_tree) {
422        return Err(emit_err(&e, exit::GENERAL_ERROR));
423    }
424    if let Err(e) = super::restore_worktree_and_index(layout, store, target_tree) {
425        return Err(emit_err(&e, exit::GENERAL_ERROR));
426    }
427    super::restore_head_ref(layout, &target)
428}
429
430fn create_commit(
431    layout: &RepoLayout,
432    store: &ObjectStore,
433    tree_hash: Hash,
434    parent: Hash,
435    message: &[u8],
436) -> Result<Hash, u8> {
437    let cfg = config::read_or_default(layout)
438        .map_err(|e| emit_err(&format!("config: {e}"), exit::CONFIG_ERROR))?;
439    let mut signer = super::commit::load_commit_signer(layout, &cfg)
440        .map_err(|(msg, code)| emit_err(&msg, code))?;
441    let signer_public = signer
442        .public_key()
443        .map_err(|(msg, code)| emit_err(&msg, code))?;
444    let author = super::commit::resolve_author(None, &cfg.user_identity, &signer_public)
445        .map_err(|e| emit_err(&format!("author: {e}"), exit::CONFIG_ERROR))?;
446    let timestamp = SystemTime::now()
447        .duration_since(UNIX_EPOCH)
448        .map_or(0, |d| d.as_secs());
449    let mut unsigned = Commit::new_unannotated(
450        tree_hash,
451        vec![parent],
452        author,
453        signer_public,
454        message.to_vec(),
455        timestamp,
456        [0u8; 64],
457    );
458    let sig = signer
459        .sign_commit(&unsigned)
460        .map_err(|(msg, code)| emit_err(&msg, code))?;
461    unsigned.signature = sig;
462    let bytes = serialize::serialize(&Object::Commit(unsigned))
463        .map_err(|e| emit_err(&format!("serialize: {e}"), exit::DATAERR))?;
464    store
465        .write(&bytes)
466        .map_err(|e| emit_err(&format!("store commit: {e}"), exit::CANTCREAT))
467}