use loopflow::ops::{
plan_rebase, rebase_with_recovery, NullProgress, OpsError, RebaseClass, RebaseOptions,
RebaseStrategy,
};
use loopflow_test_support::TestRepo;
use std::process::Command;
fn git(repo: &std::path::Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[test]
fn rebase_onto_main_succeeds() {
let repo = TestRepo::new();
repo.create_branch("feature");
repo.create_file("feature.txt", "feature");
repo.stage_all();
repo.commit("feature work");
repo.checkout("main");
repo.create_file("main.txt", "main");
repo.stage_all();
repo.commit("main work");
repo.push();
repo.checkout("feature");
rebase_with_recovery(
repo.path(),
&RebaseOptions {
onto: "origin/main".to_string(),
push: false,
},
&NullProgress,
)
.expect("rebase");
}
#[test]
fn rebase_conflict_returns_error() {
let repo = TestRepo::new();
repo.create_branch("feature");
repo.create_file("conflict.txt", "feature line\n");
repo.stage_all();
repo.commit("feature work");
repo.checkout("main");
repo.create_file("conflict.txt", "main line\n");
repo.stage_all();
repo.commit("main work");
repo.push();
repo.checkout("feature");
let result = rebase_with_recovery(
repo.path(),
&RebaseOptions {
onto: "origin/main".to_string(),
push: false,
},
&NullProgress,
);
assert!(
matches!(result, Err(OpsError::RebaseConflict { ref onto, .. }) if onto == "origin/main"),
"expected rebase conflict error, got: {:?}",
result
);
}
#[test]
fn rebase_with_push() {
let repo = TestRepo::new();
repo.create_branch("feature");
repo.push_new_branch("feature");
repo.create_file("feature.txt", "feature");
repo.stage_all();
repo.commit("feature work");
repo.checkout("main");
repo.create_file("main.txt", "main");
repo.stage_all();
repo.commit("main work");
repo.push();
repo.checkout("feature");
rebase_with_recovery(
repo.path(),
&RebaseOptions {
onto: "origin/main".to_string(),
push: true,
},
&NullProgress,
)
.expect("rebase");
}
#[test]
fn rebase_stacked_branch_after_squash_merge() {
let repo = TestRepo::new();
repo.create_branch("branch-a");
repo.create_file("a1.txt", "a1");
repo.stage_all();
repo.commit("a1");
repo.create_file("a2.txt", "a2");
repo.stage_all();
repo.commit("a2");
repo.create_branch("branch-b");
repo.create_file("b1.txt", "b1");
repo.stage_all();
repo.commit("b1");
let b_head = repo.head_sha();
repo.checkout("main");
git(repo.path(), &["merge", "--squash", "branch-a"]);
git(repo.path(), &["commit", "-m", "squash merge branch-a"]);
repo.push();
repo.checkout("branch-b");
rebase_with_recovery(
repo.path(),
&RebaseOptions {
onto: "origin/main".to_string(),
push: false,
},
&NullProgress,
)
.expect("rebase of stacked branch after squash-merge should succeed");
assert!(
repo.path().join("b1.txt").exists(),
"b1.txt should exist after rebase"
);
assert!(
repo.path().join("a1.txt").exists(),
"a1.txt should exist from squash merge"
);
assert_ne!(repo.head_sha(), b_head, "HEAD should differ after rebase");
}
#[test]
fn plan_rebase_classifies_dirty_scratch_only_branch_as_reset() {
let repo = TestRepo::new();
repo.create_branch("feature");
repo.create_file("scratch/design.md", "notes");
let plan = plan_rebase(repo.path(), None).expect("plan rebase");
assert_eq!(plan.class, RebaseClass::ScratchOnly);
assert_eq!(plan.strategy, RebaseStrategy::ResetToBase);
assert_eq!(plan.unique_commits, 0);
assert!(!plan.protected);
assert!(plan
.changed_files
.iter()
.all(|path| path.starts_with("scratch")));
}
#[test]
fn plan_rebase_handles_modified_scratch_file_with_leading_status() {
let repo = TestRepo::new();
repo.create_file("scratch/notes.md", "original\n");
repo.stage_all();
repo.commit("add scratch notes");
repo.push();
repo.create_branch("feature");
repo.create_file("scratch/notes.md", "evolved working notes\n");
let plan = plan_rebase(repo.path(), None).expect("plan rebase");
assert_eq!(plan.class, RebaseClass::ScratchOnly);
assert_eq!(plan.strategy, RebaseStrategy::ResetToBase);
assert!(
plan.changed_files
.iter()
.all(|path| path.starts_with("scratch")),
"changed_files should all be under scratch/, got: {:?}",
plan.changed_files
);
}
#[test]
fn plan_rebase_ignores_upstream_changes_when_branch_is_only_behind() {
let repo = TestRepo::new();
repo.create_branch("feature");
repo.checkout("main");
repo.create_file("main.txt", "main");
repo.stage_all();
repo.commit("main change");
repo.checkout("feature");
repo.create_file("scratch/design.md", "notes");
let plan = plan_rebase(repo.path(), Some("main")).expect("plan rebase");
assert_eq!(plan.class, RebaseClass::ScratchOnly);
assert_eq!(plan.strategy, RebaseStrategy::ResetToBase);
assert!(plan
.changed_files
.iter()
.all(|path| path.starts_with("scratch")));
}
#[test]
fn plan_rebase_classifies_wave_changes_as_protected() {
let repo = TestRepo::new();
repo.create_branch("feature");
repo.create_file("wave/goals/MEMORY.md", "state");
repo.stage_all();
repo.commit("update wave memory");
let plan = plan_rebase(repo.path(), None).expect("plan rebase");
assert_eq!(plan.class, RebaseClass::Protected);
assert_eq!(plan.strategy, RebaseStrategy::DirectRebase);
assert!(plan.protected);
}
#[test]
fn plan_rebase_uses_open_stack_parent() {
let repo = TestRepo::new();
repo.create_branch("a");
repo.create_file("a.txt", "a");
repo.stage_all();
repo.commit("a");
repo.create_branch("a.b");
let plan = plan_rebase(repo.path(), None).expect("plan rebase");
assert_eq!(plan.stack_parent.as_deref(), Some("a"));
assert_eq!(plan.base_ref, "a");
assert_eq!(plan.class, RebaseClass::StackParentOpen);
assert_eq!(plan.strategy, RebaseStrategy::RebaseOntoParent);
}