Skip to main content

mkit_cli/commands/
reset.rs

1//! `mkit reset [--soft|--mixed] [<commit>]` — move the current branch
2//! (or detached HEAD) to `<commit>`, optionally resetting the index.
3//!
4//! Two modes, mirroring `git reset`'s safe subset:
5//!
6//! - **`--soft`** — move HEAD / the current branch only. The index and
7//!   the worktree are left exactly as they are, so the difference between
8//!   the old tip and the new target shows up as staged changes.
9//! - **`--mixed`** (the default) — move HEAD *and* rewrite `.mkit/index`
10//!   to mirror the target commit's tree. The worktree is untouched, so
11//!   changes relative to the target appear as un-staged worktree edits.
12//!
13//! `<commit>` defaults to `HEAD` (a no-op move that still re-syncs the
14//! index under `--mixed`) and is resolved through the shared revspec
15//! resolver, so a branch, tag, `HEAD`, full/short hash, or `HEAD~n`/`^`
16//! navigation all work.
17//!
18//! - **`--hard`** — move HEAD, reset the index to the target tree, AND
19//!   reset the worktree to it (discarding tracked-file changes). Like
20//!   git, untracked files are left in place. This is the one destructive
21//!   variant, so it runs the same dirty/untracked guard as `checkout`
22//!   (#176): it **refuses** to discard locally-modified or staged content
23//!   unless `-f`/`--force` is given. That guard is an mkit safety
24//!   divergence — git's `reset --hard` discards silently.
25
26use std::io::Write;
27
28use clap::Parser;
29use mkit_core::hash::Hash;
30use mkit_core::index::EntryStatus;
31use mkit_core::object::Object;
32use mkit_core::ops::restore::{RestoreOptions, restore_tree_to_worktree};
33use mkit_core::refs::{self, Head, RefWriteCondition};
34use mkit_core::store::ObjectStore;
35
36use crate::clap_shim;
37use crate::exit;
38use crate::format;
39
40#[derive(Debug, Parser)]
41#[command(
42    name = "mkit reset",
43    about = "Move HEAD (and, by default, the index) to a commit."
44)]
45#[allow(clippy::struct_excessive_bools)] // clap option flags, not a state machine
46struct ResetOpts {
47    /// Move HEAD only; leave the index and worktree untouched.
48    #[arg(long, conflicts_with = "mixed")]
49    soft: bool,
50
51    /// Move HEAD and reset the index to the target tree; leave the
52    /// worktree untouched. This is the default.
53    #[arg(long)]
54    mixed: bool,
55
56    /// Move HEAD, reset the index AND the worktree to the target tree
57    /// (discarding tracked-file changes; untracked files are kept).
58    /// Refuses to discard locally-modified/staged content without `-f`.
59    #[arg(long, conflicts_with_all = ["soft", "mixed"])]
60    hard: bool,
61
62    /// With `--hard`, discard locally-modified or staged content instead
63    /// of refusing (the mkit safety guard). No effect without `--hard`.
64    #[arg(short = 'f', long)]
65    force: bool,
66
67    /// Commit to reset to (branch, tag, HEAD, full/short hash, `HEAD~n`,
68    /// `^`). Defaults to `HEAD`.
69    target: Option<String>,
70}
71
72#[must_use]
73#[allow(clippy::too_many_lines)] // linear flow over the soft/mixed/hard modes
74pub fn run(args: &[String]) -> u8 {
75    let opts = match clap_shim::parse::<ResetOpts>("mkit reset", args) {
76        Ok(o) => o,
77        Err(code) => return code,
78    };
79    let cwd = match std::env::current_dir() {
80        Ok(p) => p,
81        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
82    };
83    let mkit_dir = cwd.join(mkit_core::MKIT_DIR);
84    let store = match ObjectStore::open(&cwd) {
85        Ok(s) => s,
86        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
87    };
88    let _lock = match super::acquire_worktree_lock(&cwd) {
89        Ok(l) => l,
90        Err(code) => return code,
91    };
92
93    // --soft = HEAD only; --mixed (default) and --hard also reset the
94    // index; --hard additionally resets the worktree.
95    let reset_index = !opts.soft;
96
97    let spec = opts.target.as_deref().unwrap_or("HEAD");
98    let target = match super::revspec::resolve_revision(&store, &mkit_dir, spec) {
99        Ok(h) => h,
100        Err(e) => {
101            return emit_err(
102                &format!("no such commit: {spec} ({e})"),
103                exit::GENERAL_ERROR,
104            );
105        }
106    };
107
108    // The target must be a commit/remix; we need its tree for --mixed and
109    // we refuse to point HEAD at a bare tree/blob.
110    let tree_hash = match store.read_object(&target) {
111        Ok(Object::Commit(c)) => c.tree_hash,
112        Ok(Object::Remix(r)) => r.tree_hash,
113        Ok(_) => {
114            return emit_err(
115                &format!(
116                    "{} does not resolve to a commit or remix",
117                    format::short_hash(&target, 8)
118                ),
119                exit::GENERAL_ERROR,
120            );
121        }
122        Err(e) => return emit_err(&format!("read target commit: {e}"), exit::GENERAL_ERROR),
123    };
124
125    // --hard is the one destructive variant: it overwrites the worktree.
126    // `clean = false` so the guard/restore KEEP untracked files (git
127    // `reset --hard` leaves them); we delete dropped *tracked* files
128    // ourselves below.
129    let restore_opts = RestoreOptions {
130        clean: false,
131        sparse_patterns: None,
132    };
133
134    // For --hard, capture the tracked paths the target DROPS — each with
135    // its current index blob hash — computed from the current index BEFORE
136    // it is re-synced. `clean = false` won't delete these, so we remove
137    // them ourselves; the hashes let the guard below detect local edits to
138    // ignored-but-tracked files that the shared guard cannot see.
139    let hard_removed: Vec<(String, EntryStatus, Hash)> = if opts.hard {
140        match super::dropped_tracked_paths(&cwd, &store, tree_hash) {
141            Ok(p) => p,
142            Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
143        }
144    } else {
145        Vec::new()
146    };
147
148    // Guard BEFORE any mutation, unless `-f`. The shared `checkout` guard
149    // refuses if discarding would lose locally-modified, staged, or
150    // colliding-untracked content — an mkit safety divergence (git's
151    // `reset --hard` discards silently). The guard's worktree snapshot now
152    // keeps tracked files even when they match an ignore rule, but the
153    // dropped-path set (paths present at HEAD/index and gone in the target)
154    // is computed and re-checked here directly regardless, so a
155    // locally-modified ignored-but-tracked file is never discarded silently.
156    if opts.hard && !opts.force {
157        if let Err(e) =
158            super::ensure_restore_safe_with_options(&cwd, &store, tree_hash, &restore_opts)
159        {
160            return emit_err(
161                &format!("{e}\nhint: use `mkit reset --hard -f` to discard these changes"),
162                exit::GENERAL_ERROR,
163            );
164        }
165        match super::locally_modified_dropped_path(&cwd, &store, &hard_removed) {
166            Ok(Some(path)) => {
167                return emit_err(
168                    &format!(
169                        "reset --hard would discard local changes to '{path}'\n\
170                         hint: use `mkit reset --hard -f` to discard these changes"
171                    ),
172                    exit::GENERAL_ERROR,
173                );
174            }
175            Ok(None) => {}
176            Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
177        }
178    }
179
180    // If reset moves the branch off its current tip, that old tip may
181    // become unreachable — record it BEFORE the move (under the worktree
182    // lock) so it stays recoverable, and abort if the log can't be
183    // written. Fail closed: an unreadable/corrupt current ref
184    // (`resolve_head` Err) aborts rather than letting `move_head` clobber
185    // it unlogged. `Ok(None)` is an unborn branch (nothing to supersede);
186    // a no-op move (old == target) records nothing.
187    match refs::resolve_head(&mkit_dir) {
188        Ok(Some(old_head)) if old_head != target => {
189            let branch = super::head_branch_name(&mkit_dir);
190            if let Err((msg, code)) =
191                super::record_superseded(&mkit_dir, "reset", &branch, old_head)
192            {
193                return emit_err(&msg, code);
194            }
195        }
196        Ok(_) => {}
197        Err(e) => return emit_err(&format!("read HEAD: {e}"), exit::DATAERR),
198    }
199
200    // Move HEAD / the current branch FIRST. As in `checkout`, advancing
201    // the ref before the index keeps the failure modes benign: a later
202    // index-write failure leaves HEAD on the target with a stale index,
203    // which `mkit status` surfaces and a re-run repairs.
204    if let Err((msg, code)) = move_head(&mkit_dir, &target) {
205        return emit_err(&msg, code);
206    }
207
208    if reset_index && let Err(e) = super::sync_index_to_tree(&cwd, &store, tree_hash) {
209        return emit_err(&e, exit::CANTCREAT);
210    }
211
212    // --hard: materialize the target tree into the worktree (overwriting
213    // tracked files, keeping untracked ones), then delete the tracked
214    // files the target dropped.
215    if opts.hard {
216        if let Err(e) = restore_tree_to_worktree(&store, &tree_hash, &cwd, &restore_opts) {
217            return emit_err(&format!("reset worktree: {e}"), exit::CANTCREAT);
218        }
219        for (path, _, _) in &hard_removed {
220            if let Err(e) = super::remove_dropped_path(&cwd.join(path)) {
221                return emit_err(
222                    &format!("reset worktree: remove {path}: {e}"),
223                    exit::CANTCREAT,
224                );
225            }
226        }
227    }
228
229    let mut stderr = std::io::stderr().lock();
230    let mode = if opts.hard {
231        "hard"
232    } else if reset_index {
233        "mixed"
234    } else {
235        "soft"
236    };
237    let _ = writeln!(
238        stderr,
239        "reset ({mode}) to {}",
240        format::short_hash(&target, 8)
241    );
242    exit::OK
243}
244
245/// Point the current branch (or detached HEAD) at `target`. Routes branch
246/// moves through the history-recording ref helper so a `history-mmr`
247/// build journals the move; detached HEAD is rewritten directly.
248fn move_head(mkit_dir: &std::path::Path, target: &Hash) -> Result<(), (String, u8)> {
249    let head = refs::read_head(mkit_dir).map_err(|e| (format!("read HEAD: {e}"), exit::DATAERR))?;
250    match head {
251        Head::Branch(name) => {
252            super::write_ref_recording_history(mkit_dir, &name, RefWriteCondition::Any, target)
253                .map_err(|e| (format!("write ref: {e}"), exit::CANTCREAT))
254        }
255        Head::Detached(_) => refs::write_head_detached(mkit_dir, target)
256            .map_err(|e| (format!("update HEAD: {e}"), exit::CANTCREAT)),
257    }
258}
259
260fn emit_err(msg: &str, code: u8) -> u8 {
261    let mut stderr = std::io::stderr().lock();
262    let _ = writeln!(stderr, "error: {msg}");
263    code
264}