omni_dev/git/
amendment.rs1use crate::data::amendments::{Amendment, AmendmentFile};
4use anyhow::{Context, Result};
5use git2::{Oid, Repository};
6use std::collections::HashMap;
7use std::process::Command;
8
9pub struct AmendmentHandler {
11 repo: Repository,
12}
13
14impl AmendmentHandler {
15 pub fn new() -> Result<Self> {
17 let repo = Repository::open(".").context("Failed to open git repository")?;
18 Ok(Self { repo })
19 }
20
21 pub fn apply_amendments(&self, yaml_file: &str) -> Result<()> {
23 let amendment_file = AmendmentFile::load_from_file(yaml_file)?;
25
26 self.perform_safety_checks(&amendment_file)?;
28
29 let amendments = self.organize_amendments(&amendment_file.amendments)?;
31
32 if amendments.is_empty() {
33 println!("No valid amendments found to apply.");
34 return Ok(());
35 }
36
37 if amendments.len() == 1 && self.is_head_commit(&amendments[0].0)? {
39 println!("Amending HEAD commit: {}", &amendments[0].0[..8]);
40 self.amend_head_commit(&amendments[0].1)?;
41 } else {
42 println!(
43 "Amending {} commits using interactive rebase",
44 amendments.len()
45 );
46 self.amend_via_rebase(amendments)?;
47 }
48
49 println!("✅ Amendment operations completed successfully");
50 Ok(())
51 }
52
53 fn perform_safety_checks(&self, amendment_file: &AmendmentFile) -> Result<()> {
55 self.check_working_directory_clean()
57 .context("Cannot amend commits with uncommitted changes")?;
58
59 for amendment in &amendment_file.amendments {
61 self.validate_commit_amendable(&amendment.commit)?;
62 }
63
64 Ok(())
65 }
66
67 fn validate_commit_amendable(&self, commit_hash: &str) -> Result<()> {
69 let oid = Oid::from_str(commit_hash)
71 .with_context(|| format!("Invalid commit hash: {}", commit_hash))?;
72
73 let _commit = self
74 .repo
75 .find_commit(oid)
76 .with_context(|| format!("Commit not found: {}", commit_hash))?;
77
78 Ok(())
83 }
84
85 fn organize_amendments(&self, amendments: &[Amendment]) -> Result<Vec<(String, String)>> {
87 let mut valid_amendments = Vec::new();
88 let mut commit_depths = HashMap::new();
89
90 for amendment in amendments {
92 if let Ok(depth) = self.get_commit_depth_from_head(&amendment.commit) {
93 commit_depths.insert(amendment.commit.clone(), depth);
94 valid_amendments.push((amendment.commit.clone(), amendment.message.clone()));
95 } else {
96 println!(
97 "Warning: Skipping invalid commit {}",
98 &amendment.commit[..8]
99 );
100 }
101 }
102
103 valid_amendments.sort_by_key(|(commit, _)| commit_depths.get(commit).copied().unwrap_or(0));
105
106 valid_amendments.reverse();
108
109 Ok(valid_amendments)
110 }
111
112 fn get_commit_depth_from_head(&self, commit_hash: &str) -> Result<usize> {
114 let target_oid = Oid::from_str(commit_hash)?;
115 let mut revwalk = self.repo.revwalk()?;
116 revwalk.push_head()?;
117
118 for (depth, oid_result) in revwalk.enumerate() {
119 let oid = oid_result?;
120 if oid == target_oid {
121 return Ok(depth);
122 }
123 }
124
125 anyhow::bail!("Commit {} not found in current branch history", commit_hash);
126 }
127
128 fn is_head_commit(&self, commit_hash: &str) -> Result<bool> {
130 let head_oid = self.repo.head()?.target().context("HEAD has no target")?;
131 let target_oid = Oid::from_str(commit_hash)?;
132 Ok(head_oid == target_oid)
133 }
134
135 fn amend_head_commit(&self, new_message: &str) -> Result<()> {
137 let head_commit = self.repo.head()?.peel_to_commit()?;
138
139 let output = Command::new("git")
141 .args(["commit", "--amend", "--message", new_message])
142 .output()
143 .context("Failed to execute git commit --amend")?;
144
145 if !output.status.success() {
146 let error_msg = String::from_utf8_lossy(&output.stderr);
147 anyhow::bail!("Failed to amend HEAD commit: {}", error_msg);
148 }
149
150 let new_head = self.repo.head()?.peel_to_commit()?;
152
153 println!(
154 "✅ Amended HEAD commit {} -> {}",
155 &head_commit.id().to_string()[..8],
156 &new_head.id().to_string()[..8]
157 );
158
159 Ok(())
160 }
161
162 fn amend_via_rebase(&self, amendments: Vec<(String, String)>) -> Result<()> {
164 if amendments.is_empty() {
165 return Ok(());
166 }
167
168 println!("Amending commits individually in reverse order (newest to oldest)");
169
170 let mut sorted_amendments = amendments.clone();
172 sorted_amendments
173 .sort_by_key(|(hash, _)| self.get_commit_depth_from_head(hash).unwrap_or(usize::MAX));
174
175 for (commit_hash, new_message) in sorted_amendments {
177 let depth = self.get_commit_depth_from_head(&commit_hash)?;
178
179 if depth == 0 {
180 println!("Amending HEAD commit: {}", &commit_hash[..8]);
182 self.amend_head_commit(&new_message)?;
183 } else {
184 println!("Amending commit at depth {}: {}", depth, &commit_hash[..8]);
186 self.amend_single_commit_via_rebase(&commit_hash, &new_message)?;
187 }
188 }
189
190 Ok(())
191 }
192
193 fn amend_single_commit_via_rebase(&self, commit_hash: &str, new_message: &str) -> Result<()> {
195 let base_commit = format!("{}^", commit_hash);
197
198 let temp_dir = tempfile::tempdir()?;
200 let sequence_file = temp_dir.path().join("rebase-sequence");
201
202 let mut sequence_content = String::new();
204 let commit_list_output = Command::new("git")
205 .args(["rev-list", "--reverse", &format!("{}..HEAD", base_commit)])
206 .output()
207 .context("Failed to get commit list for rebase")?;
208
209 if !commit_list_output.status.success() {
210 anyhow::bail!("Failed to generate commit list for rebase");
211 }
212
213 let commit_list = String::from_utf8_lossy(&commit_list_output.stdout);
214 for line in commit_list.lines() {
215 let commit = line.trim();
216 if commit.is_empty() {
217 continue;
218 }
219
220 let subject_output = Command::new("git")
222 .args(["log", "--format=%s", "-n", "1", commit])
223 .output()
224 .context("Failed to get commit subject")?;
225
226 let subject = String::from_utf8_lossy(&subject_output.stdout)
227 .trim()
228 .to_string();
229
230 if commit.starts_with(&commit_hash[..commit.len().min(commit_hash.len())]) {
231 sequence_content.push_str(&format!("edit {} {}\n", commit, subject));
233 } else {
234 sequence_content.push_str(&format!("pick {} {}\n", commit, subject));
236 }
237 }
238
239 std::fs::write(&sequence_file, sequence_content)?;
241
242 println!(
243 "Starting interactive rebase to amend commit: {}",
244 &commit_hash[..8]
245 );
246
247 let rebase_result = Command::new("git")
249 .args(["rebase", "-i", &base_commit])
250 .env(
251 "GIT_SEQUENCE_EDITOR",
252 format!("cp {}", sequence_file.display()),
253 )
254 .env("GIT_EDITOR", "true") .output()
256 .context("Failed to start interactive rebase")?;
257
258 if !rebase_result.status.success() {
259 let error_msg = String::from_utf8_lossy(&rebase_result.stderr);
260
261 let _ = Command::new("git").args(["rebase", "--abort"]).output();
263
264 anyhow::bail!("Interactive rebase failed: {}", error_msg);
265 }
266
267 let repo_state = self.repo.state();
269 if repo_state == git2::RepositoryState::RebaseInteractive {
270 let current_commit_output = Command::new("git")
272 .args(["rev-parse", "HEAD"])
273 .output()
274 .context("Failed to get current commit during rebase")?;
275
276 let current_commit = String::from_utf8_lossy(¤t_commit_output.stdout)
277 .trim()
278 .to_string();
279
280 if current_commit
281 .starts_with(&commit_hash[..current_commit.len().min(commit_hash.len())])
282 {
283 let amend_result = Command::new("git")
285 .args(["commit", "--amend", "-m", new_message])
286 .output()
287 .context("Failed to amend commit during rebase")?;
288
289 if !amend_result.status.success() {
290 let error_msg = String::from_utf8_lossy(&amend_result.stderr);
291 let _ = Command::new("git").args(["rebase", "--abort"]).output();
292 anyhow::bail!("Failed to amend commit: {}", error_msg);
293 }
294
295 println!("✅ Amended commit: {}", &commit_hash[..8]);
296
297 let continue_result = Command::new("git")
299 .args(["rebase", "--continue"])
300 .output()
301 .context("Failed to continue rebase")?;
302
303 if !continue_result.status.success() {
304 let error_msg = String::from_utf8_lossy(&continue_result.stderr);
305 let _ = Command::new("git").args(["rebase", "--abort"]).output();
306 anyhow::bail!("Failed to continue rebase: {}", error_msg);
307 }
308
309 println!("✅ Rebase completed successfully");
310 } else {
311 let _ = Command::new("git").args(["rebase", "--abort"]).output();
312 anyhow::bail!(
313 "Unexpected commit during rebase. Expected {}, got {}",
314 &commit_hash[..8],
315 ¤t_commit[..8]
316 );
317 }
318 } else if repo_state != git2::RepositoryState::Clean {
319 anyhow::bail!(
320 "Repository in unexpected state after rebase: {:?}",
321 repo_state
322 );
323 }
324
325 Ok(())
326 }
327
328 fn check_working_directory_clean(&self) -> Result<()> {
330 let statuses = self
331 .repo
332 .statuses(None)
333 .context("Failed to get repository status")?;
334
335 let actual_changes: Vec<_> = statuses
337 .iter()
338 .filter(|entry| {
339 let status = entry.status();
340 !status.is_ignored()
342 })
343 .collect();
344
345 if !actual_changes.is_empty() {
346 println!("Working directory has uncommitted changes:");
348 for status_entry in &actual_changes {
349 let status = status_entry.status();
350 let file_path = status_entry.path().unwrap_or("unknown");
351 println!(" {} -> {:?}", file_path, status);
352 }
353
354 anyhow::bail!(
355 "Working directory is not clean. Please commit or stash changes before amending commit messages."
356 );
357 }
358
359 Ok(())
360 }
361}