omni_dev/git/
amendment.rs1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use anyhow::{Context, Result};
8use git2::{Oid, Repository};
9use tracing::debug;
10
11use crate::data::amendments::{Amendment, AmendmentFile};
12use crate::git::SHORT_HASH_LEN;
13
14pub struct AmendmentHandler {
16 repo: Repository,
17 repo_root: PathBuf,
20}
21
22impl AmendmentHandler {
23 pub fn new(repo_root: &Path) -> Result<Self> {
25 let repo = Repository::open(repo_root).context("Failed to open git repository")?;
26 Ok(Self {
27 repo,
28 repo_root: repo_root.to_path_buf(),
29 })
30 }
31
32 fn git_command(&self) -> Command {
36 let mut cmd = Command::new("git");
37 cmd.current_dir(&self.repo_root);
38 cmd
39 }
40
41 pub fn apply_amendments(&self, yaml_file: &str) -> Result<()> {
43 let amendment_file = AmendmentFile::load_from_file(yaml_file)?;
45
46 self.perform_safety_checks(&amendment_file)?;
48
49 let amendments = self.organize_amendments(&amendment_file.amendments)?;
51
52 if amendments.is_empty() {
53 println!("No valid amendments found to apply.");
54 return Ok(());
55 }
56
57 if amendments.len() == 1 && self.is_head_commit(&amendments[0].0)? {
59 println!(
60 "Amending HEAD commit: {}",
61 &amendments[0].0[..SHORT_HASH_LEN]
62 );
63 self.amend_head_commit(&amendments[0].1)?;
64 } else {
65 println!(
66 "Amending {} commits using interactive rebase",
67 amendments.len()
68 );
69 self.amend_via_rebase(amendments)?;
70 }
71
72 println!("✅ Amendment operations completed successfully");
73 Ok(())
74 }
75
76 fn perform_safety_checks(&self, amendment_file: &AmendmentFile) -> Result<()> {
78 crate::utils::preflight::check_working_directory_clean_at(&self.repo_root)
80 .context("Cannot amend commits with uncommitted changes")?;
81
82 for amendment in &amendment_file.amendments {
84 self.validate_commit_amendable(&amendment.commit)?;
85 }
86
87 Ok(())
88 }
89
90 fn validate_commit_amendable(&self, commit_hash: &str) -> Result<()> {
92 let oid = Oid::from_str(commit_hash)
94 .with_context(|| format!("Invalid commit hash: {commit_hash}"))?;
95
96 let _commit = self
97 .repo
98 .find_commit(oid)
99 .with_context(|| format!("Commit not found: {commit_hash}"))?;
100
101 Ok(())
106 }
107
108 fn organize_amendments(&self, amendments: &[Amendment]) -> Result<Vec<(String, String)>> {
110 let mut valid_amendments = Vec::new();
111 let mut commit_depths = HashMap::new();
112
113 for amendment in amendments {
115 if let Ok(depth) = self.get_commit_depth_from_head(&amendment.commit) {
116 commit_depths.insert(amendment.commit.clone(), depth);
117 valid_amendments.push((amendment.commit.clone(), amendment.message.clone()));
118 } else {
119 println!(
120 "Warning: Skipping invalid commit {}",
121 &amendment.commit[..SHORT_HASH_LEN]
122 );
123 }
124 }
125
126 valid_amendments.sort_by_key(|(commit, _)| commit_depths.get(commit).copied().unwrap_or(0));
128
129 valid_amendments.reverse();
131
132 Ok(valid_amendments)
133 }
134
135 fn get_commit_depth_from_head(&self, commit_hash: &str) -> Result<usize> {
137 let target_oid = Oid::from_str(commit_hash)?;
138 let mut revwalk = self.repo.revwalk()?;
139 revwalk.push_head()?;
140
141 for (depth, oid_result) in revwalk.enumerate() {
142 let oid = oid_result?;
143 if oid == target_oid {
144 return Ok(depth);
145 }
146 }
147
148 anyhow::bail!("Commit {commit_hash} not found in current branch history");
149 }
150
151 fn is_head_commit(&self, commit_hash: &str) -> Result<bool> {
153 let head_oid = self.repo.head()?.target().context("HEAD has no target")?;
154 let target_oid = Oid::from_str(commit_hash)?;
155 Ok(head_oid == target_oid)
156 }
157
158 fn amend_head_commit(&self, new_message: &str) -> Result<()> {
160 let head_commit = self.repo.head()?.peel_to_commit()?;
161
162 let output = self
164 .git_command()
165 .args(["commit", "--amend", "--message", new_message])
166 .output()
167 .context("Failed to execute git commit --amend")?;
168
169 if !output.status.success() {
170 let error_msg = String::from_utf8_lossy(&output.stderr);
171 anyhow::bail!("Failed to amend HEAD commit: {error_msg}");
172 }
173
174 let new_head = self.repo.head()?.peel_to_commit()?;
176
177 println!(
178 "✅ Amended HEAD commit {} -> {}",
179 &head_commit.id().to_string()[..SHORT_HASH_LEN],
180 &new_head.id().to_string()[..SHORT_HASH_LEN]
181 );
182
183 Ok(())
184 }
185
186 fn amend_via_rebase(&self, amendments: Vec<(String, String)>) -> Result<()> {
188 if amendments.is_empty() {
189 return Ok(());
190 }
191
192 println!("Amending commits individually in reverse order (newest to oldest)");
193
194 let mut sorted_amendments = amendments;
196 sorted_amendments
197 .sort_by_key(|(hash, _)| self.get_commit_depth_from_head(hash).unwrap_or(usize::MAX));
198
199 for (commit_hash, new_message) in sorted_amendments {
201 let depth = self.get_commit_depth_from_head(&commit_hash)?;
202
203 if depth == 0 {
204 println!("Amending HEAD commit: {}", &commit_hash[..SHORT_HASH_LEN]);
206 self.amend_head_commit(&new_message)?;
207 } else {
208 println!(
210 "Amending commit at depth {}: {}",
211 depth,
212 &commit_hash[..SHORT_HASH_LEN]
213 );
214 self.amend_single_commit_via_rebase(&commit_hash, &new_message)?;
215 }
216 }
217
218 Ok(())
219 }
220
221 fn amend_single_commit_via_rebase(&self, commit_hash: &str, new_message: &str) -> Result<()> {
223 let base_commit = format!("{commit_hash}^");
225
226 let temp_dir = tempfile::tempdir()?;
228 let sequence_file = temp_dir.path().join("rebase-sequence");
229
230 let mut sequence_content = String::new();
232 let commit_list_output = self
233 .git_command()
234 .args(["rev-list", "--reverse", &format!("{base_commit}..HEAD")])
235 .output()
236 .context("Failed to get commit list for rebase")?;
237
238 if !commit_list_output.status.success() {
239 anyhow::bail!("Failed to generate commit list for rebase");
240 }
241
242 let commit_list = String::from_utf8_lossy(&commit_list_output.stdout);
243 for line in commit_list.lines() {
244 let commit = line.trim();
245 if commit.is_empty() {
246 continue;
247 }
248
249 let subject_output = self
251 .git_command()
252 .args(["log", "--format=%s", "-n", "1", commit])
253 .output()
254 .context("Failed to get commit subject")?;
255
256 let subject = String::from_utf8_lossy(&subject_output.stdout)
257 .trim()
258 .to_string();
259
260 if commit.starts_with(&commit_hash[..commit.len().min(commit_hash.len())]) {
261 sequence_content.push_str(&format!("edit {commit} {subject}\n"));
263 } else {
264 sequence_content.push_str(&format!("pick {commit} {subject}\n"));
266 }
267 }
268
269 std::fs::write(&sequence_file, sequence_content)?;
271
272 println!(
273 "Starting interactive rebase to amend commit: {}",
274 &commit_hash[..SHORT_HASH_LEN]
275 );
276
277 let rebase_result = self.git_command()
279 .args(["rebase", "-i", &base_commit])
280 .env(
281 "GIT_SEQUENCE_EDITOR",
282 format!("cp {}", sequence_file.display()),
283 )
284 .env("GIT_EDITOR", "true") .output()
286 .context("Failed to start interactive rebase")?;
287
288 if !rebase_result.status.success() {
289 let error_msg = String::from_utf8_lossy(&rebase_result.stderr);
290
291 if let Err(e) = self.git_command().args(["rebase", "--abort"]).output() {
293 debug!("Rebase abort during cleanup failed: {e}");
294 }
295
296 anyhow::bail!("Interactive rebase failed: {error_msg}");
297 }
298
299 let repo_state = self.repo.state();
301 if repo_state == git2::RepositoryState::RebaseInteractive {
302 let current_commit_output = self
304 .git_command()
305 .args(["rev-parse", "HEAD"])
306 .output()
307 .context("Failed to get current commit during rebase")?;
308
309 let current_commit = String::from_utf8_lossy(¤t_commit_output.stdout)
310 .trim()
311 .to_string();
312
313 if current_commit
314 .starts_with(&commit_hash[..current_commit.len().min(commit_hash.len())])
315 {
316 let amend_result = self
318 .git_command()
319 .args(["commit", "--amend", "-m", new_message])
320 .output()
321 .context("Failed to amend commit during rebase")?;
322
323 if !amend_result.status.success() {
324 let error_msg = String::from_utf8_lossy(&amend_result.stderr);
325 if let Err(e) = self.git_command().args(["rebase", "--abort"]).output() {
327 debug!("Rebase abort during cleanup failed: {e}");
328 }
329 anyhow::bail!("Failed to amend commit: {error_msg}");
330 }
331
332 println!("✅ Amended commit: {}", &commit_hash[..SHORT_HASH_LEN]);
333
334 let continue_result = self
336 .git_command()
337 .args(["rebase", "--continue"])
338 .output()
339 .context("Failed to continue rebase")?;
340
341 if !continue_result.status.success() {
342 let error_msg = String::from_utf8_lossy(&continue_result.stderr);
343 if let Err(e) = self.git_command().args(["rebase", "--abort"]).output() {
345 debug!("Rebase abort during cleanup failed: {e}");
346 }
347 anyhow::bail!("Failed to continue rebase: {error_msg}");
348 }
349
350 println!("✅ Rebase completed successfully");
351 } else {
352 if let Err(e) = self.git_command().args(["rebase", "--abort"]).output() {
354 debug!("Rebase abort during cleanup failed: {e}");
355 }
356 anyhow::bail!(
357 "Unexpected commit during rebase. Expected {}, got {}",
358 &commit_hash[..SHORT_HASH_LEN],
359 ¤t_commit[..SHORT_HASH_LEN]
360 );
361 }
362 } else if repo_state != git2::RepositoryState::Clean {
363 anyhow::bail!("Repository in unexpected state after rebase: {repo_state:?}");
364 }
365
366 Ok(())
367 }
368}