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