1use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34use std::process::Command;
35
36use anyhow::{bail, Context, Result};
37use git2::{Oid, Repository, RepositoryState, StatusOptions};
38use serde::Serialize;
39
40use crate::git::remote::RemoteInfo;
41use crate::git::resolve_git_binary;
42
43#[derive(Debug, Clone)]
45pub enum Selection {
46 Paths(Vec<PathBuf>),
50 All {
53 base: PathBuf,
55 },
56}
57
58#[derive(Debug, Clone, Default)]
60pub struct RebaseOptions {
61 pub onto: Option<String>,
65 pub autostash: bool,
68 pub dry_run: bool,
70 pub keep_conflicts: bool,
82 pub git_bin: Option<PathBuf>,
87}
88
89impl RebaseOptions {
90 fn git_bin(&self) -> PathBuf {
93 self.git_bin.clone().unwrap_or_else(resolve_git_binary)
94 }
95}
96
97#[derive(Debug, Clone, Serialize)]
99pub struct Plan {
100 pub fetches: Vec<FetchOutcome>,
103 pub worktrees: Vec<WorktreeOutcome>,
105}
106
107impl Plan {
108 #[must_use]
111 pub fn has_pending_rebases(&self) -> bool {
112 self.worktrees
113 .iter()
114 .any(|w| matches!(w.result, RebaseResult::WouldRebase { .. }))
115 }
116}
117
118#[derive(Debug, Clone, Serialize)]
120pub struct FetchOutcome {
121 pub repo_root: PathBuf,
123 pub onto: String,
125 pub fetched: bool,
127 pub ok: bool,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub detail: Option<String>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
136pub struct WorktreeOutcome {
137 pub path: PathBuf,
139 #[serde(skip_serializing_if = "Option::is_none")]
141 pub branch: Option<String>,
142 pub onto: String,
144 #[serde(flatten)]
146 pub result: RebaseResult,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
155#[serde(tag = "status", rename_all = "kebab-case")]
156pub enum RebaseResult {
157 Rebased {
159 behind: usize,
161 },
162 WouldRebase {
164 behind: usize,
166 },
167 UpToDate,
169 Skipped {
171 reason: SkipReason,
173 },
174 Conflict {
178 detail: String,
180 #[serde(skip_serializing_if = "is_false")]
185 left_in_place: bool,
186 },
187 FetchFailed {
189 detail: String,
191 },
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
196#[serde(rename_all = "kebab-case")]
197pub enum SkipReason {
198 MainWorkingTree,
201 DetachedHead,
203 Dirty,
205 OperationInProgress,
207 NotAWorktree,
209 NoOntoRef,
211}
212
213pub fn plan(selection: &Selection, opts: &RebaseOptions) -> Result<Plan> {
224 let paths = resolve_selection(selection)?;
225 let git = opts.git_bin();
228
229 let inspected: Vec<Inspected> = paths.iter().map(|p| Inspected::read(p)).collect();
231
232 let onto_by_repo = resolve_onto_by_repo(&inspected, opts.onto.as_deref());
234
235 let (fetches, fetch_ok) = fetch_all(&git, &onto_by_repo);
237
238 let worktrees = inspected
240 .iter()
241 .map(|i| i.classify(&onto_by_repo, &fetch_ok, opts.autostash))
242 .collect();
243
244 Ok(Plan { fetches, worktrees })
245}
246
247#[must_use]
255pub fn execute(plan: Plan, opts: &RebaseOptions) -> Vec<WorktreeOutcome> {
256 let git = opts.git_bin();
257 plan.worktrees
258 .into_iter()
259 .map(|mut outcome| {
260 if let RebaseResult::WouldRebase { behind } = outcome.result {
261 outcome.result = match rebase_worktree(&git, &outcome.path, &outcome.onto, opts) {
262 Ok(()) => RebaseResult::Rebased { behind },
263 Err(detail) => RebaseResult::Conflict {
264 detail,
265 left_in_place: opts.keep_conflicts,
266 },
267 };
268 }
269 outcome
270 })
271 .collect()
272}
273
274fn resolve_selection(selection: &Selection) -> Result<Vec<PathBuf>> {
278 match selection {
279 Selection::Paths(paths) => Ok(paths.clone()),
280 Selection::All { base } => linked_worktree_paths(base),
281 }
282}
283
284fn linked_worktree_paths(base: &Path) -> Result<Vec<PathBuf>> {
289 let repo = Repository::discover(base)
290 .with_context(|| format!("not inside a git repository: {}", base.display()))?;
291 let root = main_root(&repo);
292 let main_repo = Repository::open(&root)
293 .with_context(|| format!("cannot open main repository: {}", root.display()))?;
294 let names = main_repo
295 .worktrees()
296 .context("cannot enumerate worktrees")?;
297 let mut paths = Vec::new();
298 for name in names.iter().flatten().flatten() {
301 if let Ok(worktree) = main_repo.find_worktree(name) {
302 paths.push(worktree.path().to_path_buf());
303 }
304 }
305 Ok(paths)
306}
307
308enum Inspected {
312 Ok(Inspection),
314 Unresolvable {
316 path: PathBuf,
318 },
319}
320
321struct Inspection {
323 path: PathBuf,
324 repo_root: PathBuf,
325 branch: Option<String>,
326 head_oid: Option<Oid>,
327 is_main: bool,
328 state_clean: bool,
329 dirty: bool,
330}
331
332impl Inspected {
333 fn read(path: &Path) -> Self {
337 let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
338 let Ok(repo) = Repository::discover(&canon) else {
339 return Self::Unresolvable { path: canon };
340 };
341 let is_main = !repo.is_worktree();
342 let repo_root = main_root(&repo);
343 let (branch, head_oid) = head_branch(&repo);
344 let state_clean = repo.state() == RepositoryState::Clean;
345 let dirty = is_dirty(&repo);
346 Self::Ok(Inspection {
347 path: canon,
348 repo_root,
349 branch,
350 head_oid,
351 is_main,
352 state_clean,
353 dirty,
354 })
355 }
356
357 fn repo_root(&self) -> Option<&Path> {
359 match self {
360 Self::Ok(i) => Some(&i.repo_root),
361 Self::Unresolvable { .. } => None,
362 }
363 }
364
365 fn classify(
367 &self,
368 onto_by_repo: &BTreeMap<PathBuf, OntoSpec>,
369 fetch_ok: &BTreeMap<PathBuf, bool>,
370 autostash: bool,
371 ) -> WorktreeOutcome {
372 let i = match self {
373 Self::Unresolvable { path } => {
374 return WorktreeOutcome::skipped(
375 path.clone(),
376 None,
377 String::new(),
378 SkipReason::NotAWorktree,
379 );
380 }
381 Self::Ok(i) => i,
382 };
383
384 let onto = onto_by_repo.get(&i.repo_root);
385 let onto_display = onto.map_or_else(String::new, |s| s.display.clone());
386 let branch = i.branch.clone();
387 let skip = |reason| {
388 WorktreeOutcome::skipped(i.path.clone(), branch.clone(), onto_display.clone(), reason)
389 };
390
391 if i.is_main {
393 return skip(SkipReason::MainWorkingTree);
394 }
395 let (Some(head), Some(_)) = (i.head_oid, i.branch.as_ref()) else {
396 return skip(SkipReason::DetachedHead);
397 };
398 if !i.state_clean {
399 return skip(SkipReason::OperationInProgress);
400 }
401 if i.dirty && !autostash {
402 return skip(SkipReason::Dirty);
403 }
404 let Some(onto) = onto else {
405 return skip(SkipReason::NoOntoRef);
406 };
407
408 if fetch_ok.get(&i.repo_root) == Some(&false) {
410 let detail = "the repository's fetch failed".to_string();
411 return WorktreeOutcome {
412 path: i.path.clone(),
413 branch,
414 onto: onto_display,
415 result: RebaseResult::FetchFailed { detail },
416 };
417 }
418
419 match behind_count(&i.repo_root, head, &onto.display) {
421 None => skip(SkipReason::NoOntoRef),
422 Some(0) => WorktreeOutcome {
423 path: i.path.clone(),
424 branch,
425 onto: onto_display,
426 result: RebaseResult::UpToDate,
427 },
428 Some(behind) => WorktreeOutcome {
429 path: i.path.clone(),
430 branch,
431 onto: onto_display,
432 result: RebaseResult::WouldRebase { behind },
433 },
434 }
435 }
436}
437
438impl WorktreeOutcome {
439 fn skipped(path: PathBuf, branch: Option<String>, onto: String, reason: SkipReason) -> Self {
441 Self {
442 path,
443 branch,
444 onto,
445 result: RebaseResult::Skipped { reason },
446 }
447 }
448}
449
450fn main_root(repo: &Repository) -> PathBuf {
453 let commondir = repo.commondir();
454 let commondir = std::fs::canonicalize(commondir).unwrap_or_else(|_| commondir.to_path_buf());
455 let parent = commondir.parent().map(Path::to_path_buf);
456 parent.unwrap_or(commondir)
457}
458
459fn head_branch(repo: &Repository) -> (Option<String>, Option<Oid>) {
462 match repo.head() {
463 Ok(head) if head.is_branch() => (
464 head.shorthand().ok().map(ToString::to_string),
465 head.target(),
466 ),
467 Ok(head) => (None, head.target()),
468 Err(_) => (None, None),
469 }
470}
471
472fn is_dirty(repo: &Repository) -> bool {
476 let mut opts = StatusOptions::new();
477 opts.include_untracked(false)
478 .include_ignored(false)
479 .exclude_submodules(true);
480 repo.statuses(Some(&mut opts))
481 .is_ok_and(|statuses| !statuses.is_empty())
482}
483
484fn behind_count(repo_root: &Path, head: Oid, onto: &str) -> Option<usize> {
487 let repo = Repository::open(repo_root).ok()?;
488 let onto_oid = repo.revparse_single(onto).ok()?.peel_to_commit().ok()?.id();
489 let (_ahead, behind) = repo.graph_ahead_behind(head, onto_oid).ok()?;
490 Some(behind)
491}
492
493#[derive(Debug, Clone, PartialEq, Eq)]
497struct OntoSpec {
498 display: String,
500 fetch: Option<(String, String)>,
503}
504
505fn resolve_onto_by_repo(
509 inspected: &[Inspected],
510 override_ref: Option<&str>,
511) -> BTreeMap<PathBuf, OntoSpec> {
512 let mut map: BTreeMap<PathBuf, OntoSpec> = BTreeMap::new();
513 for root in inspected.iter().filter_map(Inspected::repo_root) {
514 if map.contains_key(root) {
515 continue;
516 }
517 if let Ok(repo) = Repository::open(root) {
518 map.insert(root.to_path_buf(), resolve_onto(&repo, override_ref));
519 }
520 }
521 map
522}
523
524fn resolve_onto(repo: &Repository, override_ref: Option<&str>) -> OntoSpec {
527 if let Some(reference) = override_ref {
528 return onto_from_override(repo, reference);
529 }
530 let remote = "origin";
531 let branch =
532 RemoteInfo::detect_main_branch_local(repo, remote).unwrap_or_else(|| "main".to_string());
533 OntoSpec {
534 display: format!("{remote}/{branch}"),
535 fetch: Some((remote.to_string(), branch)),
536 }
537}
538
539fn onto_from_override(repo: &Repository, reference: &str) -> OntoSpec {
543 if let Some((remote, branch)) = reference.split_once('/') {
544 if repo.find_remote(remote).is_ok() {
545 return OntoSpec {
546 display: reference.to_string(),
547 fetch: Some((remote.to_string(), branch.to_string())),
548 };
549 }
550 }
551 OntoSpec {
552 display: reference.to_string(),
553 fetch: None,
554 }
555}
556
557fn fetch_all(
563 git: &Path,
564 onto_by_repo: &BTreeMap<PathBuf, OntoSpec>,
565) -> (Vec<FetchOutcome>, BTreeMap<PathBuf, bool>) {
566 let mut fetches = Vec::new();
567 let mut fetch_ok = BTreeMap::new();
568 for (root, spec) in onto_by_repo {
569 let outcome = match &spec.fetch {
570 Some((remote, branch)) => {
571 let result = fetch_once(git, root, remote, branch);
572 let ok = result.is_ok();
573 FetchOutcome {
574 repo_root: root.clone(),
575 onto: spec.display.clone(),
576 fetched: true,
577 ok,
578 detail: result.err().map(|e| e.to_string()),
579 }
580 }
581 None => FetchOutcome {
582 repo_root: root.clone(),
583 onto: spec.display.clone(),
584 fetched: false,
585 ok: true,
586 detail: None,
587 },
588 };
589 fetch_ok.insert(root.clone(), outcome.ok);
590 fetches.push(outcome);
591 }
592 (fetches, fetch_ok)
593}
594
595fn fetch_once(git: &Path, repo_root: &Path, remote: &str, branch: &str) -> Result<()> {
598 let output = run_git_in(git, repo_root, &["fetch", remote, branch])?;
599 if output.status.success() {
600 return Ok(());
601 }
602 bail!(
603 "git fetch {remote} {branch} failed: {}",
604 trimmed_stderr(&output)
605 )
606}
607
608fn rebase_worktree(
620 git: &Path,
621 path: &Path,
622 onto: &str,
623 opts: &RebaseOptions,
624) -> std::result::Result<(), String> {
625 let args = rebase_args(onto, opts.autostash);
626 let argv: Vec<&str> = args.iter().map(String::as_str).collect();
627 match run_git_in(git, path, &argv) {
628 Ok(output) if output.status.success() => Ok(()),
629 Ok(output) => {
630 let detail = trimmed_stderr(&output);
631 if !opts.keep_conflicts {
632 let _ = run_git_in(git, path, &["rebase", "--abort"]);
634 }
635 Err(detail)
636 }
637 Err(err) => Err(err.to_string()),
638 }
639}
640
641fn rebase_args(onto: &str, autostash: bool) -> Vec<String> {
644 let mut args = vec!["rebase".to_string()];
645 if autostash {
646 args.push("--autostash".to_string());
647 }
648 args.push(onto.to_string());
649 args
650}
651
652fn run_git_in(git: &Path, dir: &Path, args: &[&str]) -> Result<std::process::Output> {
668 let mut cmd = Command::new(git);
669 cmd.env_clear();
670 cmd.envs(std::env::vars_os());
671 cmd.current_dir(dir)
672 .args(args)
673 .output()
674 .with_context(|| format!("failed to execute {} in {}", git.display(), dir.display()))
675}
676
677#[allow(clippy::trivially_copy_pass_by_ref)]
681fn is_false(b: &bool) -> bool {
682 !*b
683}
684
685fn trimmed_stderr(output: &std::process::Output) -> String {
688 let stderr = String::from_utf8_lossy(&output.stderr);
689 let trimmed = stderr.trim();
690 if trimmed.is_empty() {
691 String::from_utf8_lossy(&output.stdout).trim().to_string()
692 } else {
693 trimmed.to_string()
694 }
695}
696
697#[cfg(test)]
707pub(crate) fn test_serial_lock() -> std::sync::MutexGuard<'static, ()> {
708 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
709 LOCK.lock()
710 .unwrap_or_else(std::sync::PoisonError::into_inner)
711}
712
713#[cfg(test)]
714#[allow(clippy::unwrap_used, clippy::expect_used)]
715mod tests {
716 use super::*;
717
718 fn serial() -> std::sync::MutexGuard<'static, ()> {
720 super::test_serial_lock()
721 }
722
723 #[test]
726 fn rebase_args_omits_autostash_by_default() {
727 assert_eq!(
728 rebase_args("origin/main", false),
729 vec!["rebase", "origin/main"]
730 );
731 }
732
733 #[test]
734 fn rebase_args_inserts_autostash_before_the_ref() {
735 assert_eq!(
736 rebase_args("origin/main", true),
737 vec!["rebase", "--autostash", "origin/main"]
738 );
739 }
740
741 #[test]
742 fn onto_from_override_fetches_a_remote_tracking_ref() {
743 let (_dir, repo) = repo_with_origin();
744 let spec = onto_from_override(&repo, "origin/release");
745 assert_eq!(spec.display, "origin/release");
746 assert_eq!(
747 spec.fetch,
748 Some(("origin".to_string(), "release".to_string()))
749 );
750 }
751
752 #[test]
753 fn onto_from_override_keeps_a_multi_segment_branch_whole() {
754 let (_dir, repo) = repo_with_origin();
755 let spec = onto_from_override(&repo, "origin/feature/foo");
756 assert_eq!(
757 spec.fetch,
758 Some(("origin".to_string(), "feature/foo".to_string()))
759 );
760 }
761
762 #[test]
763 fn onto_from_override_does_not_fetch_a_local_ref() {
764 let (_dir, repo) = repo_with_origin();
765 assert_eq!(onto_from_override(&repo, "develop").fetch, None);
767 assert_eq!(onto_from_override(&repo, "upstream/x").fetch, None);
768 assert_eq!(onto_from_override(&repo, "HEAD~2").fetch, None);
769 }
770
771 #[test]
772 fn resolve_onto_defaults_to_origin_main() {
773 let (_dir, repo) = repo_with_origin();
774 let spec = resolve_onto(&repo, None);
775 assert_eq!(spec.display, "origin/main");
776 assert_eq!(spec.fetch, Some(("origin".to_string(), "main".to_string())));
777 }
778
779 #[test]
782 fn one_repo_with_many_worktrees_fetches_exactly_once() {
783 let _guard = serial();
786 let scenario = Scenario::new();
787 scenario.add_worktree("feature-a");
788 scenario.add_worktree("feature-b");
789 scenario.add_worktree("feature-c");
790
791 let plan = plan(
792 &Selection::All {
793 base: scenario.local,
794 },
795 &RebaseOptions::default(),
796 )
797 .unwrap();
798
799 assert_eq!(
800 plan.fetches.len(),
801 1,
802 "fetch must run once per repo, not per worktree"
803 );
804 assert_eq!(plan.worktrees.len(), 3);
805 assert!(plan.fetches[0].ok);
806 }
807
808 #[test]
809 fn resolve_onto_by_repo_collapses_worktrees_of_one_repo() {
810 let _guard = serial();
811 let scenario = Scenario::new();
812 scenario.add_worktree("feature-a");
813 scenario.add_worktree("feature-b");
814 let paths = linked_worktree_paths(&scenario.local).unwrap();
815 let inspected: Vec<Inspected> = paths.iter().map(|p| Inspected::read(p)).collect();
816 let map = resolve_onto_by_repo(&inspected, None);
817 assert_eq!(
818 map.len(),
819 1,
820 "two worktrees of one repo resolve to one onto entry"
821 );
822 }
823
824 #[test]
827 fn behind_worktree_is_rebased_onto_the_fetched_ref() {
828 let _guard = serial();
829 let scenario = Scenario::new();
830 let wt = scenario.add_worktree("feature");
831 scenario.advance_origin_main("second\n");
834
835 let plan = plan(
836 &Selection::Paths(vec![wt.clone()]),
837 &RebaseOptions::default(),
838 )
839 .unwrap();
840 assert_eq!(plan.worktrees.len(), 1);
841 assert_eq!(
842 plan.worktrees[0].result,
843 RebaseResult::WouldRebase { behind: 1 },
844 "the feature worktree is one commit behind the fetched origin/main"
845 );
846
847 let outcomes = execute(plan, &RebaseOptions::default());
848 assert_eq!(outcomes[0].result, RebaseResult::Rebased { behind: 1 });
849 assert!(head_contains(&wt, &scenario.origin_main_oid()));
851 }
852
853 #[test]
854 fn up_to_date_worktree_is_not_rebased() {
855 let _guard = serial();
856 let scenario = Scenario::new();
857 let wt = scenario.add_worktree("feature");
858 let plan = plan(&Selection::Paths(vec![wt]), &RebaseOptions::default()).unwrap();
860 assert_eq!(plan.worktrees[0].result, RebaseResult::UpToDate);
861 assert!(!plan.has_pending_rebases());
862 }
863
864 #[test]
865 fn dirty_worktree_is_skipped_but_autostash_rebases_it() {
866 let _guard = serial();
867 let scenario = Scenario::new();
868 let wt = scenario.add_worktree("feature");
869 scenario.advance_origin_main("second\n");
870 std::fs::write(wt.join("keep.txt"), "dirty change\n").unwrap();
872
873 let skipped = plan(
874 &Selection::Paths(vec![wt.clone()]),
875 &RebaseOptions::default(),
876 )
877 .unwrap();
878 assert_eq!(
879 skipped.worktrees[0].result,
880 RebaseResult::Skipped {
881 reason: SkipReason::Dirty
882 }
883 );
884
885 let opts = RebaseOptions {
886 autostash: true,
887 ..RebaseOptions::default()
888 };
889 let planned = plan(&Selection::Paths(vec![wt.clone()]), &opts).unwrap();
890 assert_eq!(
891 planned.worktrees[0].result,
892 RebaseResult::WouldRebase { behind: 1 }
893 );
894 let outcomes = execute(planned, &opts);
895 assert_eq!(outcomes[0].result, RebaseResult::Rebased { behind: 1 });
896 assert_eq!(
898 std::fs::read_to_string(wt.join("keep.txt")).unwrap(),
899 "dirty change\n"
900 );
901 }
902
903 #[test]
904 fn main_working_tree_is_skipped() {
905 let _guard = serial();
906 let scenario = Scenario::new();
907 let plan = plan(
908 &Selection::Paths(vec![scenario.local]),
909 &RebaseOptions::default(),
910 )
911 .unwrap();
912 assert_eq!(
913 plan.worktrees[0].result,
914 RebaseResult::Skipped {
915 reason: SkipReason::MainWorkingTree
916 }
917 );
918 }
919
920 #[test]
921 fn non_worktree_path_is_skipped_not_fatal() {
922 let dir = tempfile::tempdir().unwrap();
923 let plan = plan(
924 &Selection::Paths(vec![dir.path().to_path_buf()]),
925 &RebaseOptions::default(),
926 )
927 .unwrap();
928 assert_eq!(
929 plan.worktrees[0].result,
930 RebaseResult::Skipped {
931 reason: SkipReason::NotAWorktree
932 }
933 );
934 }
935
936 #[test]
937 fn conflicting_rebase_aborts_and_leaves_the_worktree_untouched() {
938 let _guard = serial();
939 let scenario = Scenario::new();
940 let wt = scenario.add_worktree("feature");
941 scenario.commit_in_worktree(&wt, "file.txt", "feature side\n", "feature edit");
943 scenario.advance_origin_main("main side\n");
944 let head_before = head_oid(&wt);
945
946 let plan = plan(
947 &Selection::Paths(vec![wt.clone()]),
948 &RebaseOptions::default(),
949 )
950 .unwrap();
951 assert!(matches!(
952 plan.worktrees[0].result,
953 RebaseResult::WouldRebase { .. }
954 ));
955 let outcomes = execute(plan, &RebaseOptions::default());
956 assert!(
957 matches!(
958 outcomes[0].result,
959 RebaseResult::Conflict {
960 left_in_place: false,
961 ..
962 }
963 ),
964 "a conflicting rebase is reported, not silently half-applied"
965 );
966 assert_eq!(head_oid(&wt), head_before);
968 let repo = Repository::open(&wt).unwrap();
969 assert_eq!(repo.state(), RepositoryState::Clean);
970 }
971
972 #[test]
973 fn keep_conflicts_leaves_the_worktree_mid_rebase() {
974 let _guard = serial();
978 let scenario = Scenario::new();
979 let wt = scenario.add_worktree("feature");
980 scenario.commit_in_worktree(&wt, "file.txt", "feature side\n", "feature edit");
981 scenario.advance_origin_main("main side\n");
982
983 let opts = RebaseOptions {
984 keep_conflicts: true,
985 ..RebaseOptions::default()
986 };
987 let plan = plan(&Selection::Paths(vec![wt.clone()]), &opts).unwrap();
988 let outcomes = execute(plan, &opts);
989 assert!(
990 matches!(
991 outcomes[0].result,
992 RebaseResult::Conflict {
993 left_in_place: true,
994 ..
995 }
996 ),
997 "the outcome records that the worktree was left mid-rebase"
998 );
999 let repo = Repository::open(&wt).unwrap();
1002 assert_ne!(
1003 repo.state(),
1004 RepositoryState::Clean,
1005 "the worktree must still be mid-rebase, not aborted back to clean"
1006 );
1007 let conflicted = std::fs::read_to_string(wt.join("file.txt")).unwrap();
1009 assert!(
1010 conflicted.contains("<<<<<<<"),
1011 "expected conflict markers, got: {conflicted}"
1012 );
1013 }
1014
1015 #[test]
1016 fn a_kept_conflict_does_not_stop_the_rest_of_the_batch() {
1017 let _guard = serial();
1020 let scenario = Scenario::new();
1021 let clashing = scenario.add_worktree("clashing");
1022 let clean = scenario.add_worktree("clean");
1023 scenario.commit_in_worktree(&clashing, "file.txt", "feature side\n", "feature edit");
1024 scenario.advance_origin_main("main side\n");
1025
1026 let opts = RebaseOptions {
1027 keep_conflicts: true,
1028 ..RebaseOptions::default()
1029 };
1030 let plan = plan(&Selection::Paths(vec![clashing, clean.clone()]), &opts).unwrap();
1031 let outcomes = execute(plan, &opts);
1032 assert!(matches!(
1033 outcomes[0].result,
1034 RebaseResult::Conflict {
1035 left_in_place: true,
1036 ..
1037 }
1038 ));
1039 assert_eq!(
1040 outcomes[1].result,
1041 RebaseResult::Rebased { behind: 1 },
1042 "the second worktree rebases despite the first being left conflicted"
1043 );
1044 assert!(head_contains(&clean, &scenario.origin_main_oid()));
1045 }
1046
1047 #[test]
1048 fn left_in_place_is_omitted_from_json_when_false() {
1049 let aborted = serde_json::to_value(RebaseResult::Conflict {
1052 detail: "boom".to_string(),
1053 left_in_place: false,
1054 })
1055 .unwrap();
1056 assert_eq!(aborted["status"], "conflict");
1057 assert!(aborted.get("left_in_place").is_none());
1058
1059 let kept = serde_json::to_value(RebaseResult::Conflict {
1060 detail: "boom".to_string(),
1061 left_in_place: true,
1062 })
1063 .unwrap();
1064 assert_eq!(kept["left_in_place"], true);
1065 }
1066
1067 #[test]
1068 fn git_bin_defaults_to_the_resolver_and_honours_an_override() {
1069 assert_eq!(
1070 RebaseOptions::default().git_bin(),
1071 crate::git::resolve_git_binary(),
1072 "an unset git_bin falls back to the shared resolver"
1073 );
1074 let opts = RebaseOptions {
1075 git_bin: Some(PathBuf::from("/custom/git")),
1076 ..RebaseOptions::default()
1077 };
1078 assert_eq!(opts.git_bin(), PathBuf::from("/custom/git"));
1079 }
1080
1081 #[test]
1082 fn dry_run_fetches_but_rebases_nothing() {
1083 let _guard = serial();
1084 let scenario = Scenario::new();
1085 let wt = scenario.add_worktree("feature");
1086 scenario.advance_origin_main("second\n");
1087 let head_before = head_oid(&wt);
1088
1089 let opts = RebaseOptions {
1090 dry_run: true,
1091 ..RebaseOptions::default()
1092 };
1093 let plan = plan(&Selection::Paths(vec![wt.clone()]), &opts).unwrap();
1094 assert_eq!(
1097 plan.worktrees[0].result,
1098 RebaseResult::WouldRebase { behind: 1 }
1099 );
1100 assert_eq!(plan.fetches.len(), 1);
1101 assert!(plan.fetches[0].fetched && plan.fetches[0].ok);
1102 assert_eq!(
1103 head_oid(&wt),
1104 head_before,
1105 "dry run must not move the branch"
1106 );
1107 }
1108
1109 #[test]
1110 fn json_shape_is_kebab_tagged() {
1111 let outcome = WorktreeOutcome {
1112 path: PathBuf::from("/wt"),
1113 branch: Some("feature".to_string()),
1114 onto: "origin/main".to_string(),
1115 result: RebaseResult::Skipped {
1116 reason: SkipReason::Dirty,
1117 },
1118 };
1119 let value = serde_json::to_value(&outcome).unwrap();
1120 assert_eq!(value["status"], "skipped");
1121 assert_eq!(value["reason"], "dirty");
1122 assert_eq!(value["onto"], "origin/main");
1123 }
1124
1125 fn inspected(
1130 branch: Option<&str>,
1131 head: Option<Oid>,
1132 is_main: bool,
1133 state_clean: bool,
1134 dirty: bool,
1135 ) -> Inspected {
1136 Inspected::Ok(Inspection {
1137 path: PathBuf::from("/wt"),
1138 repo_root: PathBuf::from("/repo"),
1139 branch: branch.map(str::to_string),
1140 head_oid: head,
1141 is_main,
1142 state_clean,
1143 dirty,
1144 })
1145 }
1146
1147 fn onto_map() -> BTreeMap<PathBuf, OntoSpec> {
1148 let mut map = BTreeMap::new();
1149 map.insert(
1150 PathBuf::from("/repo"),
1151 OntoSpec {
1152 display: "origin/main".to_string(),
1153 fetch: Some(("origin".to_string(), "main".to_string())),
1154 },
1155 );
1156 map
1157 }
1158
1159 fn ok_map(ok: bool) -> BTreeMap<PathBuf, bool> {
1160 let mut map = BTreeMap::new();
1161 map.insert(PathBuf::from("/repo"), ok);
1162 map
1163 }
1164
1165 fn classify_reason(
1166 inspected: &Inspected,
1167 onto: &BTreeMap<PathBuf, OntoSpec>,
1168 autostash: bool,
1169 ) -> RebaseResult {
1170 inspected.classify(onto, &ok_map(true), autostash).result
1171 }
1172
1173 #[test]
1174 fn classify_skips_the_main_working_tree() {
1175 let out = classify_reason(
1176 &inspected(Some("main"), Some(Oid::ZERO_SHA1), true, true, false),
1177 &onto_map(),
1178 false,
1179 );
1180 assert_eq!(
1181 out,
1182 RebaseResult::Skipped {
1183 reason: SkipReason::MainWorkingTree
1184 }
1185 );
1186 }
1187
1188 #[test]
1189 fn classify_skips_a_detached_head() {
1190 let out = classify_reason(
1191 &inspected(None, Some(Oid::ZERO_SHA1), false, true, false),
1192 &onto_map(),
1193 false,
1194 );
1195 assert_eq!(
1196 out,
1197 RebaseResult::Skipped {
1198 reason: SkipReason::DetachedHead
1199 }
1200 );
1201 }
1202
1203 #[test]
1204 fn classify_skips_an_in_progress_operation() {
1205 let out = classify_reason(
1206 &inspected(Some("f"), Some(Oid::ZERO_SHA1), false, false, false),
1207 &onto_map(),
1208 false,
1209 );
1210 assert_eq!(
1211 out,
1212 RebaseResult::Skipped {
1213 reason: SkipReason::OperationInProgress
1214 }
1215 );
1216 }
1217
1218 #[test]
1219 fn classify_skips_dirty_only_without_autostash() {
1220 let dirty = inspected(Some("f"), Some(Oid::ZERO_SHA1), false, true, true);
1221 assert_eq!(
1222 classify_reason(&dirty, &onto_map(), false),
1223 RebaseResult::Skipped {
1224 reason: SkipReason::Dirty
1225 }
1226 );
1227 assert_eq!(
1230 classify_reason(&dirty, &onto_map(), true),
1231 RebaseResult::Skipped {
1232 reason: SkipReason::NoOntoRef
1233 }
1234 );
1235 }
1236
1237 #[test]
1238 fn classify_reports_no_onto_ref_when_the_repo_is_unresolved() {
1239 let out = classify_reason(
1240 &inspected(Some("f"), Some(Oid::ZERO_SHA1), false, true, false),
1241 &BTreeMap::new(),
1242 false,
1243 );
1244 assert_eq!(
1245 out,
1246 RebaseResult::Skipped {
1247 reason: SkipReason::NoOntoRef
1248 }
1249 );
1250 }
1251
1252 #[test]
1253 fn classify_reports_fetch_failed_when_the_repos_fetch_failed() {
1254 let out = inspected(Some("f"), Some(Oid::ZERO_SHA1), false, true, false)
1255 .classify(&onto_map(), &ok_map(false), false)
1256 .result;
1257 assert!(matches!(out, RebaseResult::FetchFailed { .. }));
1258 }
1259
1260 #[test]
1261 fn classify_reports_not_a_worktree_for_an_unresolvable_path() {
1262 let out = Inspected::Unresolvable {
1263 path: PathBuf::from("/x"),
1264 }
1265 .classify(&onto_map(), &ok_map(true), false)
1266 .result;
1267 assert_eq!(
1268 out,
1269 RebaseResult::Skipped {
1270 reason: SkipReason::NotAWorktree
1271 }
1272 );
1273 }
1274
1275 #[test]
1276 fn head_branch_reports_branch_detached_and_unborn() {
1277 let dir = tempfile::tempdir().unwrap();
1278 let repo = Repository::init(dir.path()).unwrap();
1279 config_identity(&repo);
1280 assert_eq!(head_branch(&repo), (None, None));
1282 let oid = empty_commit(&repo, "refs/heads/main", &[]);
1284 repo.set_head("refs/heads/main").unwrap();
1285 let (branch, head) = head_branch(&repo);
1286 assert_eq!(branch.as_deref(), Some("main"));
1287 assert_eq!(head, Some(oid));
1288 repo.set_head_detached(oid).unwrap();
1290 assert_eq!(head_branch(&repo), (None, Some(oid)));
1291 }
1292
1293 #[test]
1294 fn resolve_onto_honours_an_override() {
1295 let (_dir, repo) = repo_with_origin();
1296 assert_eq!(
1297 resolve_onto(&repo, Some("origin/main")).fetch,
1298 Some(("origin".to_string(), "main".to_string()))
1299 );
1300 assert_eq!(resolve_onto(&repo, Some("develop")).fetch, None);
1301 }
1302
1303 #[test]
1304 fn fetch_all_skips_the_fetch_for_a_local_onto() {
1305 let mut map = BTreeMap::new();
1306 map.insert(
1307 PathBuf::from("/repo"),
1308 OntoSpec {
1309 display: "HEAD~1".to_string(),
1310 fetch: None,
1311 },
1312 );
1313 let (fetches, ok) = fetch_all(Path::new("git"), &map);
1314 assert_eq!(fetches.len(), 1);
1315 assert!(!fetches[0].fetched && fetches[0].ok);
1316 assert_eq!(ok.get(Path::new("/repo")), Some(&true));
1317 }
1318
1319 #[test]
1320 fn fetch_once_errors_when_the_remote_is_missing() {
1321 let _guard = serial();
1322 let dir = tempfile::tempdir().unwrap();
1323 let repo = Repository::init(dir.path()).unwrap();
1324 config_identity(&repo);
1325 let err = fetch_once(&resolve_git_binary(), dir.path(), "origin", "main")
1326 .unwrap_err()
1327 .to_string();
1328 assert!(err.contains("git fetch"), "got: {err}");
1329 }
1330
1331 fn repo_with_origin() -> (tempfile::TempDir, Repository) {
1336 let dir = tempfile::tempdir().unwrap();
1337 let repo = Repository::init(dir.path()).unwrap();
1338 config_identity(&repo);
1339 repo.remote("origin", "https://example.invalid/x.git")
1340 .unwrap();
1341 let oid = empty_commit(&repo, "refs/heads/main", &[]);
1342 repo.reference("refs/remotes/origin/main", oid, true, "seed")
1343 .unwrap();
1344 (dir, repo)
1345 }
1346
1347 struct Scenario {
1350 root: tempfile::TempDir,
1351 origin: PathBuf,
1352 local: PathBuf,
1353 }
1354
1355 impl Scenario {
1356 fn new() -> Self {
1357 let root = tempfile::tempdir().unwrap();
1358 let origin = root.path().join("origin.git");
1359 let local = root.path().join("local");
1360 std::fs::create_dir_all(&origin).unwrap();
1361 std::fs::create_dir_all(&local).unwrap();
1362 git(&origin, &["init", "--bare", "-b", "main"]);
1363 git(&local, &["init", "-b", "main"]);
1364 config_repo(&local, "Test", "test@example.com");
1365 std::fs::write(local.join("file.txt"), "first\n").unwrap();
1366 std::fs::write(local.join("keep.txt"), "keep\n").unwrap();
1369 git(&local, &["add", "file.txt", "keep.txt"]);
1370 git(&local, &["commit", "-m", "first"]);
1371 git(
1372 &local,
1373 &["remote", "add", "origin", origin.to_str().unwrap()],
1374 );
1375 git(&local, &["push", "-u", "origin", "main"]);
1376 Self {
1377 root,
1378 origin,
1379 local,
1380 }
1381 }
1382
1383 fn add_worktree(&self, name: &str) -> PathBuf {
1385 let path = self.root.path().join(name);
1386 git(
1387 &self.local,
1388 &[
1389 "worktree",
1390 "add",
1391 "-b",
1392 name,
1393 path.to_str().unwrap(),
1394 "main",
1395 ],
1396 );
1397 path
1398 }
1399
1400 fn advance_origin_main(&self, content: &str) {
1407 let repo = Repository::open_bare(&self.origin).unwrap();
1408 let parent = repo
1409 .find_commit(repo.refname_to_id("refs/heads/main").unwrap())
1410 .unwrap();
1411 let mut builder = repo.treebuilder(Some(&parent.tree().unwrap())).unwrap();
1414 let blob = repo.blob(content.as_bytes()).unwrap();
1415 builder.insert("file.txt", blob, 0o100_644).unwrap();
1416 let tree = repo.find_tree(builder.write().unwrap()).unwrap();
1417 let sig = git2::Signature::now("Other", "other@example.com").unwrap();
1418 repo.commit(
1419 Some("refs/heads/main"),
1420 &sig,
1421 &sig,
1422 "advance",
1423 &tree,
1424 &[&parent],
1425 )
1426 .unwrap();
1427 }
1428
1429 fn commit_in_worktree(&self, wt: &Path, file: &str, content: &str, msg: &str) {
1431 std::fs::write(wt.join(file), content).unwrap();
1432 git(wt, &["add", file]);
1433 git(wt, &["commit", "-m", msg]);
1434 }
1435
1436 fn origin_main_oid(&self) -> Oid {
1438 let repo = Repository::open_bare(&self.origin).unwrap();
1439 repo.refname_to_id("refs/heads/main").unwrap()
1440 }
1441 }
1442
1443 fn config_repo(dir: &Path, name: &str, email: &str) {
1452 git(dir, &["config", "user.name", name]);
1453 git(dir, &["config", "user.email", email]);
1454 git(dir, &["config", "commit.gpgsign", "false"]);
1455 }
1456
1457 fn git(dir: &Path, args: &[&str]) {
1458 let output = run_git_in(&resolve_git_binary(), dir, args).unwrap();
1459 assert!(
1460 output.status.success(),
1461 "git {args:?} failed: {}",
1462 String::from_utf8_lossy(&output.stderr)
1463 );
1464 }
1465
1466 fn config_identity(repo: &Repository) {
1467 let mut cfg = repo.config().unwrap();
1468 cfg.set_str("user.name", "Test").unwrap();
1469 cfg.set_str("user.email", "test@example.com").unwrap();
1470 }
1471
1472 fn empty_commit(repo: &Repository, refname: &str, parents: &[&git2::Commit<'_>]) -> Oid {
1473 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
1474 let tree = repo
1475 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
1476 .unwrap();
1477 repo.commit(Some(refname), &sig, &sig, "seed", &tree, parents)
1478 .unwrap()
1479 }
1480
1481 fn head_oid(wt: &Path) -> Oid {
1482 let repo = Repository::open(wt).unwrap();
1483 let head = repo.head().unwrap();
1484 head.target().unwrap()
1485 }
1486
1487 fn head_contains(wt: &Path, oid: &Oid) -> bool {
1488 let repo = Repository::open(wt).unwrap();
1489 let head = repo.head().unwrap().target().unwrap();
1490 repo.graph_descendant_of(head, *oid).unwrap_or(false) || head == *oid
1491 }
1492}