Skip to main content

omni_dev/git/
amendment.rs

1//! Git commit amendment operations.
2
3use 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
14/// Amendment operation handler.
15pub struct AmendmentHandler {
16    repo: Repository,
17    /// Workdir the `git` subprocesses are pinned to, so amendments target the
18    /// injected repository rather than the process current working directory.
19    repo_root: PathBuf,
20}
21
22impl AmendmentHandler {
23    /// Creates a new amendment handler for the repository at `repo_root`.
24    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    /// Builds a `git` subprocess pinned to the handler's repo workdir, so every
33    /// rebase/commit/read operation targets the injected repository rather than
34    /// the process current working directory.
35    fn git_command(&self) -> Command {
36        let mut cmd = Command::new("git");
37        cmd.current_dir(&self.repo_root);
38        cmd
39    }
40
41    /// Applies amendments from a YAML file.
42    pub fn apply_amendments(&self, yaml_file: &str) -> Result<()> {
43        // Load and validate amendment file
44        let amendment_file = AmendmentFile::load_from_file(yaml_file)?;
45
46        // Safety checks
47        self.perform_safety_checks(&amendment_file)?;
48
49        // Group amendments by their position in history
50        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        // Check if we only need to amend HEAD
58        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    /// Performs safety checks before amendment.
77    fn perform_safety_checks(&self, amendment_file: &AmendmentFile) -> Result<()> {
78        // Check if working directory is clean
79        crate::utils::preflight::check_working_directory_clean_at(&self.repo_root)
80            .context("Cannot amend commits with uncommitted changes")?;
81
82        // Check if commits exist and are not in remote main branches
83        for amendment in &amendment_file.amendments {
84            self.validate_commit_amendable(&amendment.commit)?;
85        }
86
87        Ok(())
88    }
89
90    /// Validates that a commit can be safely amended.
91    fn validate_commit_amendable(&self, commit_hash: &str) -> Result<()> {
92        // Check if commit exists
93        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        // TODO: Check if commit is in remote main branches
102        // This would require implementing main branch detection and remote checking
103        // For now, we'll skip this check as it's complex and the basic functionality works
104
105        Ok(())
106    }
107
108    /// Organizes amendments by their order in git history.
109    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        // Calculate depth of each commit from HEAD
114        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        // Sort by depth (deepest first for rebase order)
127        valid_amendments.sort_by_key(|(commit, _)| commit_depths.get(commit).copied().unwrap_or(0));
128
129        // Reverse so we process from oldest to newest
130        valid_amendments.reverse();
131
132        Ok(valid_amendments)
133    }
134
135    /// Returns the depth of a commit from HEAD (0 = HEAD, 1 = HEAD~1, etc.).
136    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    /// Checks if a commit hash is the current HEAD.
152    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    /// Amends the HEAD commit message.
159    fn amend_head_commit(&self, new_message: &str) -> Result<()> {
160        let head_commit = self.repo.head()?.peel_to_commit()?;
161
162        // Use the simpler approach: git commit --amend
163        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        // Get the new commit ID for logging
175        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    /// Amends commits via individual interactive rebases (following shell script strategy).
187    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        // Sort amendments by commit depth (newest first, following shell script approach)
195        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        // Process each commit individually
200        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                // This is HEAD - simple amendment
205                println!("Amending HEAD commit: {}", &commit_hash[..SHORT_HASH_LEN]);
206                self.amend_head_commit(&new_message)?;
207            } else {
208                // This is an older commit - use individual interactive rebase
209                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    /// Amends a single commit using individual interactive rebase (shell script strategy).
222    fn amend_single_commit_via_rebase(&self, commit_hash: &str, new_message: &str) -> Result<()> {
223        // Get the parent of the target commit to use as rebase base
224        let base_commit = format!("{commit_hash}^");
225
226        // Create temporary sequence file for this specific rebase
227        let temp_dir = tempfile::tempdir()?;
228        let sequence_file = temp_dir.path().join("rebase-sequence");
229
230        // Generate rebase sequence: edit the target commit, pick the rest
231        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            // Get short commit message for the sequence file
250            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                // This is our target commit - mark it for editing
262                sequence_content.push_str(&format!("edit {commit} {subject}\n"));
263            } else {
264                // Other commits - just pick them
265                sequence_content.push_str(&format!("pick {commit} {subject}\n"));
266            }
267        }
268
269        // Write sequence file
270        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        // Execute rebase with custom sequence editor
278        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") // Prevent interactive editor
285            .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            // Best-effort cleanup; the rebase may not have started.
292            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        // Check if we're now in a rebase state where we can amend
300        let repo_state = self.repo.state();
301        if repo_state == git2::RepositoryState::RebaseInteractive {
302            // We should be stopped at the target commit - amend it
303            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(&current_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                // Amend with new message
317                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                    // Best-effort cleanup; abort so the repo isn't left mid-rebase.
326                    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                // Continue the rebase
335                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                    // Best-effort cleanup; abort so the repo isn't left mid-rebase.
344                    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                // Best-effort cleanup; abort so the repo isn't left mid-rebase.
353                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                    &current_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}