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