Skip to main content

anodizer_core/git/commits/
rollback.rs

1use super::*;
2use anyhow::{Context as _, Result, bail};
3use std::path::Path;
4use std::process::Command;
5
6/// Committer identity (author + committer name/email) for the rare path
7/// where a git invocation lands on a host with no `user.email` /
8/// `user.name` configured — notably `actions/checkout@v6`, which does
9/// NOT set committer identity for the workflow runner. Resolved once per
10/// caller and threaded through to [`revert_commit_in`] so the CLI never
11/// mutates the repo's git config (env-only, scoped to the single spawn).
12///
13/// Convention: when both `name` and `email` are populated, the values
14/// are exported as `GIT_AUTHOR_NAME` / `GIT_AUTHOR_EMAIL` AND
15/// `GIT_COMMITTER_NAME` / `GIT_COMMITTER_EMAIL` on the git child
16/// processes (revert + amend). When `None`, the child inherits whatever
17/// the parent / repo config provides.
18#[derive(Debug, Clone, Default)]
19pub struct CommitterIdentity {
20    pub name: Option<String>,
21    pub email: Option<String>,
22}
23
24impl CommitterIdentity {
25    /// Return a default committer identity to use when `user.email` and
26    /// `user.name` are both unset on the host. Email uses the
27    /// short-hostname (best-effort; falls back to `"localhost"`) so a
28    /// reviewer can tell at a glance which machine emitted the
29    /// rollback commit.
30    pub fn default_for_rollback() -> Self {
31        let host = std::env::var("HOSTNAME")
32            .ok()
33            .or_else(|| std::env::var("COMPUTERNAME").ok())
34            .and_then(|h| h.split('.').next().map(str::to_string))
35            .filter(|h| !h.is_empty())
36            .unwrap_or_else(|| "localhost".to_string());
37        Self {
38            name: Some("anodize-rollback".to_string()),
39            email: Some(format!("anodize-rollback@{host}")),
40        }
41    }
42
43    fn apply_to(&self, cmd: &mut Command) {
44        if let Some(n) = &self.name {
45            cmd.env("GIT_AUTHOR_NAME", n).env("GIT_COMMITTER_NAME", n);
46        }
47        if let Some(e) = &self.email {
48            cmd.env("GIT_AUTHOR_EMAIL", e).env("GIT_COMMITTER_EMAIL", e);
49        }
50    }
51}
52
53/// Read `git config user.email` / `user.name` in `cwd`. Returns
54/// `(name, email)`, each `Some(value)` when configured (and non-empty)
55/// or `None` when unset. Used by [`revert_commit_in`] to detect the
56/// CI-checkout case where neither identity is configured and the
57/// committer env fallback must fire.
58pub(super) fn read_git_identity(cwd: &Path) -> (Option<String>, Option<String>) {
59    let one = |key: &str| -> Option<String> {
60        let out = Command::new("git")
61            .current_dir(cwd)
62            .args(["config", "--get", key])
63            .env("LC_ALL", "C")
64            .env("GIT_TERMINAL_PROMPT", "0")
65            .output()
66            .ok()?;
67        if !out.status.success() {
68            return None;
69        }
70        let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
71        if value.is_empty() { None } else { Some(value) }
72    };
73    (one("user.name"), one("user.email"))
74}
75
76/// Resolve the committer identity to use for a rollback-style commit.
77/// When the host already has `user.name` AND `user.email` configured
78/// (or `GIT_AUTHOR_*` / `GIT_COMMITTER_*` are set in the parent env),
79/// returns an empty identity so the child inherits the existing
80/// values. Otherwise returns a synthetic identity so the commit
81/// doesn't fail with "Author identity unknown" on bare-CI hosts.
82pub fn resolve_rollback_identity(cwd: &Path) -> CommitterIdentity {
83    let env_author_set =
84        std::env::var("GIT_AUTHOR_EMAIL").is_ok() && std::env::var("GIT_AUTHOR_NAME").is_ok();
85    let env_committer_set =
86        std::env::var("GIT_COMMITTER_EMAIL").is_ok() && std::env::var("GIT_COMMITTER_NAME").is_ok();
87    if env_author_set && env_committer_set {
88        return CommitterIdentity::default();
89    }
90    let (name, email) = read_git_identity(cwd);
91    if name.is_some() && email.is_some() {
92        return CommitterIdentity::default();
93    }
94    CommitterIdentity::default_for_rollback()
95}
96
97/// Run `git revert --no-edit <sha>` in `cwd`, optionally followed by
98/// `git commit --amend -m <message>`.
99///
100/// Refuses against a dirty working tree (`git revert` would surface a
101/// less actionable "your local changes would be overwritten" message
102/// otherwise). Mirrors the dirty-tree guard used by
103/// `stage-publish/src/util/git_revert.rs`. The guard counts only
104/// TRACKED modifications (`--untracked-files=no`): a revert never
105/// touches untracked files, and a failure-recovery rollback runs right
106/// after a release wrote `dist/` — in repos that don't gitignore their
107/// dist, an untracked-counts-as-dirty guard would refuse every
108/// post-release rollback. The one genuine hazard (an untracked file
109/// where the revert must restore a tracked one) is refused by git
110/// itself with an explicit "would be overwritten" error.
111///
112/// On revert failure (typically a merge conflict against later commits
113/// on top of the bump), runs `git revert --abort` to restore the
114/// working tree before bubbling the error — otherwise the next
115/// rollback attempt would trip the dirty-tree guard and the operator
116/// would be stuck.
117///
118/// `identity` is threaded through as committer env vars so the call
119/// works on bare-CI hosts where the workflow checkout doesn't set
120/// `user.email` / `user.name`. The env is scoped to the spawn; the
121/// repo's git config is never mutated.
122pub fn revert_commit_in(
123    cwd: &Path,
124    sha: &str,
125    message: Option<&str>,
126    identity: &CommitterIdentity,
127) -> Result<()> {
128    let status = Command::new("git")
129        .args(["status", "--porcelain", "--untracked-files=no"])
130        .current_dir(cwd)
131        .env("LC_ALL", "C")
132        .env("GIT_TERMINAL_PROMPT", "0")
133        .output()
134        .with_context(|| format!("revert_commit_in: git status in {}", cwd.display()))?;
135    if !status.status.success() {
136        let stderr_raw = String::from_utf8_lossy(&status.stderr);
137        let raw = format!("git status failed: {}", stderr_raw.trim());
138        bail!("{}", crate::redact::redact_process_env(&raw));
139    }
140    if !status.stdout.is_empty() {
141        bail!(
142            "refusing to revert in a dirty working tree at {}\nstatus:\n{}",
143            cwd.display(),
144            String::from_utf8_lossy(&status.stdout),
145        );
146    }
147
148    let mut revert_cmd = Command::new("git");
149    revert_cmd
150        .current_dir(cwd)
151        .args(["revert", "--no-edit", sha])
152        .env("LC_ALL", "C")
153        .env("GIT_TERMINAL_PROMPT", "0");
154    identity.apply_to(&mut revert_cmd);
155    let out = revert_cmd
156        .output()
157        .with_context(|| format!("revert_commit_in: git revert in {}", cwd.display()))?;
158    if !out.status.success() {
159        let stderr_raw = String::from_utf8_lossy(&out.stderr);
160        // Restore the working tree before bubbling — otherwise the dirty-tree
161        // guard above traps a subsequent rollback retry forever.
162        let _ = Command::new("git")
163            .current_dir(cwd)
164            .args(["revert", "--abort"])
165            .env("LC_ALL", "C")
166            .env("GIT_TERMINAL_PROMPT", "0")
167            .output();
168        let raw = format!(
169            "git revert {sha} hit conflicts and was aborted (working tree restored). \
170             The bump commit overlaps with later changes — resolve manually, \
171             or re-run with --mode=reset to force.\nstderr: {}",
172            stderr_raw.trim()
173        );
174        bail!("{}", crate::redact::redact_process_env(&raw));
175    }
176    if let Some(msg) = message {
177        let mut amend_cmd = Command::new("git");
178        amend_cmd
179            .current_dir(cwd)
180            .args(["commit", "--amend", "-m", msg])
181            .env("LC_ALL", "C")
182            .env("GIT_TERMINAL_PROMPT", "0");
183        identity.apply_to(&mut amend_cmd);
184        let out = amend_cmd.output().with_context(|| {
185            format!("revert_commit_in: git commit --amend in {}", cwd.display())
186        })?;
187        if !out.status.success() {
188            let stderr_raw = String::from_utf8_lossy(&out.stderr);
189            let raw = format!("git commit --amend failed: {}", stderr_raw.trim());
190            bail!("{}", crate::redact::redact_process_env(&raw));
191        }
192    }
193    Ok(())
194}
195
196/// Run `git reset --hard <sha>` in `cwd`. **Destructive** — rewrites HEAD
197/// and the index in place; callers must surface a warning before invoking.
198pub fn reset_hard_in(cwd: &Path, sha: &str) -> Result<()> {
199    git_output_in(cwd, &["reset", "--hard", sha])?;
200    Ok(())
201}
202
203/// Push a branch (`HEAD:refs/heads/<branch>`) to the `origin` remote.
204///
205/// Errors when no `origin` remote is configured — callers driving local-only
206/// flows should pass `--no-push` to skip the call entirely.
207pub fn push_branch_in(cwd: &Path, branch: &str) -> Result<()> {
208    if !super::has_remote_in(cwd, "origin") {
209        bail!("no 'origin' remote configured, cannot push branch '{branch}'");
210    }
211    let refspec = format!("HEAD:refs/heads/{}", branch);
212    let out = Command::new("git")
213        .current_dir(cwd)
214        .args(["push", "origin", &refspec])
215        .env("GIT_TERMINAL_PROMPT", "0")
216        .env("LC_ALL", "C")
217        .output()
218        .with_context(|| format!("push_branch_in: git push origin {refspec}"))?;
219    if !out.status.success() {
220        let stderr_raw = String::from_utf8_lossy(&out.stderr);
221        let raw = format!("git push origin {} failed: {}", refspec, stderr_raw.trim());
222        bail!("{}", crate::redact::redact_process_env(&raw));
223    }
224    Ok(())
225}
226
227/// `git -C <repo> log -1 --format=%ct HEAD` — return HEAD's committer
228/// timestamp (seconds since UNIX epoch) for the given repository. Used by
229/// the determinism harness as the non-snapshot SDE seed.
230pub fn head_commit_timestamp_in(repo: &std::path::Path) -> Result<i64> {
231    let out = Command::new("git")
232        .arg("-C")
233        .arg(repo)
234        .args(["log", "-1", "--format=%ct", "HEAD"])
235        .env("GIT_TERMINAL_PROMPT", "0")
236        .env("LC_ALL", "C")
237        .output()
238        .context("failed to invoke git log -1 --format=%ct HEAD")?;
239    if !out.status.success() {
240        let stderr_raw = String::from_utf8_lossy(&out.stderr);
241        let raw = format!("git log -1 --format=%ct HEAD failed: {}", stderr_raw.trim());
242        bail!("{}", crate::redact::redact_process_env(&raw));
243    }
244    let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
245    text.parse::<i64>()
246        .with_context(|| format!("git log --format=%ct returned non-i64 timestamp: {}", text))
247}