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