use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use git2::{Oid, Repository, RepositoryState, StatusOptions};
use serde::Serialize;
use crate::git::remote::RemoteInfo;
use crate::git::resolve_git_binary;
#[derive(Debug, Clone)]
pub enum Selection {
Paths(Vec<PathBuf>),
All {
base: PathBuf,
},
}
#[derive(Debug, Clone, Default)]
pub struct RebaseOptions {
pub onto: Option<String>,
pub autostash: bool,
pub dry_run: bool,
pub keep_conflicts: bool,
pub git_bin: Option<PathBuf>,
}
impl RebaseOptions {
fn git_bin(&self) -> PathBuf {
self.git_bin.clone().unwrap_or_else(resolve_git_binary)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Plan {
pub fetches: Vec<FetchOutcome>,
pub worktrees: Vec<WorktreeOutcome>,
}
impl Plan {
#[must_use]
pub fn has_pending_rebases(&self) -> bool {
self.worktrees
.iter()
.any(|w| matches!(w.result, RebaseResult::WouldRebase { .. }))
}
}
#[derive(Debug, Clone, Serialize)]
pub struct FetchOutcome {
pub repo_root: PathBuf,
pub onto: String,
pub fetched: bool,
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WorktreeOutcome {
pub path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
pub onto: String,
#[serde(flatten)]
pub result: RebaseResult,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum RebaseResult {
Rebased {
behind: usize,
},
WouldRebase {
behind: usize,
},
UpToDate,
Skipped {
reason: SkipReason,
},
Conflict {
detail: String,
#[serde(skip_serializing_if = "is_false")]
left_in_place: bool,
},
FetchFailed {
detail: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SkipReason {
MainWorkingTree,
DetachedHead,
Dirty,
OperationInProgress,
NotAWorktree,
NoOntoRef,
}
pub fn plan(selection: &Selection, opts: &RebaseOptions) -> Result<Plan> {
let paths = resolve_selection(selection)?;
let git = opts.git_bin();
let inspected: Vec<Inspected> = paths.iter().map(|p| Inspected::read(p)).collect();
let onto_by_repo = resolve_onto_by_repo(&inspected, opts.onto.as_deref());
let (fetches, fetch_ok) = fetch_all(&git, &onto_by_repo);
let worktrees = inspected
.iter()
.map(|i| i.classify(&onto_by_repo, &fetch_ok, opts.autostash))
.collect();
Ok(Plan { fetches, worktrees })
}
#[must_use]
pub fn execute(plan: Plan, opts: &RebaseOptions) -> Vec<WorktreeOutcome> {
let git = opts.git_bin();
plan.worktrees
.into_iter()
.map(|mut outcome| {
if let RebaseResult::WouldRebase { behind } = outcome.result {
outcome.result = match rebase_worktree(&git, &outcome.path, &outcome.onto, opts) {
Ok(()) => RebaseResult::Rebased { behind },
Err(detail) => RebaseResult::Conflict {
detail,
left_in_place: opts.keep_conflicts,
},
};
}
outcome
})
.collect()
}
fn resolve_selection(selection: &Selection) -> Result<Vec<PathBuf>> {
match selection {
Selection::Paths(paths) => Ok(paths.clone()),
Selection::All { base } => linked_worktree_paths(base),
}
}
fn linked_worktree_paths(base: &Path) -> Result<Vec<PathBuf>> {
let repo = Repository::discover(base)
.with_context(|| format!("not inside a git repository: {}", base.display()))?;
let root = main_root(&repo);
let main_repo = Repository::open(&root)
.with_context(|| format!("cannot open main repository: {}", root.display()))?;
let names = main_repo
.worktrees()
.context("cannot enumerate worktrees")?;
let mut paths = Vec::new();
for name in names.iter().flatten().flatten() {
if let Ok(worktree) = main_repo.find_worktree(name) {
paths.push(worktree.path().to_path_buf());
}
}
Ok(paths)
}
enum Inspected {
Ok(Inspection),
Unresolvable {
path: PathBuf,
},
}
struct Inspection {
path: PathBuf,
repo_root: PathBuf,
branch: Option<String>,
head_oid: Option<Oid>,
is_main: bool,
state_clean: bool,
dirty: bool,
}
impl Inspected {
fn read(path: &Path) -> Self {
let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let Ok(repo) = Repository::discover(&canon) else {
return Self::Unresolvable { path: canon };
};
let is_main = !repo.is_worktree();
let repo_root = main_root(&repo);
let (branch, head_oid) = head_branch(&repo);
let state_clean = repo.state() == RepositoryState::Clean;
let dirty = is_dirty(&repo);
Self::Ok(Inspection {
path: canon,
repo_root,
branch,
head_oid,
is_main,
state_clean,
dirty,
})
}
fn repo_root(&self) -> Option<&Path> {
match self {
Self::Ok(i) => Some(&i.repo_root),
Self::Unresolvable { .. } => None,
}
}
fn classify(
&self,
onto_by_repo: &BTreeMap<PathBuf, OntoSpec>,
fetch_ok: &BTreeMap<PathBuf, bool>,
autostash: bool,
) -> WorktreeOutcome {
let i = match self {
Self::Unresolvable { path } => {
return WorktreeOutcome::skipped(
path.clone(),
None,
String::new(),
SkipReason::NotAWorktree,
);
}
Self::Ok(i) => i,
};
let onto = onto_by_repo.get(&i.repo_root);
let onto_display = onto.map_or_else(String::new, |s| s.display.clone());
let branch = i.branch.clone();
let skip = |reason| {
WorktreeOutcome::skipped(i.path.clone(), branch.clone(), onto_display.clone(), reason)
};
if i.is_main {
return skip(SkipReason::MainWorkingTree);
}
let (Some(head), Some(_)) = (i.head_oid, i.branch.as_ref()) else {
return skip(SkipReason::DetachedHead);
};
if !i.state_clean {
return skip(SkipReason::OperationInProgress);
}
if i.dirty && !autostash {
return skip(SkipReason::Dirty);
}
let Some(onto) = onto else {
return skip(SkipReason::NoOntoRef);
};
if fetch_ok.get(&i.repo_root) == Some(&false) {
let detail = "the repository's fetch failed".to_string();
return WorktreeOutcome {
path: i.path.clone(),
branch,
onto: onto_display,
result: RebaseResult::FetchFailed { detail },
};
}
match behind_count(&i.repo_root, head, &onto.display) {
None => skip(SkipReason::NoOntoRef),
Some(0) => WorktreeOutcome {
path: i.path.clone(),
branch,
onto: onto_display,
result: RebaseResult::UpToDate,
},
Some(behind) => WorktreeOutcome {
path: i.path.clone(),
branch,
onto: onto_display,
result: RebaseResult::WouldRebase { behind },
},
}
}
}
impl WorktreeOutcome {
fn skipped(path: PathBuf, branch: Option<String>, onto: String, reason: SkipReason) -> Self {
Self {
path,
branch,
onto,
result: RebaseResult::Skipped { reason },
}
}
}
fn main_root(repo: &Repository) -> PathBuf {
let commondir = repo.commondir();
let commondir = std::fs::canonicalize(commondir).unwrap_or_else(|_| commondir.to_path_buf());
let parent = commondir.parent().map(Path::to_path_buf);
parent.unwrap_or(commondir)
}
fn head_branch(repo: &Repository) -> (Option<String>, Option<Oid>) {
match repo.head() {
Ok(head) if head.is_branch() => (
head.shorthand().ok().map(ToString::to_string),
head.target(),
),
Ok(head) => (None, head.target()),
Err(_) => (None, None),
}
}
fn is_dirty(repo: &Repository) -> bool {
let mut opts = StatusOptions::new();
opts.include_untracked(false)
.include_ignored(false)
.exclude_submodules(true);
repo.statuses(Some(&mut opts))
.is_ok_and(|statuses| !statuses.is_empty())
}
fn behind_count(repo_root: &Path, head: Oid, onto: &str) -> Option<usize> {
let repo = Repository::open(repo_root).ok()?;
let onto_oid = repo.revparse_single(onto).ok()?.peel_to_commit().ok()?.id();
let (_ahead, behind) = repo.graph_ahead_behind(head, onto_oid).ok()?;
Some(behind)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct OntoSpec {
display: String,
fetch: Option<(String, String)>,
}
fn resolve_onto_by_repo(
inspected: &[Inspected],
override_ref: Option<&str>,
) -> BTreeMap<PathBuf, OntoSpec> {
let mut map: BTreeMap<PathBuf, OntoSpec> = BTreeMap::new();
for root in inspected.iter().filter_map(Inspected::repo_root) {
if map.contains_key(root) {
continue;
}
if let Ok(repo) = Repository::open(root) {
map.insert(root.to_path_buf(), resolve_onto(&repo, override_ref));
}
}
map
}
fn resolve_onto(repo: &Repository, override_ref: Option<&str>) -> OntoSpec {
if let Some(reference) = override_ref {
return onto_from_override(repo, reference);
}
let remote = "origin";
let branch =
RemoteInfo::detect_main_branch_local(repo, remote).unwrap_or_else(|| "main".to_string());
OntoSpec {
display: format!("{remote}/{branch}"),
fetch: Some((remote.to_string(), branch)),
}
}
fn onto_from_override(repo: &Repository, reference: &str) -> OntoSpec {
if let Some((remote, branch)) = reference.split_once('/') {
if repo.find_remote(remote).is_ok() {
return OntoSpec {
display: reference.to_string(),
fetch: Some((remote.to_string(), branch.to_string())),
};
}
}
OntoSpec {
display: reference.to_string(),
fetch: None,
}
}
fn fetch_all(
git: &Path,
onto_by_repo: &BTreeMap<PathBuf, OntoSpec>,
) -> (Vec<FetchOutcome>, BTreeMap<PathBuf, bool>) {
let mut fetches = Vec::new();
let mut fetch_ok = BTreeMap::new();
for (root, spec) in onto_by_repo {
let outcome = match &spec.fetch {
Some((remote, branch)) => {
let result = fetch_once(git, root, remote, branch);
let ok = result.is_ok();
FetchOutcome {
repo_root: root.clone(),
onto: spec.display.clone(),
fetched: true,
ok,
detail: result.err().map(|e| e.to_string()),
}
}
None => FetchOutcome {
repo_root: root.clone(),
onto: spec.display.clone(),
fetched: false,
ok: true,
detail: None,
},
};
fetch_ok.insert(root.clone(), outcome.ok);
fetches.push(outcome);
}
(fetches, fetch_ok)
}
fn fetch_once(git: &Path, repo_root: &Path, remote: &str, branch: &str) -> Result<()> {
let output = run_git_in(git, repo_root, &["fetch", remote, branch])?;
if output.status.success() {
return Ok(());
}
bail!(
"git fetch {remote} {branch} failed: {}",
trimmed_stderr(&output)
)
}
fn rebase_worktree(
git: &Path,
path: &Path,
onto: &str,
opts: &RebaseOptions,
) -> std::result::Result<(), String> {
let args = rebase_args(onto, opts.autostash);
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
match run_git_in(git, path, &argv) {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => {
let detail = trimmed_stderr(&output);
if !opts.keep_conflicts {
let _ = run_git_in(git, path, &["rebase", "--abort"]);
}
Err(detail)
}
Err(err) => Err(err.to_string()),
}
}
fn rebase_args(onto: &str, autostash: bool) -> Vec<String> {
let mut args = vec!["rebase".to_string()];
if autostash {
args.push("--autostash".to_string());
}
args.push(onto.to_string());
args
}
fn run_git_in(git: &Path, dir: &Path, args: &[&str]) -> Result<std::process::Output> {
let mut cmd = Command::new(git);
cmd.env_clear();
cmd.envs(std::env::vars_os());
cmd.current_dir(dir)
.args(args)
.output()
.with_context(|| format!("failed to execute {} in {}", git.display(), dir.display()))
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
!*b
}
fn trimmed_stderr(output: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
let trimmed = stderr.trim();
if trimmed.is_empty() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
trimmed.to_string()
}
}
#[cfg(test)]
pub(crate) fn test_serial_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn serial() -> std::sync::MutexGuard<'static, ()> {
super::test_serial_lock()
}
#[test]
fn rebase_args_omits_autostash_by_default() {
assert_eq!(
rebase_args("origin/main", false),
vec!["rebase", "origin/main"]
);
}
#[test]
fn rebase_args_inserts_autostash_before_the_ref() {
assert_eq!(
rebase_args("origin/main", true),
vec!["rebase", "--autostash", "origin/main"]
);
}
#[test]
fn onto_from_override_fetches_a_remote_tracking_ref() {
let (_dir, repo) = repo_with_origin();
let spec = onto_from_override(&repo, "origin/release");
assert_eq!(spec.display, "origin/release");
assert_eq!(
spec.fetch,
Some(("origin".to_string(), "release".to_string()))
);
}
#[test]
fn onto_from_override_keeps_a_multi_segment_branch_whole() {
let (_dir, repo) = repo_with_origin();
let spec = onto_from_override(&repo, "origin/feature/foo");
assert_eq!(
spec.fetch,
Some(("origin".to_string(), "feature/foo".to_string()))
);
}
#[test]
fn onto_from_override_does_not_fetch_a_local_ref() {
let (_dir, repo) = repo_with_origin();
assert_eq!(onto_from_override(&repo, "develop").fetch, None);
assert_eq!(onto_from_override(&repo, "upstream/x").fetch, None);
assert_eq!(onto_from_override(&repo, "HEAD~2").fetch, None);
}
#[test]
fn resolve_onto_defaults_to_origin_main() {
let (_dir, repo) = repo_with_origin();
let spec = resolve_onto(&repo, None);
assert_eq!(spec.display, "origin/main");
assert_eq!(spec.fetch, Some(("origin".to_string(), "main".to_string())));
}
#[test]
fn one_repo_with_many_worktrees_fetches_exactly_once() {
let _guard = serial();
let scenario = Scenario::new();
scenario.add_worktree("feature-a");
scenario.add_worktree("feature-b");
scenario.add_worktree("feature-c");
let plan = plan(
&Selection::All {
base: scenario.local,
},
&RebaseOptions::default(),
)
.unwrap();
assert_eq!(
plan.fetches.len(),
1,
"fetch must run once per repo, not per worktree"
);
assert_eq!(plan.worktrees.len(), 3);
assert!(plan.fetches[0].ok);
}
#[test]
fn resolve_onto_by_repo_collapses_worktrees_of_one_repo() {
let _guard = serial();
let scenario = Scenario::new();
scenario.add_worktree("feature-a");
scenario.add_worktree("feature-b");
let paths = linked_worktree_paths(&scenario.local).unwrap();
let inspected: Vec<Inspected> = paths.iter().map(|p| Inspected::read(p)).collect();
let map = resolve_onto_by_repo(&inspected, None);
assert_eq!(
map.len(),
1,
"two worktrees of one repo resolve to one onto entry"
);
}
#[test]
fn behind_worktree_is_rebased_onto_the_fetched_ref() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature");
scenario.advance_origin_main("second\n");
let plan = plan(
&Selection::Paths(vec![wt.clone()]),
&RebaseOptions::default(),
)
.unwrap();
assert_eq!(plan.worktrees.len(), 1);
assert_eq!(
plan.worktrees[0].result,
RebaseResult::WouldRebase { behind: 1 },
"the feature worktree is one commit behind the fetched origin/main"
);
let outcomes = execute(plan, &RebaseOptions::default());
assert_eq!(outcomes[0].result, RebaseResult::Rebased { behind: 1 });
assert!(head_contains(&wt, &scenario.origin_main_oid()));
}
#[test]
fn up_to_date_worktree_is_not_rebased() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature");
let plan = plan(&Selection::Paths(vec![wt]), &RebaseOptions::default()).unwrap();
assert_eq!(plan.worktrees[0].result, RebaseResult::UpToDate);
assert!(!plan.has_pending_rebases());
}
#[test]
fn dirty_worktree_is_skipped_but_autostash_rebases_it() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature");
scenario.advance_origin_main("second\n");
std::fs::write(wt.join("keep.txt"), "dirty change\n").unwrap();
let skipped = plan(
&Selection::Paths(vec![wt.clone()]),
&RebaseOptions::default(),
)
.unwrap();
assert_eq!(
skipped.worktrees[0].result,
RebaseResult::Skipped {
reason: SkipReason::Dirty
}
);
let opts = RebaseOptions {
autostash: true,
..RebaseOptions::default()
};
let planned = plan(&Selection::Paths(vec![wt.clone()]), &opts).unwrap();
assert_eq!(
planned.worktrees[0].result,
RebaseResult::WouldRebase { behind: 1 }
);
let outcomes = execute(planned, &opts);
assert_eq!(outcomes[0].result, RebaseResult::Rebased { behind: 1 });
assert_eq!(
std::fs::read_to_string(wt.join("keep.txt")).unwrap(),
"dirty change\n"
);
}
#[test]
fn main_working_tree_is_skipped() {
let _guard = serial();
let scenario = Scenario::new();
let plan = plan(
&Selection::Paths(vec![scenario.local]),
&RebaseOptions::default(),
)
.unwrap();
assert_eq!(
plan.worktrees[0].result,
RebaseResult::Skipped {
reason: SkipReason::MainWorkingTree
}
);
}
#[test]
fn non_worktree_path_is_skipped_not_fatal() {
let dir = tempfile::tempdir().unwrap();
let plan = plan(
&Selection::Paths(vec![dir.path().to_path_buf()]),
&RebaseOptions::default(),
)
.unwrap();
assert_eq!(
plan.worktrees[0].result,
RebaseResult::Skipped {
reason: SkipReason::NotAWorktree
}
);
}
#[test]
fn conflicting_rebase_aborts_and_leaves_the_worktree_untouched() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature");
scenario.commit_in_worktree(&wt, "file.txt", "feature side\n", "feature edit");
scenario.advance_origin_main("main side\n");
let head_before = head_oid(&wt);
let plan = plan(
&Selection::Paths(vec![wt.clone()]),
&RebaseOptions::default(),
)
.unwrap();
assert!(matches!(
plan.worktrees[0].result,
RebaseResult::WouldRebase { .. }
));
let outcomes = execute(plan, &RebaseOptions::default());
assert!(
matches!(
outcomes[0].result,
RebaseResult::Conflict {
left_in_place: false,
..
}
),
"a conflicting rebase is reported, not silently half-applied"
);
assert_eq!(head_oid(&wt), head_before);
let repo = Repository::open(&wt).unwrap();
assert_eq!(repo.state(), RepositoryState::Clean);
}
#[test]
fn keep_conflicts_leaves_the_worktree_mid_rebase() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature");
scenario.commit_in_worktree(&wt, "file.txt", "feature side\n", "feature edit");
scenario.advance_origin_main("main side\n");
let opts = RebaseOptions {
keep_conflicts: true,
..RebaseOptions::default()
};
let plan = plan(&Selection::Paths(vec![wt.clone()]), &opts).unwrap();
let outcomes = execute(plan, &opts);
assert!(
matches!(
outcomes[0].result,
RebaseResult::Conflict {
left_in_place: true,
..
}
),
"the outcome records that the worktree was left mid-rebase"
);
let repo = Repository::open(&wt).unwrap();
assert_ne!(
repo.state(),
RepositoryState::Clean,
"the worktree must still be mid-rebase, not aborted back to clean"
);
let conflicted = std::fs::read_to_string(wt.join("file.txt")).unwrap();
assert!(
conflicted.contains("<<<<<<<"),
"expected conflict markers, got: {conflicted}"
);
}
#[test]
fn a_kept_conflict_does_not_stop_the_rest_of_the_batch() {
let _guard = serial();
let scenario = Scenario::new();
let clashing = scenario.add_worktree("clashing");
let clean = scenario.add_worktree("clean");
scenario.commit_in_worktree(&clashing, "file.txt", "feature side\n", "feature edit");
scenario.advance_origin_main("main side\n");
let opts = RebaseOptions {
keep_conflicts: true,
..RebaseOptions::default()
};
let plan = plan(&Selection::Paths(vec![clashing, clean.clone()]), &opts).unwrap();
let outcomes = execute(plan, &opts);
assert!(matches!(
outcomes[0].result,
RebaseResult::Conflict {
left_in_place: true,
..
}
));
assert_eq!(
outcomes[1].result,
RebaseResult::Rebased { behind: 1 },
"the second worktree rebases despite the first being left conflicted"
);
assert!(head_contains(&clean, &scenario.origin_main_oid()));
}
#[test]
fn left_in_place_is_omitted_from_json_when_false() {
let aborted = serde_json::to_value(RebaseResult::Conflict {
detail: "boom".to_string(),
left_in_place: false,
})
.unwrap();
assert_eq!(aborted["status"], "conflict");
assert!(aborted.get("left_in_place").is_none());
let kept = serde_json::to_value(RebaseResult::Conflict {
detail: "boom".to_string(),
left_in_place: true,
})
.unwrap();
assert_eq!(kept["left_in_place"], true);
}
#[test]
fn git_bin_defaults_to_the_resolver_and_honours_an_override() {
assert_eq!(
RebaseOptions::default().git_bin(),
crate::git::resolve_git_binary(),
"an unset git_bin falls back to the shared resolver"
);
let opts = RebaseOptions {
git_bin: Some(PathBuf::from("/custom/git")),
..RebaseOptions::default()
};
assert_eq!(opts.git_bin(), PathBuf::from("/custom/git"));
}
#[test]
fn dry_run_fetches_but_rebases_nothing() {
let _guard = serial();
let scenario = Scenario::new();
let wt = scenario.add_worktree("feature");
scenario.advance_origin_main("second\n");
let head_before = head_oid(&wt);
let opts = RebaseOptions {
dry_run: true,
..RebaseOptions::default()
};
let plan = plan(&Selection::Paths(vec![wt.clone()]), &opts).unwrap();
assert_eq!(
plan.worktrees[0].result,
RebaseResult::WouldRebase { behind: 1 }
);
assert_eq!(plan.fetches.len(), 1);
assert!(plan.fetches[0].fetched && plan.fetches[0].ok);
assert_eq!(
head_oid(&wt),
head_before,
"dry run must not move the branch"
);
}
#[test]
fn json_shape_is_kebab_tagged() {
let outcome = WorktreeOutcome {
path: PathBuf::from("/wt"),
branch: Some("feature".to_string()),
onto: "origin/main".to_string(),
result: RebaseResult::Skipped {
reason: SkipReason::Dirty,
},
};
let value = serde_json::to_value(&outcome).unwrap();
assert_eq!(value["status"], "skipped");
assert_eq!(value["reason"], "dirty");
assert_eq!(value["onto"], "origin/main");
}
fn inspected(
branch: Option<&str>,
head: Option<Oid>,
is_main: bool,
state_clean: bool,
dirty: bool,
) -> Inspected {
Inspected::Ok(Inspection {
path: PathBuf::from("/wt"),
repo_root: PathBuf::from("/repo"),
branch: branch.map(str::to_string),
head_oid: head,
is_main,
state_clean,
dirty,
})
}
fn onto_map() -> BTreeMap<PathBuf, OntoSpec> {
let mut map = BTreeMap::new();
map.insert(
PathBuf::from("/repo"),
OntoSpec {
display: "origin/main".to_string(),
fetch: Some(("origin".to_string(), "main".to_string())),
},
);
map
}
fn ok_map(ok: bool) -> BTreeMap<PathBuf, bool> {
let mut map = BTreeMap::new();
map.insert(PathBuf::from("/repo"), ok);
map
}
fn classify_reason(
inspected: &Inspected,
onto: &BTreeMap<PathBuf, OntoSpec>,
autostash: bool,
) -> RebaseResult {
inspected.classify(onto, &ok_map(true), autostash).result
}
#[test]
fn classify_skips_the_main_working_tree() {
let out = classify_reason(
&inspected(Some("main"), Some(Oid::ZERO_SHA1), true, true, false),
&onto_map(),
false,
);
assert_eq!(
out,
RebaseResult::Skipped {
reason: SkipReason::MainWorkingTree
}
);
}
#[test]
fn classify_skips_a_detached_head() {
let out = classify_reason(
&inspected(None, Some(Oid::ZERO_SHA1), false, true, false),
&onto_map(),
false,
);
assert_eq!(
out,
RebaseResult::Skipped {
reason: SkipReason::DetachedHead
}
);
}
#[test]
fn classify_skips_an_in_progress_operation() {
let out = classify_reason(
&inspected(Some("f"), Some(Oid::ZERO_SHA1), false, false, false),
&onto_map(),
false,
);
assert_eq!(
out,
RebaseResult::Skipped {
reason: SkipReason::OperationInProgress
}
);
}
#[test]
fn classify_skips_dirty_only_without_autostash() {
let dirty = inspected(Some("f"), Some(Oid::ZERO_SHA1), false, true, true);
assert_eq!(
classify_reason(&dirty, &onto_map(), false),
RebaseResult::Skipped {
reason: SkipReason::Dirty
}
);
assert_eq!(
classify_reason(&dirty, &onto_map(), true),
RebaseResult::Skipped {
reason: SkipReason::NoOntoRef
}
);
}
#[test]
fn classify_reports_no_onto_ref_when_the_repo_is_unresolved() {
let out = classify_reason(
&inspected(Some("f"), Some(Oid::ZERO_SHA1), false, true, false),
&BTreeMap::new(),
false,
);
assert_eq!(
out,
RebaseResult::Skipped {
reason: SkipReason::NoOntoRef
}
);
}
#[test]
fn classify_reports_fetch_failed_when_the_repos_fetch_failed() {
let out = inspected(Some("f"), Some(Oid::ZERO_SHA1), false, true, false)
.classify(&onto_map(), &ok_map(false), false)
.result;
assert!(matches!(out, RebaseResult::FetchFailed { .. }));
}
#[test]
fn classify_reports_not_a_worktree_for_an_unresolvable_path() {
let out = Inspected::Unresolvable {
path: PathBuf::from("/x"),
}
.classify(&onto_map(), &ok_map(true), false)
.result;
assert_eq!(
out,
RebaseResult::Skipped {
reason: SkipReason::NotAWorktree
}
);
}
#[test]
fn head_branch_reports_branch_detached_and_unborn() {
let dir = tempfile::tempdir().unwrap();
let repo = Repository::init(dir.path()).unwrap();
config_identity(&repo);
assert_eq!(head_branch(&repo), (None, None));
let oid = empty_commit(&repo, "refs/heads/main", &[]);
repo.set_head("refs/heads/main").unwrap();
let (branch, head) = head_branch(&repo);
assert_eq!(branch.as_deref(), Some("main"));
assert_eq!(head, Some(oid));
repo.set_head_detached(oid).unwrap();
assert_eq!(head_branch(&repo), (None, Some(oid)));
}
#[test]
fn resolve_onto_honours_an_override() {
let (_dir, repo) = repo_with_origin();
assert_eq!(
resolve_onto(&repo, Some("origin/main")).fetch,
Some(("origin".to_string(), "main".to_string()))
);
assert_eq!(resolve_onto(&repo, Some("develop")).fetch, None);
}
#[test]
fn fetch_all_skips_the_fetch_for_a_local_onto() {
let mut map = BTreeMap::new();
map.insert(
PathBuf::from("/repo"),
OntoSpec {
display: "HEAD~1".to_string(),
fetch: None,
},
);
let (fetches, ok) = fetch_all(Path::new("git"), &map);
assert_eq!(fetches.len(), 1);
assert!(!fetches[0].fetched && fetches[0].ok);
assert_eq!(ok.get(Path::new("/repo")), Some(&true));
}
#[test]
fn fetch_once_errors_when_the_remote_is_missing() {
let _guard = serial();
let dir = tempfile::tempdir().unwrap();
let repo = Repository::init(dir.path()).unwrap();
config_identity(&repo);
let err = fetch_once(&resolve_git_binary(), dir.path(), "origin", "main")
.unwrap_err()
.to_string();
assert!(err.contains("git fetch"), "got: {err}");
}
fn repo_with_origin() -> (tempfile::TempDir, Repository) {
let dir = tempfile::tempdir().unwrap();
let repo = Repository::init(dir.path()).unwrap();
config_identity(&repo);
repo.remote("origin", "https://example.invalid/x.git")
.unwrap();
let oid = empty_commit(&repo, "refs/heads/main", &[]);
repo.reference("refs/remotes/origin/main", oid, true, "seed")
.unwrap();
(dir, repo)
}
struct Scenario {
root: tempfile::TempDir,
origin: PathBuf,
local: PathBuf,
}
impl Scenario {
fn new() -> Self {
let root = tempfile::tempdir().unwrap();
let origin = root.path().join("origin.git");
let local = root.path().join("local");
std::fs::create_dir_all(&origin).unwrap();
std::fs::create_dir_all(&local).unwrap();
git(&origin, &["init", "--bare", "-b", "main"]);
git(&local, &["init", "-b", "main"]);
config_repo(&local, "Test", "test@example.com");
std::fs::write(local.join("file.txt"), "first\n").unwrap();
std::fs::write(local.join("keep.txt"), "keep\n").unwrap();
git(&local, &["add", "file.txt", "keep.txt"]);
git(&local, &["commit", "-m", "first"]);
git(
&local,
&["remote", "add", "origin", origin.to_str().unwrap()],
);
git(&local, &["push", "-u", "origin", "main"]);
Self {
root,
origin,
local,
}
}
fn add_worktree(&self, name: &str) -> PathBuf {
let path = self.root.path().join(name);
git(
&self.local,
&[
"worktree",
"add",
"-b",
name,
path.to_str().unwrap(),
"main",
],
);
path
}
fn advance_origin_main(&self, content: &str) {
let repo = Repository::open_bare(&self.origin).unwrap();
let parent = repo
.find_commit(repo.refname_to_id("refs/heads/main").unwrap())
.unwrap();
let mut builder = repo.treebuilder(Some(&parent.tree().unwrap())).unwrap();
let blob = repo.blob(content.as_bytes()).unwrap();
builder.insert("file.txt", blob, 0o100_644).unwrap();
let tree = repo.find_tree(builder.write().unwrap()).unwrap();
let sig = git2::Signature::now("Other", "other@example.com").unwrap();
repo.commit(
Some("refs/heads/main"),
&sig,
&sig,
"advance",
&tree,
&[&parent],
)
.unwrap();
}
fn commit_in_worktree(&self, wt: &Path, file: &str, content: &str, msg: &str) {
std::fs::write(wt.join(file), content).unwrap();
git(wt, &["add", file]);
git(wt, &["commit", "-m", msg]);
}
fn origin_main_oid(&self) -> Oid {
let repo = Repository::open_bare(&self.origin).unwrap();
repo.refname_to_id("refs/heads/main").unwrap()
}
}
fn config_repo(dir: &Path, name: &str, email: &str) {
git(dir, &["config", "user.name", name]);
git(dir, &["config", "user.email", email]);
git(dir, &["config", "commit.gpgsign", "false"]);
}
fn git(dir: &Path, args: &[&str]) {
let output = run_git_in(&resolve_git_binary(), dir, args).unwrap();
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn config_identity(repo: &Repository) {
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "Test").unwrap();
cfg.set_str("user.email", "test@example.com").unwrap();
}
fn empty_commit(repo: &Repository, refname: &str, parents: &[&git2::Commit<'_>]) -> Oid {
let sig = git2::Signature::now("Test", "test@example.com").unwrap();
let tree = repo
.find_tree(repo.treebuilder(None).unwrap().write().unwrap())
.unwrap();
repo.commit(Some(refname), &sig, &sig, "seed", &tree, parents)
.unwrap()
}
fn head_oid(wt: &Path) -> Oid {
let repo = Repository::open(wt).unwrap();
let head = repo.head().unwrap();
head.target().unwrap()
}
fn head_contains(wt: &Path, oid: &Oid) -> bool {
let repo = Repository::open(wt).unwrap();
let head = repo.head().unwrap().target().unwrap();
repo.graph_descendant_of(head, *oid).unwrap_or(false) || head == *oid
}
}