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;