omni_dev/git/
amendment.rs

1//! Git commit amendment operations
2
3use crate::data::amendments::{Amendment, AmendmentFile};
4use anyhow::{Context, Result};
5use git2::{Oid, Repository};
6use std::collections::HashMap;
7use std::process::Command;
8
9/// Amendment operation handler
10pub struct AmendmentHandler {
11    repo: Repository,
12}
13
14impl AmendmentHandler {
15    /// Create a new amendment handler
16    pub fn new() -> Result<Self> {
17        let repo = Repository::open(".").context("Failed to open git repository")?;
18        Ok(Self { repo })
19    }
20
21    /// Apply amendments from a YAML file
22    pub fn apply_amendments(&self, yaml_file: &str) -> Result<()> {
23        // Load and validate amendment file
24        let amendment_file = AmendmentFile::load_from_file(yaml_file)?;
25
26        // Safety checks
27        self.perform_safety_checks(&amendment_file)?;
28
29        // Group amendments by their position in history
30        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        // Check if we only need to amend HEAD
38        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    /// Perform safety checks before amendment
54    fn perform_safety_checks(&self, amendment_file: &AmendmentFile) -> Result<()> {
55        // Check if working directory is clean
56        self.check_working_directory_clean()
57            .context("Cannot amend commits with uncommitted changes")?;
58
59        // Check if commits exist and are not in remote main branches
60        for amendment in &amendment_file.amendments {
61            self.validate_commit_amendable(&amendment.commit)?;
62        }
63
64        Ok(())
65    }
66
67    /// Validate that a commit can be safely amended
68    fn validate_commit_amendable(&self, commit_hash: &str) -> Result<()> {
69        // Check if commit exists
70        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        // TODO: Check if commit is in remote main branches
79        // This would require implementing main branch detection and remote checking
80        // For now, we'll skip this check as it's complex and the basic functionality works
81
82        Ok(())
83    }
84
85    /// Organize amendments by their order in git history
86    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        // Calculate depth of each commit from HEAD
91        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        // Sort by depth (deepest first for rebase order)
104        valid_amendments.sort_by_key(|(commit, _)| commit_depths.get(commit).copied().unwrap_or(0));
105
106        // Reverse so we process from oldest to newest
107        valid_amendments.reverse();
108
109        Ok(valid_amendments)
110    }
111
112    /// Get the depth of a commit from HEAD (0 = HEAD, 1 = HEAD~1, etc.)
113    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    /// Check if a commit hash is the current HEAD
129    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    /// Amend the HEAD commit message
136    fn amend_head_commit(&self, new_message: &str) -> Result<()> {
137        let head_commit = self.repo.head()?.peel_to_commit()?;
138
139        // Use the simpler approach: git commit --amend
140        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        // Get the new commit ID for logging
151        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    /// Amend commits via individual interactive rebases (following shell script strategy)
163    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        // Sort amendments by commit depth (newest first, following shell script approach)
171        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        // Process each commit individually
176        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                // This is HEAD - simple amendment
181                println!("Amending HEAD commit: {}", &commit_hash[..8]);
182                self.amend_head_commit(&new_message)?;
183            } else {
184                // This is an older commit - use individual interactive rebase
185                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    /// Amend a single commit using individual interactive rebase (shell script strategy)
194    fn amend_single_commit_via_rebase(&self, commit_hash: &str, new_message: &str) -> Result<()> {
195        // Get the parent of the target commit to use as rebase base
196        let base_commit = format!("{}^", commit_hash);
197
198        // Create temporary sequence file for this specific rebase
199        let temp_dir = tempfile::tempdir()?;
200        let sequence_file = temp_dir.path().join("rebase-sequence");
201
202        // Generate rebase sequence: edit the target commit, pick the rest
203        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            // Get short commit message for the sequence file
221            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                // This is our target commit - mark it for editing
232                sequence_content.push_str(&format!("edit {} {}\n", commit, subject));
233            } else {
234                // Other commits - just pick them
235                sequence_content.push_str(&format!("pick {} {}\n", commit, subject));
236            }
237        }
238
239        // Write sequence file
240        std::fs::write(&sequence_file, sequence_content)?;
241
242        println!(
243            "Starting interactive rebase to amend commit: {}",
244            &commit_hash[..8]
245        );
246
247        // Execute rebase with custom sequence editor
248        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") // Prevent interactive editor
255            .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            // Try to abort the rebase if it failed
262            let _ = Command::new("git").args(["rebase", "--abort"]).output();
263
264            anyhow::bail!("Interactive rebase failed: {}", error_msg);
265        }
266
267        // Check if we're now in a rebase state where we can amend
268        let repo_state = self.repo.state();
269        if repo_state == git2::RepositoryState::RebaseInteractive {
270            // We should be stopped at the target commit - amend it
271            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(&current_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                // Amend with new message
284                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                // Continue the rebase
298                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                    &current_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    /// Check if working directory is clean (uses the repository instance)
329    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        // Filter out ignored files and only check for actual uncommitted changes
336        let actual_changes: Vec<_> = statuses
337            .iter()
338            .filter(|entry| {
339                let status = entry.status();
340                // Only consider files that have actual changes, not ignored files
341                !status.is_ignored()
342            })
343            .collect();
344
345        if !actual_changes.is_empty() {
346            // Print details about what's unclean for debugging
347            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}