anodizer_core/git/commits/
rollback.rs1use super::*;
2use anyhow::{Context as _, Result, bail};
3use std::path::Path;
4use std::process::Command;
5
6#[derive(Debug, Clone, Default)]
19pub struct CommitterIdentity {
20 pub name: Option<String>,
21 pub email: Option<String>,
22}
23
24impl CommitterIdentity {
25 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
53pub(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
76pub 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
97pub 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 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
196pub fn reset_hard_in(cwd: &Path, sha: &str) -> Result<()> {
199 git_output_in(cwd, &["reset", "--hard", sha])?;
200 Ok(())
201}
202
203pub 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
227pub 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}