lds_git/reset.rs
1//! Reset operations — destructive, so they're guarded by the same ownership
2//! check as `commit` / `merge`.
3//!
4//! `git reset --hard` is the most reflog-heavy thing this crate does. We
5//! capture HEAD before and after so callers can produce an audit line ("HEAD
6//! moved from X to Y, mode=hard"), and so an undo path is at least
7//! discoverable via the reflog rather than silently lost.
8
9use std::path::Path;
10
11use anyhow::{Result, bail};
12
13use crate::output::{ResetMode, ResetOutput};
14use crate::stash::dirty_paths;
15use crate::{GitModule, TIMEOUT_LOCAL, git_cmd};
16
17impl GitModule {
18 /// Move HEAD to `target`, with `mode` controlling the working tree
19 /// behaviour. The working directory MUST be owned by the current
20 /// session — see [`GitModule::ensure_session_scope`].
21 ///
22 /// * [`ResetMode::Soft`] — move HEAD only (`git reset --soft`)
23 /// * [`ResetMode::Mixed`] — also reset index but keep worktree (`--mixed`)
24 /// * [`ResetMode::Hard`] — also overwrite worktree (`--hard`)
25 ///
26 /// `force` only affects `Hard`: without it, a hard reset is refused when
27 /// the repository has stash entries *and* the working tree is dirty —
28 /// the signature of "a stash was applied and is being cleaned up with the
29 /// biggest hammer available", which is precisely how stashed work gets
30 /// destroyed. [`GitModule::stash_abort`] is the non-destructive undo;
31 /// `force = true` is for callers who mean the reset regardless.
32 pub async fn reset(
33 &self,
34 working_dir: &Path,
35 mode: ResetMode,
36 target: &str,
37 force: bool,
38 ) -> Result<ResetOutput> {
39 self.ensure_session_scope(working_dir)?;
40
41 if matches!(mode, ResetMode::Hard) && !force {
42 self.ensure_no_stash_in_flight(working_dir).await?;
43 }
44
45 let previous_head = git_cmd(working_dir, &["rev-parse", "HEAD"], TIMEOUT_LOCAL).await?;
46 let flag = match mode {
47 ResetMode::Soft => "--soft",
48 ResetMode::Mixed => "--mixed",
49 ResetMode::Hard => "--hard",
50 };
51 git_cmd(working_dir, &["reset", flag, target], TIMEOUT_LOCAL).await?;
52 let current_head = git_cmd(working_dir, &["rev-parse", "HEAD"], TIMEOUT_LOCAL).await?;
53
54 Ok(ResetOutput {
55 mode,
56 target: target.to_string(),
57 previous_head,
58 current_head,
59 })
60 }
61
62 /// Refuse when the repository carries stash entries and `working_dir` has
63 /// uncommitted changes. Either condition alone is unremarkable; together
64 /// they are indistinguishable from a half-verified `stash_apply`, and a
65 /// `--hard` on top of that is unrecoverable.
66 async fn ensure_no_stash_in_flight(&self, working_dir: &Path) -> Result<()> {
67 let stashes = self.stash_list().await?.stashes;
68 if stashes.is_empty() {
69 return Ok(());
70 }
71 let (staged, unstaged) = dirty_paths(working_dir).await?;
72 if staged.is_empty() && unstaged.is_empty() {
73 return Ok(());
74 }
75 bail!(
76 "reset --hard refused: {} stash entr(y|ies) exist and the working tree is dirty \
77 (staged: [{}], unstaged: [{}]) — this looks like an applied stash. Use \
78 git_stash_abort to undo the apply (the entry survives), or pass force=true if \
79 the reset is intended.",
80 stashes.len(),
81 staged.join(", "),
82 unstaged.join(", "),
83 )
84 }
85}