1use 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
14pub struct AmendmentHandler {
16 repo: Repository,
17 repo_root: PathBuf,
20 allow_pushed: bool,
22}
23
24impl AmendmentHandler {
25 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 #[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 fn git_command(&self) -> Command {
49 let mut cmd = Command::new("git");
50 cmd.current_dir(&self.repo_root);
51 cmd
52 }
53
54 pub fn apply_amendments(&self, yaml_file: &str) -> Result<()> {
56 let amendment_file = AmendmentFile::load_from_file(yaml_file)?;
58 self.apply_amendment_file(&amendment_file)
59 }
60
61 pub fn apply_amendment_file(&self, amendment_file: &AmendmentFile) -> Result<()> {
68 self.perform_safety_checks(amendment_file)?;
70
71 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 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 fn perform_safety_checks(&self, amendment_file: &AmendmentFile) -> Result<()> {
100 crate::utils::preflight::check_working_directory_clean_at(&self.repo_root)
102 .context("Cannot amend commits with uncommitted changes")?;
103
104 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 fn validate_commit_amendable(
115 &self,
116 commit_hash: &str,
117 main_tips: &[crate::git::main_branches::MainBranchTip],
118 ) -> Result<()> {
119 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 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 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 valid_amendments.sort_by_key(|(commit, _)| commit_depths.get(commit).copied().unwrap_or(0));
167
168 valid_amendments.reverse();
170
171 Ok(valid_amendments)
172 }
173
174 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 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 fn amend_head_commit(&self, new_message: &str) -> Result<()> {
199 let head_commit = self.repo.head()?.peel_to_commit()?;
200
201 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 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 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 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 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 println!("Amending HEAD commit: {}", &commit_hash[..SHORT_HASH_LEN]);
245 self.amend_head_commit(&new_message)?;
246 } else {
247 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 fn amend_single_commit_via_rebase(&self, commit_hash: &str, new_message: &str) -> Result<()> {
262 let base_commit = format!("{commit_hash}^");
264
265 let temp_dir = tempfile::tempdir()?;
267 let sequence_file = temp_dir.path().join("rebase-sequence");
268
269 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 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 sequence_content.push_str(&format!("edit {commit} {subject}\n"));
302 } else {
303 sequence_content.push_str(&format!("pick {commit} {subject}\n"));
305 }
306 }
307
308 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 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") .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 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 let repo_state = self.repo.state();
340 if repo_state == git2::RepositoryState::RebaseInteractive {
341 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(¤t_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 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 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 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 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 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 ¤t_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 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 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 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 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}