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    /// Permits amending commits that already exist in remote main branches.
21    allow_pushed: bool,
22}
23
24impl AmendmentHandler {
25    /// Creates a new amendment handler for the repository at `repo_root`.
26    pub fn new(repo_root: &Path) -> Result<Self> {
27        let repo = Repository::open(repo_root).context("Failed to open git repository")?;
28        Ok(Self {
29            repo,
30            repo_root: repo_root.to_path_buf(),
31            allow_pushed: false,
32        })
33    }
34
35    /// Permits amending commits that already exist in remote main branches.
36    ///
37    /// Off by default: amending a pushed commit rewrites published history, so
38    /// callers must opt in explicitly (the `--allow-pushed` CLI flag).
39    #[must_use]
40    pub fn with_allow_pushed(mut self, allow_pushed: bool) -> Self {
41        self.allow_pushed = allow_pushed;
42        self
43    }
44
45    /// Builds a `git` subprocess pinned to the handler's repo workdir, so every
46    /// rebase/commit/read operation targets the injected repository rather than
47    /// the process current working directory.
48    fn git_command(&self) -> Command {
49        let mut cmd = Command::new("git");
50        cmd.current_dir(&self.repo_root);
51        cmd
52    }
53
54    /// Applies amendments from a YAML file.
55    pub fn apply_amendments(&self, yaml_file: &str) -> Result<()> {
56        // Load and validate amendment file
57        let amendment_file = AmendmentFile::load_from_file(yaml_file)?;
58        self.apply_amendment_file(&amendment_file)
59    }
60
61    /// Applies an already-parsed amendment file.
62    ///
63    /// The core of [`Self::apply_amendments`], split out so callers that hold
64    /// the amendments in memory (the MCP `git_amend_commits` tool, which
65    /// receives them as an inline YAML string) reuse the identical
66    /// safety-check + apply path without a round-trip through a temp file.
67    pub fn apply_amendment_file(&self, amendment_file: &AmendmentFile) -> Result<()> {
68        // Safety checks
69        self.perform_safety_checks(amendment_file)?;
70
71        // Group amendments by their position in history
72        let amendments = self.organize_amendments(&amendment_file.amendments)?;
73
74        if amendments.is_empty() {
75            println!("No valid amendments found to apply.");
76            return Ok(());
77        }
78
79        // Check if we only need to amend HEAD
80        if amendments.len() == 1 && self.is_head_commit(&amendments[0].0)? {
81            println!(
82                "Amending HEAD commit: {}",
83                &amendments[0].0[..SHORT_HASH_LEN]
84            );
85            self.amend_head_commit(&amendments[0].1)?;
86        } else {
87            println!(
88                "Amending {} commits using interactive rebase",
89                amendments.len()
90            );
91            self.amend_via_rebase(amendments)?;
92        }
93
94        println!("✅ Amendment operations completed successfully");
95        Ok(())
96    }
97
98    /// Performs safety checks before amendment.
99    fn perform_safety_checks(&self, amendment_file: &AmendmentFile) -> Result<()> {
100        // Check if working directory is clean
101        crate::utils::preflight::check_working_directory_clean_at(&self.repo_root)
102            .context("Cannot amend commits with uncommitted changes")?;
103
104        // Check if commits exist and are not in remote main branches
105        let main_tips = crate::git::main_branches::detect_main_branch_tips(&self.repo)?;
106        for amendment in &amendment_file.amendments {
107            self.validate_commit_amendable(&amendment.commit, &main_tips)?;
108        }
109
110        Ok(())
111    }
112
113    /// Validates that a commit can be safely amended.
114    fn validate_commit_amendable(
115        &self,
116        commit_hash: &str,
117        main_tips: &[crate::git::main_branches::MainBranchTip],
118    ) -> Result<()> {
119        // Check if commit exists
120        let oid = Oid::from_str(commit_hash)
121            .with_context(|| format!("Invalid commit hash: {commit_hash}"))?;
122
123        let _commit = self
124            .repo
125            .find_commit(oid)
126            .with_context(|| format!("Commit not found: {commit_hash}"))?;
127
128        let containing =
129            crate::git::main_branches::branches_containing(&self.repo, main_tips, oid)?;
130        if !containing.is_empty() {
131            let short_hash = &commit_hash[..SHORT_HASH_LEN.min(commit_hash.len())];
132            let branches = containing.join(", ");
133            if !self.allow_pushed {
134                anyhow::bail!(
135                    "Refusing to amend commit {short_hash}: it is already in remote main \
136                     branch(es): {branches}\n\
137                     Amending pushed commits rewrites published history. Re-run with \
138                     --allow-pushed to override."
139                );
140            }
141            println!("⚠️  Amending commit {short_hash} that exists in {branches} (--allow-pushed)");
142        }
143
144        Ok(())
145    }
146
147    /// Organizes amendments by their order in git history.
148    fn organize_amendments(&self, amendments: &[Amendment]) -> Result<Vec<(String, String)>> {
149        let mut valid_amendments = Vec::new();
150        let mut commit_depths = HashMap::new();
151
152        // Calculate depth of each commit from HEAD
153        for amendment in amendments {
154            if let Ok(depth) = self.get_commit_depth_from_head(&amendment.commit) {
155                commit_depths.insert(amendment.commit.clone(), depth);
156                valid_amendments.push((amendment.commit.clone(), amendment.message.clone()));
157            } else {
158                println!(
159                    "Warning: Skipping invalid commit {}",
160                    &amendment.commit[..SHORT_HASH_LEN]
161                );
162            }
163        }
164
165        // Sort by depth (deepest first for rebase order)
166        valid_amendments.sort_by_key(|(commit, _)| commit_depths.get(commit).copied().unwrap_or(0));
167
168        // Reverse so we process from oldest to newest
169        valid_amendments.reverse();
170
171        Ok(valid_amendments)
172    }
173
174    /// Returns the depth of a commit from HEAD (0 = HEAD, 1 = HEAD~1, etc.).
175    fn get_commit_depth_from_head(&self, commit_hash: &str) -> Result<usize> {
176        let target_oid = Oid::from_str(commit_hash)?;
177        let mut revwalk = self.repo.revwalk()?;
178        revwalk.push_head()?;
179
180        for (depth, oid_result) in revwalk.enumerate() {
181            let oid = oid_result?;
182            if oid == target_oid {
183                return Ok(depth);
184            }
185        }
186
187        anyhow::bail!("Commit {commit_hash} not found in current branch history");
188    }
189
190    /// Checks if a commit hash is the current HEAD.
191    fn is_head_commit(&self, commit_hash: &str) -> Result<bool> {
192        let head_oid = self.repo.head()?.target().context("HEAD has no target")?;
193        let target_oid = Oid::from_str(commit_hash)?;
194        Ok(head_oid == target_oid)
195    }
196
197    /// Amends the HEAD commit message.
198    fn amend_head_commit(&self, new_message: &str) -> Result<()> {
199        let head_commit = self.repo.head()?.peel_to_commit()?;
200
201        // Use the simpler approach: git commit --amend
202        let output = self
203            .git_command()
204            .args(["commit", "--amend", "--message", new_message])
205            .output()
206            .context("Failed to execute git commit --amend")?;
207
208        if !output.status.success() {
209            let error_msg = String::from_utf8_lossy(&output.stderr);
210            anyhow::bail!("Failed to amend HEAD commit: {error_msg}");
211        }
212
213        // Get the new commit ID for logging
214        let new_head = self.repo.head()?.peel_to_commit()?;
215
216        println!(
217            "✅ Amended HEAD commit {} -> {}",
218            &head_commit.id().to_string()[..SHORT_HASH_LEN],
219            &new_head.id().to_string()[..SHORT_HASH_LEN]
220        );
221
222        Ok(())
223    }
224
225    /// Amends commits via individual interactive rebases (following shell script strategy).
226    fn amend_via_rebase(&self, amendments: Vec<(String, String)>) -> Result<()> {
227        if amendments.is_empty() {
228            return Ok(());
229        }
230
231        println!("Amending commits individually in reverse order (newest to oldest)");
232
233        // Sort amendments by commit depth (newest first, following shell script approach)
234        let mut sorted_amendments = amendments;
235        sorted_amendments
236            .sort_by_key(|(hash, _)| self.get_commit_depth_from_head(hash).unwrap_or(usize::MAX));
237
238        // Process each commit individually
239        for (commit_hash, new_message) in sorted_amendments {
240            let depth = self.get_commit_depth_from_head(&commit_hash)?;
241
242            if depth == 0 {
243                // This is HEAD - simple amendment
244                println!("Amending HEAD commit: {}", &commit_hash[..SHORT_HASH_LEN]);
245                self.amend_head_commit(&new_message)?;
246            } else {
247                // This is an older commit - use individual interactive rebase
248                println!(
249                    "Amending commit at depth {}: {}",
250                    depth,
251                    &commit_hash[..SHORT_HASH_LEN]
252                );
253                self.amend_single_commit_via_rebase(&commit_hash, &new_message)?;
254            }
255        }
256
257        Ok(())
258    }
259
260    /// Amends a single commit using individual interactive rebase (shell script strategy).
261    fn amend_single_commit_via_rebase(&self, commit_hash: &str, new_message: &str) -> Result<()> {
262        // Get the parent of the target commit to use as rebase base
263        let base_commit = format!("{commit_hash}^");
264
265        // Create temporary sequence file for this specific rebase
266        let temp_dir = tempfile::tempdir()?;
267        let sequence_file = temp_dir.path().join("rebase-sequence");
268
269        // Generate rebase sequence: edit the target commit, pick the rest
270        let mut sequence_content = String::new();
271        let commit_list_output = self
272            .git_command()
273            .args(["rev-list", "--reverse", &format!("{base_commit}..HEAD")])
274            .output()
275            .context("Failed to get commit list for rebase")?;
276
277        if !commit_list_output.status.success() {
278            anyhow::bail!("Failed to generate commit list for rebase");
279        }
280
281        let commit_list = String::from_utf8_lossy(&commit_list_output.stdout);
282        for line in commit_list.lines() {
283            let commit = line.trim();
284            if commit.is_empty() {
285                continue;
286            }
287
288            // Get short commit message for the sequence file
289            let subject_output = self
290                .git_command()
291                .args(["log", "--format=%s", "-n", "1", commit])
292                .output()
293                .context("Failed to get commit subject")?;
294
295            let subject = String::from_utf8_lossy(&subject_output.stdout)
296                .trim()
297                .to_string();
298
299            if commit.starts_with(&commit_hash[..commit.len().min(commit_hash.len())]) {
300                // This is our target commit - mark it for editing
301                sequence_content.push_str(&format!("edit {commit} {subject}\n"));
302            } else {
303                // Other commits - just pick them
304                sequence_content.push_str(&format!("pick {commit} {subject}\n"));
305            }
306        }
307
308        // Write sequence file
309        std::fs::write(&sequence_file, sequence_content)?;
310
311        println!(
312            "Starting interactive rebase to amend commit: {}",
313            &commit_hash[..SHORT_HASH_LEN]
314        );
315
316        // Execute rebase with custom sequence editor
317        let rebase_result = self.git_command()
318            .args(["rebase", "-i", &base_commit])
319            .env(
320                "GIT_SEQUENCE_EDITOR",
321                format!("cp {}", sequence_file.display()),
322            )
323            .env("GIT_EDITOR", "true") // Prevent interactive editor
324            .output()
325            .context("Failed to start interactive rebase")?;
326
327        if !rebase_result.status.success() {
328            let error_msg = String::from_utf8_lossy(&rebase_result.stderr);
329
330            // Best-effort cleanup; the rebase may not have started.
331            if let Err(e) = self.git_command().args(["rebase", "--abort"]).output() {
332                debug!("Rebase abort during cleanup failed: {e}");
333            }
334
335            anyhow::bail!("Interactive rebase failed: {error_msg}");
336        }
337
338        // Check if we're now in a rebase state where we can amend
339        let repo_state = self.repo.state();
340        if repo_state == git2::RepositoryState::RebaseInteractive {
341            // We should be stopped at the target commit - amend it
342            let current_commit_output = self
343                .git_command()
344                .args(["rev-parse", "HEAD"])
345                .output()
346                .context("Failed to get current commit during rebase")?;
347
348            let current_commit = String::from_utf8_lossy(&current_commit_output.stdout)
349                .trim()
350                .to_string();
351
352            if current_commit
353                .starts_with(&commit_hash[..current_commit.len().min(commit_hash.len())])
354            {
355                // Amend with new message
356                let amend_result = self
357                    .git_command()
358                    .args(["commit", "--amend", "-m", new_message])
359                    .output()
360                    .context("Failed to amend commit during rebase")?;
361
362                if !amend_result.status.success() {
363                    let error_msg = String::from_utf8_lossy(&amend_result.stderr);
364                    // Best-effort cleanup; abort so the repo isn't left mid-rebase.
365                    if let Err(e) = self.git_command().args(["rebase", "--abort"]).output() {
366                        debug!("Rebase abort during cleanup failed: {e}");
367                    }
368                    anyhow::bail!("Failed to amend commit: {error_msg}");
369                }
370
371                println!("✅ Amended commit: {}", &commit_hash[..SHORT_HASH_LEN]);
372
373                // Continue the rebase
374                let continue_result = self
375                    .git_command()
376                    .args(["rebase", "--continue"])
377                    .output()
378                    .context("Failed to continue rebase")?;
379
380                if !continue_result.status.success() {
381                    let error_msg = String::from_utf8_lossy(&continue_result.stderr);
382                    // Best-effort cleanup; abort so the repo isn't left mid-rebase.
383                    if let Err(e) = self.git_command().args(["rebase", "--abort"]).output() {
384                        debug!("Rebase abort during cleanup failed: {e}");
385                    }
386                    anyhow::bail!("Failed to continue rebase: {error_msg}");
387                }
388
389                println!("✅ Rebase completed successfully");
390            } else {
391                // Best-effort cleanup; abort so the repo isn't left mid-rebase.
392                if let Err(e) = self.git_command().args(["rebase", "--abort"]).output() {
393                    debug!("Rebase abort during cleanup failed: {e}");
394                }
395                anyhow::bail!(
396                    "Unexpected commit during rebase. Expected {}, got {}",
397                    &commit_hash[..SHORT_HASH_LEN],
398                    &current_commit[..SHORT_HASH_LEN]
399                );
400            }
401        } else if repo_state != git2::RepositoryState::Clean {
402            anyhow::bail!("Repository in unexpected state after rebase: {repo_state:?}");
403        }
404
405        Ok(())
406    }
407}
408
409#[cfg(test)]
410#[allow(clippy::unwrap_used, clippy::expect_used)]
411mod tests {
412    use super::*;
413
414    /// Runs `git` in `dir` with a deterministic identity, asserting success.
415    fn git_in(dir: &Path, args: &[&str]) {
416        let output = Command::new("git")
417            .current_dir(dir)
418            .args([
419                "-c",
420                "user.email=test@example.com",
421                "-c",
422                "user.name=Test",
423                "-c",
424                "commit.gpgsign=false",
425                "-c",
426                "tag.gpgsign=false",
427            ])
428            .args(args)
429            .output()
430            .unwrap();
431        let stderr = String::from_utf8_lossy(&output.stderr);
432        assert!(output.status.success(), "git {args:?} failed: {stderr}");
433    }
434
435    /// Builds a work repo on `main` with one commit pushed to a bare `origin`
436    /// remote. Local identity and no-signing config are set so the handler's
437    /// own `git` subprocesses stay hermetic. All three temp dirs are returned
438    /// so the caller keeps them alive: work repo, bare remote, and a scratch
439    /// dir for amendment YAML files (outside the worktree, which must stay
440    /// clean for the safety checks).
441    fn repo_with_pushed_main() -> (tempfile::TempDir, tempfile::TempDir, tempfile::TempDir) {
442        let tmp_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
443        std::fs::create_dir_all(&tmp_root).unwrap();
444        let bare = tempfile::tempdir_in(&tmp_root).unwrap();
445        git_in(bare.path(), &["init", "--bare"]);
446
447        let work = tempfile::tempdir_in(&tmp_root).unwrap();
448        git_in(work.path(), &["init"]);
449        git_in(work.path(), &["checkout", "-b", "main"]);
450        git_in(work.path(), &["config", "user.email", "test@example.com"]);
451        git_in(work.path(), &["config", "user.name", "Test"]);
452        git_in(work.path(), &["config", "commit.gpgsign", "false"]);
453        std::fs::write(work.path().join("file.txt"), "content").unwrap();
454        git_in(work.path(), &["add", "."]);
455        git_in(work.path(), &["commit", "-m", "initial pushed commit"]);
456        git_in(
457            work.path(),
458            &["remote", "add", "origin", bare.path().to_str().unwrap()],
459        );
460        git_in(work.path(), &["push", "origin", "main"]);
461
462        let scratch = tempfile::tempdir_in(&tmp_root).unwrap();
463        (work, bare, scratch)
464    }
465
466    fn head_hash(dir: &Path) -> String {
467        Repository::open(dir)
468            .unwrap()
469            .head()
470            .unwrap()
471            .target()
472            .unwrap()
473            .to_string()
474    }
475
476    fn head_message(dir: &Path) -> String {
477        let repo = Repository::open(dir).unwrap();
478        let head = repo.head().unwrap().peel_to_commit().unwrap();
479        head.message().unwrap_or("").to_string()
480    }
481
482    /// Writes an amendment YAML targeting `hash` into `scratch` and returns
483    /// its path as a string.
484    fn amendment_yaml(scratch: &Path, hash: &str, message: &str) -> String {
485        let file = crate::data::amendments::AmendmentFile {
486            amendments: vec![crate::data::amendments::Amendment {
487                commit: hash.to_string(),
488                message: message.to_string(),
489                summary: String::new(),
490            }],
491        };
492        let path = scratch.join("amendments.yaml");
493        file.save_to_file(&path).unwrap();
494        path.to_string_lossy().into_owned()
495    }
496
497    #[test]
498    fn refuses_amending_commit_in_remote_main() {
499        let (work, _bare, scratch) = repo_with_pushed_main();
500        let hash = head_hash(work.path());
501        let yaml = amendment_yaml(scratch.path(), &hash, "rewritten message");
502
503        let handler = AmendmentHandler::new(work.path()).unwrap();
504        let err = handler
505            .apply_amendments(&yaml)
506            .expect_err("amending a pushed commit must be refused");
507        let msg = format!("{err:#}");
508        assert!(
509            msg.contains(&hash[..SHORT_HASH_LEN]),
510            "error should name the short hash, got: {msg}"
511        );
512        assert!(
513            msg.contains("origin/main"),
514            "error should name the containing branch, got: {msg}"
515        );
516        assert!(
517            msg.contains("--allow-pushed"),
518            "error should mention the override flag, got: {msg}"
519        );
520        // The commit must be untouched.
521        assert_eq!(head_hash(work.path()), hash);
522    }
523
524    #[test]
525    fn allow_pushed_overrides_refusal() {
526        let (work, _bare, scratch) = repo_with_pushed_main();
527        let hash = head_hash(work.path());
528        let yaml = amendment_yaml(scratch.path(), &hash, "rewritten message");
529
530        let handler = AmendmentHandler::new(work.path())
531            .unwrap()
532            .with_allow_pushed(true);
533        handler
534            .apply_amendments(&yaml)
535            .expect("--allow-pushed must permit amending a pushed commit");
536        assert_eq!(head_message(work.path()).trim(), "rewritten message");
537    }
538
539    #[test]
540    fn unpushed_commit_amends_without_flag() {
541        let (work, _bare, scratch) = repo_with_pushed_main();
542        std::fs::write(work.path().join("new.txt"), "more").unwrap();
543        git_in(work.path(), &["add", "."]);
544        git_in(work.path(), &["commit", "-m", "unpushed commit"]);
545        let hash = head_hash(work.path());
546        let yaml = amendment_yaml(scratch.path(), &hash, "improved unpushed message");
547
548        let handler = AmendmentHandler::new(work.path()).unwrap();
549        handler
550            .apply_amendments(&yaml)
551            .expect("amending an unpushed commit must not require the flag");
552        assert_eq!(
553            head_message(work.path()).trim(),
554            "improved unpushed message"
555        );
556    }
557}