1#[macro_use]
2extern crate slog;
3use anyhow::{anyhow, Result};
4
5mod commute;
6mod config;
7mod owned;
8mod stack;
9
10use std::io::Write;
11
12pub struct Config<'a> {
13 pub dry_run: bool,
14 pub force: bool,
15 pub base: Option<&'a str>,
16 pub and_rebase: bool,
17 pub whole_file: bool,
18 pub one_fixup_per_commit: bool,
19 pub logger: &'a slog::Logger,
20}
21
22pub fn run(config: &mut Config) -> Result<()> {
23 let repo = git2::Repository::open_from_env()?;
24 debug!(config.logger, "repository found"; "path" => repo.path().to_str());
25
26 config.one_fixup_per_commit |= config::one_fixup_per_commit(&repo);
37
38 run_with_repo(config, &repo)
39}
40
41fn run_with_repo(config: &Config, repo: &git2::Repository) -> Result<()> {
42 let stack = stack::working_stack(repo, config.base, config.force, config.logger)?;
43 if stack.is_empty() {
44 crit!(config.logger, "No commits available to fix up, exiting");
45 return Ok(());
46 }
47
48 let autostage_enabled = config::auto_stage_if_nothing_staged(repo);
49 let index_was_empty = nothing_left_in_index(repo)?;
50 let mut we_added_everything_to_index = false;
51 if autostage_enabled && index_was_empty {
52 we_added_everything_to_index = true;
53
54 let pathspec = ["."];
57 let mut index = repo.index()?;
58 index.add_all(pathspec.iter(), git2::IndexAddOption::DEFAULT, None)?;
59 index.write()?;
60 }
61
62 let mut diff_options = Some({
63 let mut ret = git2::DiffOptions::new();
64 ret.context_lines(0)
65 .id_abbrev(40)
66 .ignore_filemode(true)
67 .ignore_submodules(true);
68 ret
69 });
70
71 let (stack, summary_counts): (Vec<_>, _) = {
72 let mut diffs = Vec::with_capacity(stack.len());
73 for commit in &stack {
74 let diff = owned::Diff::new(
75 &repo.diff_tree_to_tree(
76 if commit.parents().len() == 0 {
77 None
78 } else {
79 Some(commit.parent(0)?.tree()?)
80 }
81 .as_ref(),
82 Some(&commit.tree()?),
83 diff_options.as_mut(),
84 )?,
85 )?;
86 trace!(config.logger, "parsed commit diff";
87 "commit" => commit.id().to_string(),
88 "diff" => format!("{:?}", diff),
89 );
90 diffs.push(diff);
91 }
92
93 let summary_counts = stack::summary_counts(&stack);
94 (stack.into_iter().zip(diffs).collect(), summary_counts)
95 };
96
97 let mut head_tree = repo.head()?.peel_to_tree()?;
98 let index = owned::Diff::new(&repo.diff_tree_to_index(
99 Some(&head_tree),
100 None,
101 diff_options.as_mut(),
102 )?)?;
103 trace!(config.logger, "parsed index";
104 "index" => format!("{:?}", index),
105 );
106
107 let signature = repo
108 .signature()
109 .or_else(|_| git2::Signature::now("nobody", "nobody@example.com"))?;
110 let mut head_commit = repo.head()?.peel_to_commit()?;
111
112 let mut hunks_with_commit = vec![];
113
114 let mut patches_considered = 0usize;
115 'patch: for index_patch in index.iter() {
116 let old_path = index_patch.new_path.as_slice();
117 if index_patch.status != git2::Delta::Modified {
118 debug!(config.logger, "skipped non-modified hunk";
119 "path" => String::from_utf8_lossy(old_path).into_owned(),
120 "status" => format!("{:?}", index_patch.status),
121 );
122 continue 'patch;
123 }
124
125 patches_considered += 1;
126
127 let mut preceding_hunks_offset = 0isize;
128 let mut applied_hunks_offset = 0isize;
129 'hunk: for index_hunk in &index_patch.hunks {
130 debug!(config.logger, "next hunk";
131 "header" => index_hunk.header(),
132 "path" => String::from_utf8_lossy(old_path).into_owned(),
133 );
134
135 let isolated_hunk = index_hunk
141 .clone()
142 .shift_added_block(-preceding_hunks_offset);
143
144 let hunk_to_apply = isolated_hunk
148 .clone()
149 .shift_both_blocks(applied_hunks_offset);
150
151 let hunk_offset = index_hunk.changed_offset();
153
154 debug!(config.logger, "";
176 "to apply" => hunk_to_apply.header(),
177 "to commute" => isolated_hunk.header(),
178 "preceding hunks" => format!("{}/{}", applied_hunks_offset, preceding_hunks_offset),
179 );
180
181 preceding_hunks_offset += hunk_offset;
182
183 let mut dest_commit = None;
185 let mut commuted_old_path = old_path;
186 let mut commuted_index_hunk = isolated_hunk;
187
188 'commit: for (commit, diff) in &stack {
189 let c_logger = config.logger.new(o!(
190 "commit" => commit.id().to_string(),
191 ));
192 let next_patch = match diff.by_new(commuted_old_path) {
193 Some(patch) => patch,
194 None => {
198 debug!(c_logger, "skipped commit with no path");
199 continue 'commit;
200 }
201 };
202
203 if config.whole_file {
208 debug!(
209 c_logger,
210 "Commit touches the hunk file and match whole file is enabled"
211 );
212 dest_commit = Some(commit);
213 break 'commit;
214 }
215
216 if next_patch.status == git2::Delta::Added {
217 debug!(c_logger, "found noncommutative commit by add");
218 dest_commit = Some(commit);
219 break 'commit;
220 }
221 if commuted_old_path != next_patch.old_path.as_slice() {
222 debug!(c_logger, "changed commute path";
223 "path" => String::from_utf8_lossy(&next_patch.old_path).into_owned(),
224 );
225 commuted_old_path = next_patch.old_path.as_slice();
226 }
227 commuted_index_hunk = match commute::commute_diff_before(
228 &commuted_index_hunk,
229 &next_patch.hunks,
230 ) {
231 Some(hunk) => {
232 debug!(c_logger, "commuted hunk with commit";
233 "offset" => (hunk.added.start as i64) - (commuted_index_hunk.added.start as i64),
234 );
235 hunk
236 }
237 None => {
240 debug!(c_logger, "found noncommutative commit by conflict");
241 dest_commit = Some(commit);
242 break 'commit;
243 }
244 };
245 }
246 let dest_commit = match dest_commit {
247 Some(commit) => commit,
248 None => {
251 warn!(
252 config.logger,
253 "Could not find a commit to fix up, use \
254 --base to increase the search range."
255 );
256 continue 'hunk;
257 }
258 };
259
260 let hunk_with_commit = HunkWithCommit {
261 hunk_to_apply,
262 dest_commit,
263 index_patch,
264 };
265 hunks_with_commit.push(hunk_with_commit);
266
267 applied_hunks_offset += hunk_offset;
268 }
269 }
270
271 let target_always_sha: bool = config::fixup_target_always_sha(repo);
272
273 hunks_with_commit.sort_by_key(|h| h.dest_commit.id());
274 for (current, next) in hunks_with_commit
282 .iter()
283 .zip(hunks_with_commit.iter().skip(1).map(Some).chain([None]))
284 {
285 let new_head_tree = apply_hunk_to_tree(
286 repo,
287 &head_tree,
288 ¤t.hunk_to_apply,
289 ¤t.index_patch.old_path,
290 )?;
291
292 let commit_fixup = next.map_or(true, |next| {
294 !config.one_fixup_per_commit || next.dest_commit.id() != current.dest_commit.id()
296 });
297 if commit_fixup {
298 let dest_commit_id = current.dest_commit.id().to_string();
303 let dest_commit_locator = match target_always_sha {
304 true => &dest_commit_id,
305 false => current
306 .dest_commit
307 .summary()
308 .filter(|&msg| summary_counts[msg] == 1)
309 .unwrap_or(&dest_commit_id),
310 };
311 let diff = repo
312 .diff_tree_to_tree(Some(&head_commit.tree()?), Some(&new_head_tree), None)?
313 .stats()?;
314 if !config.dry_run {
315 head_tree = new_head_tree;
316 head_commit = repo.find_commit(repo.commit(
317 Some("HEAD"),
318 &signature,
319 &signature,
320 &format!("fixup! {}\n", dest_commit_locator),
321 &head_tree,
322 &[&head_commit],
323 )?)?;
324 info!(config.logger, "committed";
325 "commit" => head_commit.id().to_string(),
326 "header" => format!("+{},-{}", diff.insertions(), diff.deletions()),
327 );
328 } else {
329 info!(config.logger, "would have committed";
330 "fixup" => dest_commit_locator,
331 "header" => format!("+{},-{}", diff.insertions(), diff.deletions()),
332 );
333 }
334 } else {
335 head_tree = new_head_tree;
337 }
338 }
339
340 if autostage_enabled && we_added_everything_to_index {
341 let mut index = repo.index()?;
345 index.read_tree(&head_tree)?;
346 index.write()?;
347 }
348
349 if patches_considered == 0 {
350 if index_was_empty && !we_added_everything_to_index {
351 warn!(
352 config.logger,
353 "No changes staged, try adding something \
354 to the index or set {} = true",
355 config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME
356 );
357 } else {
358 warn!(
359 config.logger,
360 "Could not find a commit to fix up, use \
361 --base to increase the search range."
362 )
363 }
364 } else if config.and_rebase {
365 use std::process::Command;
366 let last_commit_in_stack = &stack.last().unwrap().0;
368 let number_of_parents = last_commit_in_stack.parents().len();
370 assert!(number_of_parents <= 1);
371
372 let mut command = Command::new("git");
373 command.args(["rebase", "--interactive", "--autosquash", "--autostash"]);
374
375 if number_of_parents == 0 {
376 command.arg("--root");
377 } else {
378 let base_commit_sha = last_commit_in_stack.parent(0)?.id().to_string();
381 command.arg(&base_commit_sha);
382 }
383
384 command.status().expect("could not run git rebase");
387 }
388
389 Ok(())
390}
391
392struct HunkWithCommit<'c, 'r, 'p> {
393 hunk_to_apply: owned::Hunk,
394 dest_commit: &'c git2::Commit<'r>,
395 index_patch: &'p owned::Patch,
396}
397
398fn apply_hunk_to_tree<'repo>(
399 repo: &'repo git2::Repository,
400 base: &git2::Tree,
401 hunk: &owned::Hunk,
402 path: &[u8],
403) -> Result<git2::Tree<'repo>> {
404 let mut treebuilder = repo.treebuilder(Some(base))?;
405
406 if let Some(slash) = path.iter().position(|&x| x == b'/') {
408 let (first, rest) = path.split_at(slash);
409 let rest = &rest[1..];
410
411 let (subtree, submode) = {
412 let entry = treebuilder
413 .get(first)?
414 .ok_or_else(|| anyhow!("couldn't find tree entry in tree for path"))?;
415 (repo.find_tree(entry.id())?, entry.filemode())
416 };
417 let result_subtree = apply_hunk_to_tree(repo, &subtree, hunk, rest)?;
419
420 treebuilder.insert(first, result_subtree.id(), submode)?;
421 return Ok(repo.find_tree(treebuilder.write()?)?);
422 }
423
424 let (blob, mode) = {
425 let entry = treebuilder
426 .get(path)?
427 .ok_or_else(|| anyhow!("couldn't find blob entry in tree for path"))?;
428 (repo.find_blob(entry.id())?, entry.filemode())
429 };
430
431 let mut blobwriter = repo.blob_writer(None)?;
435 let old_content = blob.content();
436 let (old_start, _, _, _) = hunk.anchors();
437
438 let old_content = {
441 let (pre, post) = split_lines_after(old_content, old_start);
442 blobwriter.write_all(pre)?;
443 post
444 };
445 for line in &*hunk.added.lines {
447 blobwriter.write_all(line)?;
448 }
449 let (_, old_content) = split_lines_after(old_content, hunk.removed.lines.len());
452 blobwriter.write_all(old_content)?;
454
455 treebuilder.insert(path, blobwriter.commit()?, mode)?;
456 Ok(repo.find_tree(treebuilder.write()?)?)
457}
458
459fn split_lines_after(content: &[u8], n: usize) -> (&[u8], &[u8]) {
461 let split_index = if n > 0 {
462 memchr::Memchr::new(b'\n', content)
463 .fuse() .nth(n - 1) .map(|x| x + 1)
466 .unwrap_or_else(|| content.len())
467 } else {
468 0
469 };
470 content.split_at(split_index)
471}
472
473fn nothing_left_in_index(repo: &git2::Repository) -> Result<bool> {
474 let stats = index_stats(repo)?;
475 let nothing = stats.files_changed() == 0 && stats.insertions() == 0 && stats.deletions() == 0;
476 Ok(nothing)
477}
478
479fn index_stats(repo: &git2::Repository) -> Result<git2::DiffStats> {
480 let head = repo.head()?.peel_to_tree()?;
481 let diff = repo.diff_tree_to_index(Some(&head), Some(&repo.index()?), None)?;
482 let stats = diff.stats()?;
483 Ok(stats)
484}
485
486#[cfg(test)]
487mod tests {
488 use std::path::{Path, PathBuf};
489
490 use super::*;
491
492 struct Context {
493 repo: git2::Repository,
494 dir: tempfile::TempDir,
495 }
496
497 impl Context {
498 fn join(&self, p: &Path) -> PathBuf {
499 self.dir.path().join(p)
500 }
501 }
502
503 fn prepare_repo() -> (Context, PathBuf) {
505 let dir = tempfile::tempdir().unwrap();
506 let repo = git2::Repository::init(dir.path()).unwrap();
507
508 let path = PathBuf::from("test-file.txt");
509 std::fs::write(
510 dir.path().join(&path),
511 br#"
512line
513line
514
515more
516lines
517"#,
518 )
519 .unwrap();
520
521 {
523 let tree = add(&repo, &path);
524 let signature = repo
525 .signature()
526 .or_else(|_| git2::Signature::now("nobody", "nobody@example.com"))
527 .unwrap();
528 repo.commit(
529 Some("HEAD"),
530 &signature,
531 &signature,
532 "Initial commit.",
533 &tree,
534 &[],
535 )
536 .unwrap();
537 }
538
539 (Context { repo, dir }, path)
540 }
541
542 fn add<'r>(repo: &'r git2::Repository, path: &Path) -> git2::Tree<'r> {
544 let mut index = repo.index().unwrap();
545 index.add_path(&path).unwrap();
546 index.write().unwrap();
547
548 let tree_id = index.write_tree_to(&repo).unwrap();
549 repo.find_tree(tree_id).unwrap()
550 }
551
552 fn prepare_and_stage() -> Context {
554 let (ctx, file_path) = prepare_repo();
555
556 let path = ctx.join(&file_path);
558 let contents = std::fs::read_to_string(&path).unwrap();
559 let modifications = format!("new_line1\n{contents}\nnew_line2");
560 std::fs::write(&path, &modifications).unwrap();
561
562 add(&ctx.repo, &file_path);
564
565 ctx
566 }
567
568 #[test]
569 fn multiple_fixups_per_commit() {
570 let ctx = prepare_and_stage();
571
572 let drain = slog::Discard;
574 let logger = slog::Logger::root(drain, o!());
575 let config = Config {
576 dry_run: false,
577 force: false,
578 base: None,
579 and_rebase: false,
580 whole_file: false,
581 one_fixup_per_commit: false,
582 logger: &logger,
583 };
584 run_with_repo(&config, &ctx.repo).unwrap();
585
586 let mut revwalk = ctx.repo.revwalk().unwrap();
587 revwalk.push_head().unwrap();
588 assert_eq!(revwalk.count(), 3);
589
590 assert!(nothing_left_in_index(&ctx.repo).unwrap());
591 }
592
593 #[test]
594 fn one_fixup_per_commit() {
595 let ctx = prepare_and_stage();
596
597 let drain = slog::Discard;
599 let logger = slog::Logger::root(drain, o!());
600 let config = Config {
601 dry_run: false,
602 force: false,
603 base: None,
604 and_rebase: false,
605 whole_file: false,
606 one_fixup_per_commit: true,
607 logger: &logger,
608 };
609 run_with_repo(&config, &ctx.repo).unwrap();
610
611 let mut revwalk = ctx.repo.revwalk().unwrap();
612 revwalk.push_head().unwrap();
613 assert_eq!(revwalk.count(), 2);
614
615 assert!(nothing_left_in_index(&ctx.repo).unwrap());
616 }
617
618 fn autostage_common(ctx: &Context, file_path: &PathBuf) -> (PathBuf, PathBuf) {
619 let path = ctx.join(&file_path);
621 let contents = std::fs::read_to_string(&path).unwrap();
622 let modifications = format!("{contents}\nnew_line2");
623 std::fs::write(&path, &modifications).unwrap();
624
625 let fp2 = PathBuf::from("unrel.txt");
627 std::fs::write(ctx.join(&fp2), "foo").unwrap();
628
629 (path, fp2)
630 }
631
632 #[test]
633 fn autostage_if_index_was_empty() {
634 let (ctx, file_path) = prepare_repo();
635
636 ctx.repo
638 .config()
639 .unwrap()
640 .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, true)
641 .unwrap();
642
643 autostage_common(&ctx, &file_path);
644
645 let drain = slog::Discard;
647 let logger = slog::Logger::root(drain, o!());
648 let config = Config {
649 dry_run: false,
650 force: false,
651 base: None,
652 and_rebase: false,
653 whole_file: false,
654 one_fixup_per_commit: false,
655 logger: &logger,
656 };
657 run_with_repo(&config, &ctx.repo).unwrap();
658
659 let mut revwalk = ctx.repo.revwalk().unwrap();
660 revwalk.push_head().unwrap();
661 assert_eq!(revwalk.count(), 2);
662
663 assert!(nothing_left_in_index(&ctx.repo).unwrap());
664 }
665
666 #[test]
667 fn do_not_autostage_if_index_was_not_empty() {
668 let (ctx, file_path) = prepare_repo();
669
670 ctx.repo
672 .config()
673 .unwrap()
674 .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, true)
675 .unwrap();
676
677 let (_, fp2) = autostage_common(&ctx, &file_path);
678 add(&ctx.repo, &fp2);
680
681 let drain = slog::Discard;
683 let logger = slog::Logger::root(drain, o!());
684 let config = Config {
685 dry_run: false,
686 force: false,
687 base: None,
688 and_rebase: false,
689 whole_file: false,
690 one_fixup_per_commit: false,
691 logger: &logger,
692 };
693 run_with_repo(&config, &ctx.repo).unwrap();
694
695 let mut revwalk = ctx.repo.revwalk().unwrap();
696 revwalk.push_head().unwrap();
697 assert_eq!(revwalk.count(), 1);
698
699 assert_eq!(index_stats(&ctx.repo).unwrap().files_changed(), 1);
700 }
701
702 #[test]
703 fn do_not_autostage_if_not_enabled_by_config_var() {
704 let (ctx, file_path) = prepare_repo();
705
706 ctx.repo
708 .config()
709 .unwrap()
710 .set_bool(config::AUTO_STAGE_IF_NOTHING_STAGED_CONFIG_NAME, false)
711 .unwrap();
712
713 autostage_common(&ctx, &file_path);
714
715 let drain = slog::Discard;
717 let logger = slog::Logger::root(drain, o!());
718 let config = Config {
719 dry_run: false,
720 force: false,
721 base: None,
722 and_rebase: false,
723 whole_file: false,
724 one_fixup_per_commit: false,
725 logger: &logger,
726 };
727 run_with_repo(&config, &ctx.repo).unwrap();
728
729 let mut revwalk = ctx.repo.revwalk().unwrap();
730 revwalk.push_head().unwrap();
731 assert_eq!(revwalk.count(), 1);
732
733 assert!(nothing_left_in_index(&ctx.repo).unwrap());
734 }
735
736 #[test]
737 fn fixup_message_always_commit_sha_if_configured() {
738 let ctx = prepare_and_stage();
739
740 ctx.repo
741 .config()
742 .unwrap()
743 .set_bool(config::FIXUP_TARGET_ALWAYS_SHA_CONFIG_NAME, true)
744 .unwrap();
745
746 let drain = slog::Discard;
748 let logger = slog::Logger::root(drain, o!());
749 let config = Config {
750 dry_run: false,
751 force: false,
752 base: None,
753 and_rebase: false,
754 whole_file: false,
755 one_fixup_per_commit: true,
756 logger: &logger,
757 };
758 run_with_repo(&config, &ctx.repo).unwrap();
759 assert!(nothing_left_in_index(&ctx.repo).unwrap());
760
761 let mut revwalk = ctx.repo.revwalk().unwrap();
762 revwalk.push_head().unwrap();
763
764 let oids: Vec<git2::Oid> = revwalk.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
765 assert_eq!(oids.len(), 2);
766
767 let commit = ctx.repo.find_commit(oids[0]).unwrap();
768 let actual_msg = commit.summary().unwrap();
769 let expected_msg = format!("fixup! {}", oids[1]);
770 assert_eq!(actual_msg, expected_msg);
771 }
772}