Skip to main content

omni_dev/cli/git/
amend.rs

1//! Amend command — applies commit message amendments from a YAML file.
2
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use clap::Parser;
7
8/// Amend command options.
9#[derive(Parser)]
10pub struct AmendCommand {
11    /// YAML file containing commit amendments.
12    #[arg(value_name = "YAML_FILE")]
13    pub yaml_file: String,
14
15    /// Allows amending commits that already exist in remote main branches (rewrites published history).
16    #[arg(long)]
17    pub allow_pushed: bool,
18}
19
20impl AmendCommand {
21    /// Executes the amend command.
22    ///
23    /// `repo` is the repository location resolved at the CLI boundary
24    /// (`None` = current working directory).
25    pub fn execute(self, repo: Option<&std::path::Path>) -> Result<()> {
26        use crate::git::AmendmentHandler;
27
28        // Resolve the repo root once; the preflight checks and the amendment
29        // handler (including all its `git` subprocesses) anchor to it.
30        let repo_root = match repo {
31            Some(p) => p.to_path_buf(),
32            None => std::env::current_dir().context("Failed to determine current directory")?,
33        };
34
35        // Preflight checks: validate prerequisites before any processing
36        crate::utils::check_git_repository_at(&repo_root)?;
37        crate::utils::check_working_directory_clean_at(&repo_root)?;
38
39        println!("🔄 Starting commit amendment process...");
40        println!("📄 Loading amendments from: {}", self.yaml_file);
41
42        // Create amendment handler and apply amendments
43        let handler = AmendmentHandler::new(&repo_root)
44            .context("Failed to initialize amendment handler")?
45            .with_allow_pushed(self.allow_pushed);
46
47        handler
48            .apply_amendments(&self.yaml_file)
49            .context("Failed to apply amendments")?;
50
51        Ok(())
52    }
53}
54
55/// Structured output from [`run_amend`] for programmatic consumers (MCP).
56#[derive(Debug, Clone)]
57pub struct AmendOutcome {
58    /// `true` when amendments were applied to the repository; `false` when the
59    /// file contained no amendments (nothing to do).
60    pub applied: bool,
61    /// Number of amendments in the supplied file.
62    pub amendment_count: usize,
63}
64
65/// Non-interactive core for `omni-dev git commit message amend`.
66///
67/// The deterministic, apply-messages-from-YAML counterpart to
68/// [`crate::cli::git::run_twiddle`]: unlike twiddle it makes no AI call, taking
69/// the amendments verbatim. Shared with the MCP `git_amend_commits` tool, which
70/// passes the amendments as an inline YAML string rather than a file path.
71///
72/// Runs the same preflight the CLI command does (repo present, working tree
73/// clean), then applies the amendments via [`crate::git::AmendmentHandler`],
74/// which refuses commits already in a remote main branch unless `allow_pushed`
75/// is set. `repo_path` selects the repository (`None` = current working
76/// directory).
77pub fn run_amend(
78    amendments_yaml: &str,
79    allow_pushed: bool,
80    repo_path: Option<&Path>,
81) -> Result<AmendOutcome> {
82    use crate::data::amendments::AmendmentFile;
83    use crate::git::AmendmentHandler;
84
85    let repo_root = match repo_path {
86        Some(p) => p.to_path_buf(),
87        None => std::env::current_dir().context("Failed to determine current directory")?,
88    };
89
90    crate::utils::check_git_repository_at(&repo_root)?;
91    crate::utils::check_working_directory_clean_at(&repo_root)?;
92
93    let amendment_file =
94        AmendmentFile::from_yaml_str(amendments_yaml).context("Failed to parse amendments YAML")?;
95    let amendment_count = amendment_file.amendments.len();
96
97    if amendment_count == 0 {
98        return Ok(AmendOutcome {
99            applied: false,
100            amendment_count: 0,
101        });
102    }
103
104    AmendmentHandler::new(&repo_root)
105        .context("Failed to initialize amendment handler")?
106        .with_allow_pushed(allow_pushed)
107        .apply_amendment_file(&amendment_file)
108        .context("Failed to apply amendments")?;
109
110    Ok(AmendOutcome {
111        applied: true,
112        amendment_count,
113    })
114}
115
116#[cfg(test)]
117#[allow(clippy::unwrap_used, clippy::expect_used)]
118mod run_amend_tests {
119    use super::*;
120    use std::process::Command;
121
122    /// Runs `git` in `dir` with a deterministic identity, asserting success.
123    fn git_in(dir: &Path, args: &[&str]) {
124        let output = Command::new("git")
125            .current_dir(dir)
126            .args([
127                "-c",
128                "user.email=test@example.com",
129                "-c",
130                "user.name=Test",
131                "-c",
132                "commit.gpgsign=false",
133            ])
134            .args(args)
135            .output()
136            .unwrap();
137        let stderr = String::from_utf8_lossy(&output.stderr);
138        assert!(output.status.success(), "git {args:?} failed: {stderr}");
139    }
140
141    /// A repo on `main` with one local (unpushed) commit — no remote, so nothing
142    /// is in a remote main branch and amending needs no `--allow-pushed`.
143    ///
144    /// Identity and no-signing are set as **persistent repo-local config** (not
145    /// just inline `-c` on the seed commit) because `AmendmentHandler`'s own
146    /// `git commit --amend` runs without those flags, so it must find an identity
147    /// on the repo — CI runners have no global `user.email`/`user.name`.
148    fn repo_with_local_commit() -> tempfile::TempDir {
149        let tmp_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
150        std::fs::create_dir_all(&tmp_root).unwrap();
151        let work = tempfile::tempdir_in(&tmp_root).unwrap();
152        git_in(work.path(), &["init", "-b", "main"]);
153        git_in(work.path(), &["config", "user.email", "test@example.com"]);
154        git_in(work.path(), &["config", "user.name", "Test"]);
155        git_in(work.path(), &["config", "commit.gpgsign", "false"]);
156        std::fs::write(work.path().join("file.txt"), "content").unwrap();
157        git_in(work.path(), &["add", "."]);
158        git_in(work.path(), &["commit", "-m", "original message"]);
159        work
160    }
161
162    fn head_sha(dir: &Path) -> String {
163        let out = Command::new("git")
164            .current_dir(dir)
165            .args(["rev-parse", "HEAD"])
166            .output()
167            .unwrap();
168        String::from_utf8_lossy(&out.stdout).trim().to_string()
169    }
170
171    fn head_message(dir: &Path) -> String {
172        let out = Command::new("git")
173            .current_dir(dir)
174            .args(["log", "-1", "--format=%B"])
175            .output()
176            .unwrap();
177        String::from_utf8_lossy(&out.stdout).trim().to_string()
178    }
179
180    #[test]
181    fn run_amend_applies_inline_yaml() {
182        let work = repo_with_local_commit();
183        let sha = head_sha(work.path());
184        let yaml = format!(
185            "amendments:\n  - commit: {sha}\n    message: \"feat: rewritten inline\"\n    summary: \"\"\n"
186        );
187
188        let outcome = run_amend(&yaml, false, Some(work.path())).unwrap();
189        assert!(outcome.applied);
190        assert_eq!(outcome.amendment_count, 1);
191        assert_eq!(head_message(work.path()), "feat: rewritten inline");
192    }
193
194    #[test]
195    fn run_amend_empty_list_is_noop() {
196        let work = repo_with_local_commit();
197        let before = head_sha(work.path());
198
199        let outcome = run_amend("amendments: []\n", false, Some(work.path())).unwrap();
200        assert!(!outcome.applied);
201        assert_eq!(outcome.amendment_count, 0);
202        // The commit is untouched.
203        assert_eq!(head_sha(work.path()), before);
204    }
205
206    #[test]
207    fn run_amend_rejects_invalid_yaml() {
208        let work = repo_with_local_commit();
209        let err = run_amend("not: [valid", false, Some(work.path())).unwrap_err();
210        assert!(format!("{err:#}").contains("amendments"));
211    }
212}