Skip to main content

omni_dev/git/
amendment.rs

1//! Git commit amendment operations.
2
3use 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
13/// Amendment operation handler.
14pub struct AmendmentHandler {
15    repo: Repository,
16}
17
18impl AmendmentHandler {
19    /// Creates a new amendment handler.
20    pub fn new() -> Result<Self> {
21        let repo = Repository::open(".").context("Failed to open git repository")?;
22        Ok(Self { repo })
23    }
24
25    /// Applies amendments from a YAML file.
26    pub fn apply_amendments(&self, yaml_file: &str) -> Result<()> {
27        // Load and validate amendment file
28        let amendment_file = AmendmentFile::load_from_file(yaml_file)?;
29
30        // Safety checks
31        self.perform_safety_checks(&amendment_file)?;
32
33        // Group amendments by their position in history
34        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        // Check if we only need to amend HEAD
42        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    /// Performs safety checks before amendment.
61    fn perform_safety_checks(&self, amendment_file: &AmendmentFile) -> Result<()> {
62        // Check if working directory is clean
63        crate::utils::preflight::check_working_directory_clean()
64            .context("Cannot amend commits with uncommitted changes")?;
65
66        // Check if commits exist and are not in remote main branches
67        for amendment in &amendment_file.amendments {
68            self.validate_commit_amendable(&amendment.commit)?;
69        }
70
71        Ok(())
72    }
73
74    /// Validates that a commit can be safely amended.
75    fn validate_commit_amendable(&self, commit_hash: &str) -> Result<()> {
76        // Check if commit exists
77        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        // TODO: Check if commit is in remote main branches
86        // This would require implementing main branch detection and remote checking
87        // For now, we'll skip this check as it's complex and the basic functionality works
88
89        Ok(())
90    }
91
92    /// Organizes amendments by their order in git history.
93    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        // Calculate depth of each commit from HEAD
98        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        // Sort by depth (deepest first for rebase order)
111        valid_amendments.sort_by_key(|(commit, _)| commit_depths.get(commit).copied().unwrap_or(0));
112
113        // Reverse so we process from oldest to newest
114        valid_amendments.reverse();
115
116        Ok(valid_amendments)
117    }
118
119    /// Returns the depth of a commit from HEAD (0 = HEAD, 1 = HEAD~1, etc.).
120    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    /// Checks if a commit hash is the current HEAD.
136    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    /// Amends the HEAD commit message.
143    fn amend_head_commit(&self, new_message: &str) -> Result<()> {
144        let head_commit = self.repo.head()?.peel_to_commit()?;
145
146        // Use the simpler approach: git commit --amend
147        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        // Get the new commit ID for logging
158        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    /// Amends commits via individual interactive rebases (following shell script strategy).
170    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        // Sort amendments by commit depth (newest first, following shell script approach)
178        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        // Process each commit individually
183        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                // This is HEAD - simple amendment
188                println!("Amending HEAD commit: {}", &commit_hash[..SHORT_HASH_LEN]);
189                self.amend_head_commit(&new_message)?;
190            } else {
191                // This is an older commit - use individual interactive rebase
192                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    /// Amends a single commit using individual interactive rebase (shell script strategy).
205    fn amend_single_commit_via_rebase(&self, commit_hash: &str, new_message: &str) -> Result<()> {
206        // Get the parent of the target commit to use as rebase base
207        let base_commit = format!("{commit_hash}^");
208
209        // Create temporary sequence file for this specific rebase
210        let temp_dir = tempfile::tempdir()?;
211        let sequence_file = temp_dir.path().join("rebase-sequence");
212
213        // Generate rebase sequence: edit the target commit, pick the rest
214        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            // Get short commit message for the sequence file
232            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                // This is our target commit - mark it for editing
243                sequence_content.push_str(&format!("edit {commit} {subject}\n"));
244            } else {
245                // Other commits - just pick them
246                sequence_content.push_str(&format!("pick {commit} {subject}\n"));
247            }
248        }
249
250        // Write sequence file
251        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        // Execute rebase with custom sequence editor
259        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") // Prevent interactive editor
266            .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            // Best-effort cleanup; the rebase may not have started.
273            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        // Check if we're now in a rebase state where we can amend
281        let repo_state = self.repo.state();
282        if repo_state == git2::RepositoryState::RebaseInteractive {
283            // We should be stopped at the target commit - amend it
284            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(&current_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                // Amend with new message
297                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                    // Best-effort cleanup; abort so the repo isn't left mid-rebase.
305                    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                // Continue the rebase
314                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                    // Best-effort cleanup; abort so the repo isn't left mid-rebase.
322                    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                // Best-effort cleanup; abort so the repo isn't left mid-rebase.
331                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                    &current_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}