Skip to main content

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}