1use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34
35use anyhow::{bail, Result};
36use git2::{Oid, Repository, RepositoryState, StatusOptions};
37use serde::Serialize;
38
39use crate::git::remote::RemoteInfo;
40use crate::git::resolve_git_binary;
41use crate::git::worktree_batch::{
42 head_branch, is_false, main_root, resolve_selection, run_git_in, trimmed_stderr,
43};
44
45pub use crate::git::worktree_batch::Selection;
46
47#[derive(Debug, Clone, Default)]
49pub struct RebaseOptions {
50 pub onto: Option<String>,
54 pub autostash: bool,
57 pub dry_run: bool,
59 pub keep_conflicts: bool,
71 pub git_bin: Option<PathBuf>,
76}
77
78impl RebaseOptions {
79 fn git_bin(&self) -> PathBuf {
82 self.git_bin.clone().unwrap_or_else(resolve_git_binary)
83 }
84}
85
86#[derive(Debug, Clone, Serialize)]
88pub struct Plan {
89 pub fetches: Vec<FetchOutcome>,
92 pub worktrees: Vec<WorktreeOutcome>,
94}
95
96impl Plan {
97 #[must_use]
100 pub fn has_pending_rebases(&self) -> bool {
101 self.worktrees
102 .iter()
103 .any(|w| matches!(w.result, RebaseResult::WouldRebase { .. }))
104 }
105}
106
107#[derive(Debug, Clone, Serialize)]
109pub struct FetchOutcome {
110 pub repo_root: PathBuf,
112 pub onto: String,
114 pub fetched: bool,
116 pub ok: bool,
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub detail: Option<String>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
125pub struct WorktreeOutcome {
126 pub path: PathBuf,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub branch: Option<String>,
131 pub onto: String,
133 #[serde(flatten)]
135 pub result: RebaseResult,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
144#[serde(tag = "status", rename_all = "kebab-case")]
145pub enum RebaseResult {
146 Rebased {
148 behind: usize,
150 },
151 WouldRebase {
153 behind: usize,
155 },
156 UpToDate,
158 Skipped {
160 reason: SkipReason,
162 },
163 Conflict {
167 detail: String,
169 #[serde(skip_serializing_if = "is_false")]
174 left_in_place: bool,
175 },
176 FetchFailed {
178 detail: String,
180 },
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
185#[serde(rename_all = "kebab-case")]
186pub enum SkipReason {
187 DetachedHead,
189 Dirty,
191 OperationInProgress,
193 NotAWorktree,
195 NoOntoRef,
197}
198
199pub fn plan(selection: &Selection, opts: &RebaseOptions) -> Result<Plan> {
210 let paths = resolve_selection(selection)?;
211 let git = opts.git_bin();
214
215 let inspected: Vec<Inspected> = paths.iter().map(|p| Inspected::read(p)).collect();
217
218 let onto_by_repo = resolve_onto_by_repo(&inspected, opts.onto.as_deref());
220
221 let (fetches, fetch_ok) = fetch_all(&git, &onto_by_repo);
223
224 let worktrees = inspected
226 .iter()
227 .map(|i| i.classify(&onto_by_repo, &fetch_ok, opts.autostash))
228 .collect();
229
230 Ok(Plan { fetches, worktrees })
231}
232
233#[must_use]
241pub fn execute(plan: Plan, opts: &RebaseOptions) -> Vec<WorktreeOutcome> {
242 let git = opts.git_bin();
243 plan.worktrees
244 .into_iter()
245 .map(|mut outcome| {
246 if let RebaseResult::WouldRebase { behind } = outcome.result {
247 outcome.result = match rebase_worktree(&git, &outcome.path, &outcome.onto, opts) {
248 Ok(()) => RebaseResult::Rebased { behind },
249 Err(detail) => RebaseResult::Conflict {
250 detail,
251 left_in_place: opts.keep_conflicts,
252 },
253 };
254 }
255 outcome
256 })
257 .collect()
258}
259
260enum Inspected {
264 Ok(Inspection),
266 Unresolvable {
268 path: PathBuf,
270 },
271}
272
273struct Inspection {
277 path: PathBuf,
278 repo_root: PathBuf,
279 branch: Option<String>,
280 head_oid: Option<Oid>,
281 state_clean: bool,
282 dirty: bool,
283}
284
285impl Inspected {
286 fn read(path: &Path) -> Self {
290 let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
291 let Ok(repo) = Repository::discover(&canon) else {
292 return Self::Unresolvable { path: canon };
293 };
294 let repo_root = main_root(&repo);
295 let (branch, head_oid) = head_branch(&repo);
296 let state_clean = repo.state() == RepositoryState::Clean;
297 let dirty = is_dirty(&repo);
298 Self::Ok(Inspection {
299 path: canon,
300 repo_root,
301 branch,
302 head_oid,
303 state_clean,
304 dirty,
305 })
306 }
307
308 fn repo_root(&self) -> Option<&Path> {
310 match self {
311 Self::Ok(i) => Some(&i.repo_root),
312 Self::Unresolvable { .. } => None,
313 }
314 }
315
316 fn classify(
318 &self,
319 onto_by_repo: &BTreeMap<PathBuf, OntoSpec>,
320 fetch_ok: &BTreeMap<PathBuf, bool>,
321 autostash: bool,
322 ) -> WorktreeOutcome {
323 let i = match self {
324 Self::Unresolvable { path } => {
325 return WorktreeOutcome::skipped(
326 path.clone(),
327 None,
328 String::new(),
329 SkipReason::NotAWorktree,
330 );
331 }
332 Self::Ok(i) => i,
333 };
334
335 let onto = onto_by_repo.get(&i.repo_root);
336 let onto_display = onto.map_or_else(String::new, |s| s.display.clone());
337 let branch = i.branch.clone();
338 let skip = |reason| {
339 WorktreeOutcome::skipped(i.path.clone(), branch.clone(), onto_display.clone(), reason)
340 };
341
342 let (Some(head), Some(_)) = (i.head_oid, i.branch.as_ref()) else {
346 return skip(SkipReason::DetachedHead);
347 };
348 if !i.state_clean {
349 return skip(SkipReason::OperationInProgress);
350 }
351 if i.dirty && !autostash {
352 return skip(SkipReason::Dirty);
353 }
354 let Some(onto) = onto else {
355 return skip(SkipReason::NoOntoRef);
356 };
357
358 if fetch_ok.get(&i.repo_root) == Some(&false) {
360 let detail = "the repository's fetch failed".to_string();
361 return WorktreeOutcome {
362 path: i.path.clone(),
363 branch,
364 onto: onto_display,
365 result: RebaseResult::FetchFailed { detail },
366 };
367 }
368
369 match behind_count(&i.repo_root, head, &onto.display) {
371 None => skip(SkipReason::NoOntoRef),
372 Some(0) => WorktreeOutcome {
373 path: i.path.clone(),
374 branch,
375 onto: onto_display,
376 result: RebaseResult::UpToDate,
377 },
378 Some(behind) => WorktreeOutcome {
379 path: i.path.clone(),
380 branch,
381 onto: onto_display,
382 result: RebaseResult::WouldRebase { behind },
383 },
384 }
385 }
386}
387
388impl WorktreeOutcome {
389 fn skipped(path: PathBuf, branch: Option<String>, onto: String, reason: SkipReason) -> Self {
391 Self {
392 path,
393 branch,
394 onto,
395 result: RebaseResult::Skipped { reason },
396 }
397 }
398}
399
400fn is_dirty(repo: &Repository) -> bool {
404 let mut opts = StatusOptions::new();
405 opts.include_untracked(false)
406 .include_ignored(false)
407 .exclude_submodules(true);
408 repo.statuses(Some(&mut opts))
409 .is_ok_and(|statuses| !statuses.is_empty())
410}
411
412fn behind_count(repo_root: &Path, head: Oid, onto: &str) -> Option<usize> {
415 let repo = Repository::open(repo_root).ok()?;
416 let onto_oid = repo.revparse_single(onto).ok()?.peel_to_commit().ok()?.id();
417 let (_ahead, behind) = repo.graph_ahead_behind(head, onto_oid).ok()?;
418 Some(behind)
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
425struct OntoSpec {
426 display: String,
428 fetch: Option<(String, String)>,
431}
432
433fn resolve_onto_by_repo(
437 inspected: &[Inspected],
438 override_ref: Option<&str>,
439) -> BTreeMap<PathBuf, OntoSpec> {
440 let mut map: BTreeMap<PathBuf, OntoSpec> = BTreeMap::new();
441 for root in inspected.iter().filter_map(Inspected::repo_root) {
442 if map.contains_key(root) {
443 continue;
444 }
445 if let Ok(repo) = Repository::open(root) {
446 map.insert(root.to_path_buf(), resolve_onto(&repo, override_ref));
447 }
448 }
449 map
450}
451
452fn resolve_onto(repo: &Repository, override_ref: Option<&str>) -> OntoSpec {
455 if let Some(reference) = override_ref {
456 return onto_from_override(repo, reference);
457 }
458 let remote = "origin";
459 let branch =
460 RemoteInfo::detect_main_branch_local(repo, remote).unwrap_or_else(|| "main".to_string());
461 OntoSpec {
462 display: format!("{remote}/{branch}"),
463 fetch: Some((remote.to_string(), branch)),
464 }
465}
466
467fn onto_from_override(repo: &Repository, reference: &str) -> OntoSpec {
471 if let Some((remote, branch)) = reference.split_once('/') {
472 if repo.find_remote(remote).is_ok() {
473 return OntoSpec {
474 display: reference.to_string(),
475 fetch: Some((remote.to_string(), branch.to_string())),
476 };
477 }
478 }
479 OntoSpec {
480 display: reference.to_string(),
481 fetch: None,
482 }
483}
484
485fn fetch_all(
491 git: &Path,
492 onto_by_repo: &BTreeMap<PathBuf, OntoSpec>,
493) -> (Vec<FetchOutcome>, BTreeMap<PathBuf, bool>) {
494 let mut fetches = Vec::new();
495 let mut fetch_ok = BTreeMap::new();
496 for (root, spec) in onto_by_repo {
497 let outcome = match &spec.fetch {
498 Some((remote, branch)) => {
499 let result = fetch_once(git, root, remote, branch);
500 let ok = result.is_ok();
501 FetchOutcome {
502 repo_root: root.clone(),
503 onto: spec.display.clone(),
504 fetched: true,
505 ok,
506 detail: result.err().map(|e| e.to_string()),
507 }
508 }
509 None => FetchOutcome {
510 repo_root: root.clone(),
511 onto: spec.display.clone(),
512 fetched: false,
513 ok: true,
514 detail: None,
515 },
516 };
517 fetch_ok.insert(root.clone(), outcome.ok);
518 fetches.push(outcome);
519 }
520 (fetches, fetch_ok)
521}
522
523fn fetch_once(git: &Path, repo_root: &Path, remote: &str, branch: &str) -> Result<()> {
526 let output = run_git_in(git, repo_root, &["fetch", remote, branch])?;
527 if output.status.success() {
528 return Ok(());
529 }
530 bail!(
531 "git fetch {remote} {branch} failed: {}",
532 trimmed_stderr(&output)
533 )
534}
535
536fn rebase_worktree(
548 git: &Path,
549 path: &Path,
550 onto: &str,
551 opts: &RebaseOptions,
552) -> std::result::Result<(), String> {
553 let args = rebase_args(onto, opts.autostash);
554 let argv: Vec<&str> = args.iter().map(String::as_str).collect();
555 match run_git_in(git, path, &argv) {
556 Ok(output) if output.status.success() => Ok(()),
557 Ok(output) => {
558 let detail = trimmed_stderr(&output);
559 if !opts.keep_conflicts {
560 let _ = run_git_in(git, path, &["rebase", "--abort"]);
562 }
563 Err(detail)
564 }
565 Err(err) => Err(err.to_string()),
566 }
567}
568
569fn rebase_args(onto: &str, autostash: bool) -> Vec<String> {
572 let mut args = vec!["rebase".to_string()];
573 if autostash {
574 args.push("--autostash".to_string());
575 }
576 args.push(onto.to_string());
577 args
578}
579
580#[cfg(test)]
581#[allow(clippy::unwrap_used, clippy::expect_used)]
582mod tests {
583 use super::*;
584
585 use crate::git::worktree_batch::all_worktree_paths;
586
587 fn serial() -> std::sync::MutexGuard<'static, ()> {
590 crate::git::worktree_batch::test_serial_lock()
591 }
592
593 #[test]
596 fn rebase_args_omits_autostash_by_default() {
597 assert_eq!(
598 rebase_args("origin/main", false),
599 vec!["rebase", "origin/main"]
600 );
601 }
602
603 #[test]
604 fn rebase_args_inserts_autostash_before_the_ref() {
605 assert_eq!(
606 rebase_args("origin/main", true),
607 vec!["rebase", "--autostash", "origin/main"]
608 );
609 }
610
611 #[test]
612 fn onto_from_override_fetches_a_remote_tracking_ref() {
613 let (_dir, repo) = repo_with_origin();
614 let spec = onto_from_override(&repo, "origin/release");
615 assert_eq!(spec.display, "origin/release");
616 assert_eq!(
617 spec.fetch,
618 Some(("origin".to_string(), "release".to_string()))
619 );
620 }
621
622 #[test]
623 fn onto_from_override_keeps_a_multi_segment_branch_whole() {
624 let (_dir, repo) = repo_with_origin();
625 let spec = onto_from_override(&repo, "origin/feature/foo");
626 assert_eq!(
627 spec.fetch,
628 Some(("origin".to_string(), "feature/foo".to_string()))
629 );
630 }
631
632 #[test]
633 fn onto_from_override_does_not_fetch_a_local_ref() {
634 let (_dir, repo) = repo_with_origin();
635 assert_eq!(onto_from_override(&repo, "develop").fetch, None);
637 assert_eq!(onto_from_override(&repo, "upstream/x").fetch, None);
638 assert_eq!(onto_from_override(&repo, "HEAD~2").fetch, None);
639 }
640
641 #[test]
642 fn resolve_onto_defaults_to_origin_main() {
643 let (_dir, repo) = repo_with_origin();
644 let spec = resolve_onto(&repo, None);
645 assert_eq!(spec.display, "origin/main");
646 assert_eq!(spec.fetch, Some(("origin".to_string(), "main".to_string())));
647 }
648
649 #[test]
652 fn one_repo_with_many_worktrees_fetches_exactly_once() {
653 let _guard = serial();
656 let scenario = Scenario::new();
657 scenario.add_worktree("feature-a");
658 scenario.add_worktree("feature-b");
659 scenario.add_worktree("feature-c");
660
661 let plan = plan(
662 &Selection::All {
663 base: scenario.local.clone(),
664 },
665 &RebaseOptions::default(),
666 )
667 .unwrap();
668
669 assert_eq!(
670 plan.fetches.len(),
671 1,
672 "fetch must run once per repo, not per worktree"
673 );
674 assert_eq!(
675 plan.worktrees.len(),
676 4,
677 "--all now includes the main working tree alongside its three linked \
678 worktrees (#1438)"
679 );
680 let main_canon = std::fs::canonicalize(&scenario.local).unwrap();
681 assert!(plan.worktrees.iter().any(|w| w.path == main_canon));
682 assert!(plan.fetches[0].ok);
683 }
684
685 #[test]
686 fn all_worktree_paths_includes_the_main_working_tree() {
687 let _guard = serial();
688 let scenario = Scenario::new();
689 scenario.add_worktree("feature-a");
690 let paths = all_worktree_paths(&scenario.local).unwrap();
691 assert_eq!(paths.len(), 2);
692 assert!(paths.contains(&std::fs::canonicalize(&scenario.local).unwrap()));
693 }
694
695 #[test]
696 fn resolve_onto_by_repo_collapses_worktrees_of_one_repo() {
697 let _guard = serial();
698 let scenario = Scenario::new();
699 scenario.add_worktree("feature-a");
700 scenario.add_worktree("feature-b");
701 let paths = all_worktree_paths(&scenario.local).unwrap();
702 let inspected: Vec<Inspected> = paths.iter().map(|p| Inspected::read(p)).collect();
703 let map = resolve_onto_by_repo(&inspected, None);
704 assert_eq!(
705 map.len(),
706 1,
707 "the main tree and two linked worktrees of one repo resolve to one onto \
708 entry"
709 );
710 }
711
712 #[test]
715 fn behind_worktree_is_rebased_onto_the_fetched_ref() {
716 let _guard = serial();
717 let scenario = Scenario::new();
718 let wt = scenario.add_worktree("feature");
719 scenario.advance_origin_main("second\n");
722
723 let plan = plan(
724 &Selection::Paths(vec![wt.clone()]),
725 &RebaseOptions::default(),
726 )
727 .unwrap();
728 assert_eq!(plan.worktrees.len(), 1);
729 assert_eq!(
730 plan.worktrees[0].result,
731 RebaseResult::WouldRebase { behind: 1 },
732 "the feature worktree is one commit behind the fetched origin/main"
733 );
734
735 let outcomes = execute(plan, &RebaseOptions::default());
736 assert_eq!(outcomes[0].result, RebaseResult::Rebased { behind: 1 });
737 assert!(head_contains(&wt, &scenario.origin_main_oid()));
739 }
740
741 #[test]
742 fn up_to_date_worktree_is_not_rebased() {
743 let _guard = serial();
744 let scenario = Scenario::new();
745 let wt = scenario.add_worktree("feature");
746 let plan = plan(&Selection::Paths(vec![wt]), &RebaseOptions::default()).unwrap();
748 assert_eq!(plan.worktrees[0].result, RebaseResult::UpToDate);
749 assert!(!plan.has_pending_rebases());
750 }
751
752 #[test]
753 fn dirty_worktree_is_skipped_but_autostash_rebases_it() {
754 let _guard = serial();
755 let scenario = Scenario::new();
756 let wt = scenario.add_worktree("feature");
757 scenario.advance_origin_main("second\n");
758 std::fs::write(wt.join("keep.txt"), "dirty change\n").unwrap();
760
761 let skipped = plan(
762 &Selection::Paths(vec![wt.clone()]),
763 &RebaseOptions::default(),
764 )
765 .unwrap();
766 assert_eq!(
767 skipped.worktrees[0].result,
768 RebaseResult::Skipped {
769 reason: SkipReason::Dirty
770 }
771 );
772
773 let opts = RebaseOptions {
774 autostash: true,
775 ..RebaseOptions::default()
776 };
777 let planned = plan(&Selection::Paths(vec![wt.clone()]), &opts).unwrap();
778 assert_eq!(
779 planned.worktrees[0].result,
780 RebaseResult::WouldRebase { behind: 1 }
781 );
782 let outcomes = execute(planned, &opts);
783 assert_eq!(outcomes[0].result, RebaseResult::Rebased { behind: 1 });
784 assert_eq!(
786 std::fs::read_to_string(wt.join("keep.txt")).unwrap(),
787 "dirty change\n"
788 );
789 }
790
791 #[test]
792 fn main_working_tree_is_rebased_like_any_worktree() {
793 let _guard = serial();
794 let scenario = Scenario::new();
795 scenario.advance_origin_main("second\n");
797
798 let plan = plan(
799 &Selection::Paths(vec![scenario.local.clone()]),
800 &RebaseOptions::default(),
801 )
802 .unwrap();
803 assert_eq!(
804 plan.worktrees[0].result,
805 RebaseResult::WouldRebase { behind: 1 },
806 "the main working tree is a valid rebase target like any other (#1438)"
807 );
808
809 let outcomes = execute(plan, &RebaseOptions::default());
810 assert_eq!(outcomes[0].result, RebaseResult::Rebased { behind: 1 });
811 assert!(head_contains(&scenario.local, &scenario.origin_main_oid()));
812 }
813
814 #[test]
815 fn non_worktree_path_is_skipped_not_fatal() {
816 let dir = tempfile::tempdir().unwrap();
817 let plan = plan(
818 &Selection::Paths(vec![dir.path().to_path_buf()]),
819 &RebaseOptions::default(),
820 )
821 .unwrap();
822 assert_eq!(
823 plan.worktrees[0].result,
824 RebaseResult::Skipped {
825 reason: SkipReason::NotAWorktree
826 }
827 );
828 }
829
830 #[test]
831 fn conflicting_rebase_aborts_and_leaves_the_worktree_untouched() {
832 let _guard = serial();
833 let scenario = Scenario::new();
834 let wt = scenario.add_worktree("feature");
835 scenario.commit_in_worktree(&wt, "file.txt", "feature side\n", "feature edit");
837 scenario.advance_origin_main("main side\n");
838 let head_before = head_oid(&wt);
839
840 let plan = plan(
841 &Selection::Paths(vec![wt.clone()]),
842 &RebaseOptions::default(),
843 )
844 .unwrap();
845 assert!(matches!(
846 plan.worktrees[0].result,
847 RebaseResult::WouldRebase { .. }
848 ));
849 let outcomes = execute(plan, &RebaseOptions::default());
850 assert!(
851 matches!(
852 outcomes[0].result,
853 RebaseResult::Conflict {
854 left_in_place: false,
855 ..
856 }
857 ),
858 "a conflicting rebase is reported, not silently half-applied"
859 );
860 assert_eq!(head_oid(&wt), head_before);
862 let repo = Repository::open(&wt).unwrap();
863 assert_eq!(repo.state(), RepositoryState::Clean);
864 }
865
866 #[test]
867 fn keep_conflicts_leaves_the_worktree_mid_rebase() {
868 let _guard = serial();
872 let scenario = Scenario::new();
873 let wt = scenario.add_worktree("feature");
874 scenario.commit_in_worktree(&wt, "file.txt", "feature side\n", "feature edit");
875 scenario.advance_origin_main("main side\n");
876
877 let opts = RebaseOptions {
878 keep_conflicts: true,
879 ..RebaseOptions::default()
880 };
881 let plan = plan(&Selection::Paths(vec![wt.clone()]), &opts).unwrap();
882 let outcomes = execute(plan, &opts);
883 assert!(
884 matches!(
885 outcomes[0].result,
886 RebaseResult::Conflict {
887 left_in_place: true,
888 ..
889 }
890 ),
891 "the outcome records that the worktree was left mid-rebase"
892 );
893 let repo = Repository::open(&wt).unwrap();
896 assert_ne!(
897 repo.state(),
898 RepositoryState::Clean,
899 "the worktree must still be mid-rebase, not aborted back to clean"
900 );
901 let conflicted = std::fs::read_to_string(wt.join("file.txt")).unwrap();
903 assert!(
904 conflicted.contains("<<<<<<<"),
905 "expected conflict markers, got: {conflicted}"
906 );
907 }
908
909 #[test]
910 fn a_kept_conflict_does_not_stop_the_rest_of_the_batch() {
911 let _guard = serial();
914 let scenario = Scenario::new();
915 let clashing = scenario.add_worktree("clashing");
916 let clean = scenario.add_worktree("clean");
917 scenario.commit_in_worktree(&clashing, "file.txt", "feature side\n", "feature edit");
918 scenario.advance_origin_main("main side\n");
919
920 let opts = RebaseOptions {
921 keep_conflicts: true,
922 ..RebaseOptions::default()
923 };
924 let plan = plan(&Selection::Paths(vec![clashing, clean.clone()]), &opts).unwrap();
925 let outcomes = execute(plan, &opts);
926 assert!(matches!(
927 outcomes[0].result,
928 RebaseResult::Conflict {
929 left_in_place: true,
930 ..
931 }
932 ));
933 assert_eq!(
934 outcomes[1].result,
935 RebaseResult::Rebased { behind: 1 },
936 "the second worktree rebases despite the first being left conflicted"
937 );
938 assert!(head_contains(&clean, &scenario.origin_main_oid()));
939 }
940
941 #[test]
942 fn left_in_place_is_omitted_from_json_when_false() {
943 let aborted = serde_json::to_value(RebaseResult::Conflict {
946 detail: "boom".to_string(),
947 left_in_place: false,
948 })
949 .unwrap();
950 assert_eq!(aborted["status"], "conflict");
951 assert!(aborted.get("left_in_place").is_none());
952
953 let kept = serde_json::to_value(RebaseResult::Conflict {
954 detail: "boom".to_string(),
955 left_in_place: true,
956 })
957 .unwrap();
958 assert_eq!(kept["left_in_place"], true);
959 }
960
961 #[test]
962 fn git_bin_defaults_to_the_resolver_and_honours_an_override() {
963 assert_eq!(
964 RebaseOptions::default().git_bin(),
965 crate::git::resolve_git_binary(),
966 "an unset git_bin falls back to the shared resolver"
967 );
968 let opts = RebaseOptions {
969 git_bin: Some(PathBuf::from("/custom/git")),
970 ..RebaseOptions::default()
971 };
972 assert_eq!(opts.git_bin(), PathBuf::from("/custom/git"));
973 }
974
975 #[test]
976 fn dry_run_fetches_but_rebases_nothing() {
977 let _guard = serial();
978 let scenario = Scenario::new();
979 let wt = scenario.add_worktree("feature");
980 scenario.advance_origin_main("second\n");
981 let head_before = head_oid(&wt);
982
983 let opts = RebaseOptions {
984 dry_run: true,
985 ..RebaseOptions::default()
986 };
987 let plan = plan(&Selection::Paths(vec![wt.clone()]), &opts).unwrap();
988 assert_eq!(
991 plan.worktrees[0].result,
992 RebaseResult::WouldRebase { behind: 1 }
993 );
994 assert_eq!(plan.fetches.len(), 1);
995 assert!(plan.fetches[0].fetched && plan.fetches[0].ok);
996 assert_eq!(
997 head_oid(&wt),
998 head_before,
999 "dry run must not move the branch"
1000 );
1001 }
1002
1003 #[test]
1004 fn json_shape_is_kebab_tagged() {
1005 let outcome = WorktreeOutcome {
1006 path: PathBuf::from("/wt"),
1007 branch: Some("feature".to_string()),
1008 onto: "origin/main".to_string(),
1009 result: RebaseResult::Skipped {
1010 reason: SkipReason::Dirty,
1011 },
1012 };
1013 let value = serde_json::to_value(&outcome).unwrap();
1014 assert_eq!(value["status"], "skipped");
1015 assert_eq!(value["reason"], "dirty");
1016 assert_eq!(value["onto"], "origin/main");
1017 }
1018
1019 fn inspected(
1024 branch: Option<&str>,
1025 head: Option<Oid>,
1026 state_clean: bool,
1027 dirty: bool,
1028 ) -> Inspected {
1029 Inspected::Ok(Inspection {
1030 path: PathBuf::from("/wt"),
1031 repo_root: PathBuf::from("/repo"),
1032 branch: branch.map(str::to_string),
1033 head_oid: head,
1034 state_clean,
1035 dirty,
1036 })
1037 }
1038
1039 fn onto_map() -> BTreeMap<PathBuf, OntoSpec> {
1040 let mut map = BTreeMap::new();
1041 map.insert(
1042 PathBuf::from("/repo"),
1043 OntoSpec {
1044 display: "origin/main".to_string(),
1045 fetch: Some(("origin".to_string(), "main".to_string())),
1046 },
1047 );
1048 map
1049 }
1050
1051 fn ok_map(ok: bool) -> BTreeMap<PathBuf, bool> {
1052 let mut map = BTreeMap::new();
1053 map.insert(PathBuf::from("/repo"), ok);
1054 map
1055 }
1056
1057 fn classify_reason(
1058 inspected: &Inspected,
1059 onto: &BTreeMap<PathBuf, OntoSpec>,
1060 autostash: bool,
1061 ) -> RebaseResult {
1062 inspected.classify(onto, &ok_map(true), autostash).result
1063 }
1064
1065 #[test]
1066 fn classify_skips_a_detached_head() {
1067 let out = classify_reason(
1068 &inspected(None, Some(Oid::ZERO_SHA1), true, false),
1069 &onto_map(),
1070 false,
1071 );
1072 assert_eq!(
1073 out,
1074 RebaseResult::Skipped {
1075 reason: SkipReason::DetachedHead
1076 }
1077 );
1078 }
1079
1080 #[test]
1081 fn classify_skips_an_in_progress_operation() {
1082 let out = classify_reason(
1083 &inspected(Some("f"), Some(Oid::ZERO_SHA1), false, false),
1084 &onto_map(),
1085 false,
1086 );
1087 assert_eq!(
1088 out,
1089 RebaseResult::Skipped {
1090 reason: SkipReason::OperationInProgress
1091 }
1092 );
1093 }
1094
1095 #[test]
1096 fn classify_skips_dirty_only_without_autostash() {
1097 let dirty = inspected(Some("f"), Some(Oid::ZERO_SHA1), true, true);
1098 assert_eq!(
1099 classify_reason(&dirty, &onto_map(), false),
1100 RebaseResult::Skipped {
1101 reason: SkipReason::Dirty
1102 }
1103 );
1104 assert_eq!(
1107 classify_reason(&dirty, &onto_map(), true),
1108 RebaseResult::Skipped {
1109 reason: SkipReason::NoOntoRef
1110 }
1111 );
1112 }
1113
1114 #[test]
1115 fn classify_reports_no_onto_ref_when_the_repo_is_unresolved() {
1116 let out = classify_reason(
1117 &inspected(Some("f"), Some(Oid::ZERO_SHA1), true, false),
1118 &BTreeMap::new(),
1119 false,
1120 );
1121 assert_eq!(
1122 out,
1123 RebaseResult::Skipped {
1124 reason: SkipReason::NoOntoRef
1125 }
1126 );
1127 }
1128
1129 #[test]
1130 fn classify_reports_fetch_failed_when_the_repos_fetch_failed() {
1131 let out = inspected(Some("f"), Some(Oid::ZERO_SHA1), true, false)
1132 .classify(&onto_map(), &ok_map(false), false)
1133 .result;
1134 assert!(matches!(out, RebaseResult::FetchFailed { .. }));
1135 }
1136
1137 #[test]
1138 fn classify_reports_not_a_worktree_for_an_unresolvable_path() {
1139 let out = Inspected::Unresolvable {
1140 path: PathBuf::from("/x"),
1141 }
1142 .classify(&onto_map(), &ok_map(true), false)
1143 .result;
1144 assert_eq!(
1145 out,
1146 RebaseResult::Skipped {
1147 reason: SkipReason::NotAWorktree
1148 }
1149 );
1150 }
1151
1152 #[test]
1153 fn head_branch_reports_branch_detached_and_unborn() {
1154 let dir = tempfile::tempdir().unwrap();
1155 let repo = Repository::init(dir.path()).unwrap();
1156 config_identity(&repo);
1157 assert_eq!(head_branch(&repo), (None, None));
1159 let oid = empty_commit(&repo, "refs/heads/main", &[]);
1161 repo.set_head("refs/heads/main").unwrap();
1162 let (branch, head) = head_branch(&repo);
1163 assert_eq!(branch.as_deref(), Some("main"));
1164 assert_eq!(head, Some(oid));
1165 repo.set_head_detached(oid).unwrap();
1167 assert_eq!(head_branch(&repo), (None, Some(oid)));
1168 }
1169
1170 #[test]
1171 fn resolve_onto_honours_an_override() {
1172 let (_dir, repo) = repo_with_origin();
1173 assert_eq!(
1174 resolve_onto(&repo, Some("origin/main")).fetch,
1175 Some(("origin".to_string(), "main".to_string()))
1176 );
1177 assert_eq!(resolve_onto(&repo, Some("develop")).fetch, None);
1178 }
1179
1180 #[test]
1181 fn fetch_all_skips_the_fetch_for_a_local_onto() {
1182 let mut map = BTreeMap::new();
1183 map.insert(
1184 PathBuf::from("/repo"),
1185 OntoSpec {
1186 display: "HEAD~1".to_string(),
1187 fetch: None,
1188 },
1189 );
1190 let (fetches, ok) = fetch_all(Path::new("git"), &map);
1191 assert_eq!(fetches.len(), 1);
1192 assert!(!fetches[0].fetched && fetches[0].ok);
1193 assert_eq!(ok.get(Path::new("/repo")), Some(&true));
1194 }
1195
1196 #[test]
1197 fn fetch_once_errors_when_the_remote_is_missing() {
1198 let _guard = serial();
1199 let dir = tempfile::tempdir().unwrap();
1200 let repo = Repository::init(dir.path()).unwrap();
1201 config_identity(&repo);
1202 let err = fetch_once(&resolve_git_binary(), dir.path(), "origin", "main")
1203 .unwrap_err()
1204 .to_string();
1205 assert!(err.contains("git fetch"), "got: {err}");
1206 }
1207
1208 fn repo_with_origin() -> (tempfile::TempDir, Repository) {
1213 let dir = tempfile::tempdir().unwrap();
1214 let repo = Repository::init(dir.path()).unwrap();
1215 config_identity(&repo);
1216 repo.remote("origin", "https://example.invalid/x.git")
1217 .unwrap();
1218 let oid = empty_commit(&repo, "refs/heads/main", &[]);
1219 repo.reference("refs/remotes/origin/main", oid, true, "seed")
1220 .unwrap();
1221 (dir, repo)
1222 }
1223
1224 struct Scenario {
1227 root: tempfile::TempDir,
1228 origin: PathBuf,
1229 local: PathBuf,
1230 }
1231
1232 impl Scenario {
1233 fn new() -> Self {
1234 let root = tempfile::tempdir().unwrap();
1235 let origin = root.path().join("origin.git");
1236 let local = root.path().join("local");
1237 std::fs::create_dir_all(&origin).unwrap();
1238 std::fs::create_dir_all(&local).unwrap();
1239 git(&origin, &["init", "--bare", "-b", "main"]);
1240 git(&local, &["init", "-b", "main"]);
1241 config_repo(&local, "Test", "test@example.com");
1242 std::fs::write(local.join("file.txt"), "first\n").unwrap();
1243 std::fs::write(local.join("keep.txt"), "keep\n").unwrap();
1246 git(&local, &["add", "file.txt", "keep.txt"]);
1247 git(&local, &["commit", "-m", "first"]);
1248 git(
1249 &local,
1250 &["remote", "add", "origin", origin.to_str().unwrap()],
1251 );
1252 git(&local, &["push", "-u", "origin", "main"]);
1253 Self {
1254 root,
1255 origin,
1256 local,
1257 }
1258 }
1259
1260 fn add_worktree(&self, name: &str) -> PathBuf {
1262 let path = self.root.path().join(name);
1263 git(
1264 &self.local,
1265 &[
1266 "worktree",
1267 "add",
1268 "-b",
1269 name,
1270 path.to_str().unwrap(),
1271 "main",
1272 ],
1273 );
1274 path
1275 }
1276
1277 fn advance_origin_main(&self, content: &str) {
1284 let repo = Repository::open_bare(&self.origin).unwrap();
1285 let parent = repo
1286 .find_commit(repo.refname_to_id("refs/heads/main").unwrap())
1287 .unwrap();
1288 let mut builder = repo.treebuilder(Some(&parent.tree().unwrap())).unwrap();
1291 let blob = repo.blob(content.as_bytes()).unwrap();
1292 builder.insert("file.txt", blob, 0o100_644).unwrap();
1293 let tree = repo.find_tree(builder.write().unwrap()).unwrap();
1294 let sig = git2::Signature::now("Other", "other@example.com").unwrap();
1295 repo.commit(
1296 Some("refs/heads/main"),
1297 &sig,
1298 &sig,
1299 "advance",
1300 &tree,
1301 &[&parent],
1302 )
1303 .unwrap();
1304 }
1305
1306 fn commit_in_worktree(&self, wt: &Path, file: &str, content: &str, msg: &str) {
1308 std::fs::write(wt.join(file), content).unwrap();
1309 git(wt, &["add", file]);
1310 git(wt, &["commit", "-m", msg]);
1311 }
1312
1313 fn origin_main_oid(&self) -> Oid {
1315 let repo = Repository::open_bare(&self.origin).unwrap();
1316 repo.refname_to_id("refs/heads/main").unwrap()
1317 }
1318 }
1319
1320 fn config_repo(dir: &Path, name: &str, email: &str) {
1329 git(dir, &["config", "user.name", name]);
1330 git(dir, &["config", "user.email", email]);
1331 git(dir, &["config", "commit.gpgsign", "false"]);
1332 }
1333
1334 fn git(dir: &Path, args: &[&str]) {
1335 let output = run_git_in(&resolve_git_binary(), dir, args).unwrap();
1336 assert!(
1337 output.status.success(),
1338 "git {args:?} failed: {}",
1339 String::from_utf8_lossy(&output.stderr)
1340 );
1341 }
1342
1343 fn config_identity(repo: &Repository) {
1344 let mut cfg = repo.config().unwrap();
1345 cfg.set_str("user.name", "Test").unwrap();
1346 cfg.set_str("user.email", "test@example.com").unwrap();
1347 }
1348
1349 fn empty_commit(repo: &Repository, refname: &str, parents: &[&git2::Commit<'_>]) -> Oid {
1350 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
1351 let tree = repo
1352 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
1353 .unwrap();
1354 repo.commit(Some(refname), &sig, &sig, "seed", &tree, parents)
1355 .unwrap()
1356 }
1357
1358 fn head_oid(wt: &Path) -> Oid {
1359 let repo = Repository::open(wt).unwrap();
1360 let head = repo.head().unwrap();
1361 head.target().unwrap()
1362 }
1363
1364 fn head_contains(wt: &Path, oid: &Oid) -> bool {
1365 let repo = Repository::open(wt).unwrap();
1366 let head = repo.head().unwrap().target().unwrap();
1367 repo.graph_descendant_of(head, *oid).unwrap_or(false) || head == *oid
1368 }
1369}