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}