diff --git a/src/commands/commit.rs b/src/commands/commit.rs index f619dc5..1f931f7 100644 --- a/src/commands/commit.rs +++ b/src/commands/commit.rs @@ -1,7 +1,7 @@ use crate::commands::{find_upstream, resolve_rebase_autostash}; use crate::rebase_utils::{ RebaseState, apply_stash, check_worktrees, checkout_branch, clear_state, drop_stash, - git_rebase_in_progress, run_rebase_loop, save_state, state_path, + git_rebase_in_progress, passively_reconcile_rebase_state, run_rebase_loop, save_state, }; use crate::stack::{ StackBranch, StackCommit, collect_descendants, enumerate_stack_commits, @@ -17,8 +17,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub fn commit(args: &[String]) -> Result<()> { let repo = crate::open_repo()?; - let path = state_path(&repo); - if path.exists() { + if passively_reconcile_rebase_state(&repo)? { return Err(anyhow!( "A Kindra operation is already in progress. Use 'kin continue' or 'kin abort'." )); @@ -254,8 +253,22 @@ pub fn commit(args: &[String]) -> Result<()> { let status = cmd.status()?; if !status.success() { - if !pre_commit_state_required && git_rebase_in_progress(&repo) { - save_state(&repo, &state)?; + if !pre_commit_state_required { + if git_rebase_in_progress(&repo) { + state.in_progress_branch = Some(target_branch.clone()); + save_state(&repo, &state)?; + } else if autosquash_state_required + && let Some(stash_ref) = state.stash_ref.clone() + { + state.stash_ref = None; + state.in_progress_branch = None; + save_state(&repo, &state)?; + apply_stash(&stash_ref)?; + if let Err(err) = drop_stash(&stash_ref) { + eprintln!("Warning: {}", err); + } + save_state(&repo, &state)?; + } } return Err(anyhow!( "git rebase --autosquash failed. Resolve conflicts and run 'kin continue', or run 'kin abort'." @@ -264,12 +277,14 @@ pub fn commit(args: &[String]) -> Result<()> { if autosquash_state_required { if let Some(stash_ref) = state.stash_ref.clone() { - apply_stash(&stash_ref)?; state.stash_ref = None; + state.in_progress_branch = None; save_state(&repo, &state)?; + apply_stash(&stash_ref)?; if let Err(err) = drop_stash(&stash_ref) { eprintln!("Warning: {}", err); } + save_state(&repo, &state)?; } clear_state(&repo)?; } diff --git a/src/commands/continue_cmd.rs b/src/commands/continue_cmd.rs index f424584..35e006e 100644 --- a/src/commands/continue_cmd.rs +++ b/src/commands/continue_cmd.rs @@ -1,12 +1,13 @@ use crate::rebase_utils::{ - Operation, git_rebase_in_progress, load_state, run_rebase_loop, state_path, + Operation, ReconcileMode, git_rebase_in_progress, reconcile_saved_rebase_state, run_rebase_loop, }; use anyhow::{Result, anyhow}; use std::process::Command; pub fn continue_cmd() -> Result<()> { let repo = crate::open_repo()?; - let has_rebase_state = state_path(&repo).exists(); + let rebase_state = reconcile_saved_rebase_state(&repo, ReconcileMode::Continue)?; + let has_rebase_state = rebase_state.is_some(); let has_run_state = crate::commands::run::run_state_exists(&repo); if has_rebase_state && has_run_state { @@ -40,8 +41,7 @@ pub fn continue_cmd() -> Result<()> { } } - if has_rebase_state { - let state = load_state(&repo)?; + if let Some(state) = rebase_state { return match state.operation { Operation::Sync => crate::commands::sync::finish_sync_after_rebase(&repo, state), _ => run_rebase_loop(&repo, state), diff --git a/src/commands/move_cmd.rs b/src/commands/move_cmd.rs index 7caf057..26266df 100644 --- a/src/commands/move_cmd.rs +++ b/src/commands/move_cmd.rs @@ -1,5 +1,7 @@ use crate::commands::{find_upstream, resolve_rebase_autostash}; -use crate::rebase_utils::{Operation, RebaseState, run_rebase_loop, save_state, state_path}; +use crate::rebase_utils::{ + Operation, RebaseState, passively_reconcile_rebase_state, run_rebase_loop, save_state, +}; use crate::stack::{ collect_descendants, get_stack_branches_from_merge_base, plan_descendant_reorder, visualize_stack, @@ -34,8 +36,7 @@ pub fn move_cmd(args: &MoveArgs) -> Result<()> { } fn start_move(repo: &Repository, args: &MoveArgs) -> Result<()> { - let path = state_path(repo); - if path.exists() { + if passively_reconcile_rebase_state(repo)? { return Err(anyhow!( "A Kindra operation is already in progress. Use 'kin continue' or 'kin abort'." )); diff --git a/src/commands/reorder.rs b/src/commands/reorder.rs index b78efa4..99b2a28 100644 --- a/src/commands/reorder.rs +++ b/src/commands/reorder.rs @@ -1,5 +1,7 @@ use crate::commands::{find_upstream, resolve_rebase_autostash}; -use crate::rebase_utils::{Operation, RebaseState, run_rebase_loop, save_state, state_path}; +use crate::rebase_utils::{ + Operation, RebaseState, passively_reconcile_rebase_state, run_rebase_loop, save_state, +}; use anyhow::{Result, anyhow}; use clap::Args; use std::collections::{HashMap, HashSet}; @@ -21,7 +23,7 @@ pub struct ReorderArgs { pub fn reorder(args: &ReorderArgs) -> Result<()> { let repo = crate::open_repo()?; - if state_path(&repo).exists() { + if passively_reconcile_rebase_state(&repo)? { return Err(anyhow!( "A Kindra operation is already in progress. Use 'kin continue' or 'kin abort'." )); diff --git a/src/commands/restack.rs b/src/commands/restack.rs index f000e5f..f40eb7d 100644 --- a/src/commands/restack.rs +++ b/src/commands/restack.rs @@ -1,7 +1,9 @@ use crate::commands::{ prompt_multi_select, resolve_rebase_autostash, resolve_restack_history_limit, }; -use crate::rebase_utils::{Operation, RebaseState, run_rebase_loop, state_path}; +use crate::rebase_utils::{ + Operation, RebaseState, passively_reconcile_rebase_state, run_rebase_loop, +}; use anyhow::{Result, anyhow}; use clap::Args; use git2::{BranchType, Commit, Oid, Repository}; @@ -27,8 +29,10 @@ pub struct RestackArgs { pub fn restack(args: &RestackArgs) -> Result<()> { let repo = crate::open_repo()?; - if state_path(&repo).exists() { - return Err(anyhow!("A rebase operation is already in progress.")); + if passively_reconcile_rebase_state(&repo)? { + return Err(anyhow!( + "A Kindra-managed operation is already in progress. Use 'kin continue' or 'kin abort'." + )); } let head = repo.head()?; diff --git a/src/commands/run.rs b/src/commands/run.rs index 3ee464e..66f290b 100644 --- a/src/commands/run.rs +++ b/src/commands/run.rs @@ -1,5 +1,5 @@ use crate::commands::find_upstream; -use crate::rebase_utils::state_path; +use crate::rebase_utils::passively_reconcile_rebase_state; use crate::stack::{ get_stack_branches_from_merge_base, resolve_merge_base, sort_branches_topologically, }; @@ -47,7 +47,7 @@ pub(crate) struct RunState { pub fn run(args: &RunArgs) -> Result<()> { let repo = crate::open_repo()?; - if state_path(&repo).exists() || run_state_exists(&repo) { + if passively_reconcile_rebase_state(&repo)? || run_state_exists(&repo) { return Err(anyhow!( "A Kindra operation is already in progress. Use 'kin continue' or 'kin abort'." )); diff --git a/src/commands/status_cmd.rs b/src/commands/status_cmd.rs index 9f02380..2fb5f2c 100644 --- a/src/commands/status_cmd.rs +++ b/src/commands/status_cmd.rs @@ -1,4 +1,4 @@ -use crate::rebase_utils::{Operation, load_state}; +use crate::rebase_utils::{Operation, ReconcileMode, reconcile_saved_rebase_state}; use anyhow::Result; pub fn status_cmd() -> Result<()> { @@ -29,9 +29,9 @@ pub fn status_cmd() -> Result<()> { return Ok(()); } - let state = match load_state(&repo) { - Ok(state) => state, - Err(_) => { + let state = match reconcile_saved_rebase_state(&repo, ReconcileMode::Passive)? { + Some(state) => state, + None => { println!("No Kindra operation active."); return Ok(()); } diff --git a/src/commands/sync.rs b/src/commands/sync.rs index 76cd384..1166e1c 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -1,8 +1,8 @@ use crate::commands::find_upstream; use crate::commands::resolve_rebase_autostash; use crate::rebase_utils::{ - Operation, RebaseState, checkout_branch, clear_state, git_rebase_in_progress, save_state, - state_path, + Operation, RebaseState, checkout_branch, clear_state, git_rebase_in_progress, + passively_reconcile_rebase_state, save_state, }; use crate::stack::{ collect_merged_local_branches, find_sync_boundary, get_stack_branches_from_merge_base, @@ -37,8 +37,7 @@ pub struct SyncArgs { pub fn sync(args: &SyncArgs) -> Result<()> { let repo = crate::open_repo()?; - let path = state_path(&repo); - if path.exists() { + if passively_reconcile_rebase_state(&repo)? { return Err(anyhow!( "A Kindra operation is already in progress. Use 'kin continue' or 'kin abort'." )); diff --git a/src/rebase_utils.rs b/src/rebase_utils.rs index 0a57119..dc5fb7a 100644 --- a/src/rebase_utils.rs +++ b/src/rebase_utils.rs @@ -65,8 +65,14 @@ pub struct RebaseState { pub cleanup_checkout_fallback: Option, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReconcileMode { + Continue, + Passive, +} + pub fn state_path(repo: &Repository) -> PathBuf { - repo.path().join("gits_rebase_state.json") + repo.path().join("kindra_rebase_state.json") } pub fn save_state(repo: &Repository, state: &RebaseState) -> Result<()> { @@ -111,6 +117,81 @@ pub fn clear_state(repo: &Repository) -> Result<()> { Ok(()) } +pub fn reconcile_saved_rebase_state( + repo: &Repository, + mode: ReconcileMode, +) -> Result> { + if !state_path(repo).exists() { + return Ok(None); + } + + let mut state = load_state(repo)?; + if git_rebase_in_progress(repo) { + if !active_git_rebase_matches_state(repo, &state)? { + return Err(anyhow!( + "Active git rebase does not match saved Kindra rebase state. Resolve or abort the active git rebase before continuing." + )); + } + return Ok(Some(state)); + } + + let mut changed = false; + if state.operation == Operation::Sync { + if sync_rebase_completed(repo, &state)? { + state.remaining_branches.clear(); + state.in_progress_branch = None; + changed = true; + } + } else { + while let Some(current_name) = state.remaining_branches.first().cloned() { + if !branch_rebase_completed(repo, &state, ¤t_name)? { + break; + } + + if mode == ReconcileMode::Continue { + println!("Branch {} already rebased.", current_name); + } + state.remaining_branches.remove(0); + if state.in_progress_branch.as_ref() == Some(¤t_name) { + state.in_progress_branch = None; + } + changed = true; + } + } + + if state.remaining_branches.is_empty() + && state.in_progress_branch.is_none() + && mode == ReconcileMode::Passive + && can_passively_clear_completed_state(repo, &state)? + { + clear_state(repo)?; + return Ok(None); + } + + if changed { + save_state(repo, &state)?; + } + + Ok(Some(state)) +} + +pub fn passively_reconcile_rebase_state(repo: &Repository) -> Result { + if !state_path(repo).exists() { + return Ok(false); + } + + match reconcile_saved_rebase_state(repo, ReconcileMode::Passive) { + Ok(state) => Ok(state.is_some()), + Err(err) => { + eprintln!( + "Warning: failed to reconcile saved Kindra rebase state; treating it as active: {}", + err + ); + Ok(true) + } + } +} + /// `owned_tip_state_matches` treats an empty `state.owned_tip_map` as a deliberate /// "no tracked branches" sentinel and also as the migration fallback for legacy /// on-disk state loaded via `#[serde(default)]`. That means `abort` will skip @@ -257,6 +338,140 @@ fn collect_rebased_commit_set(repo: &Repository, state: &RebaseState) -> HashSet rebased_commits } +fn branch_rebase_target(state: &RebaseState, branch_name: &str) -> Result<(String, String)> { + let old_parent_id_str = state + .parent_id_map + .get(branch_name) + .ok_or_else(|| anyhow!("Parent ID not found for branch '{}'", branch_name))? + .clone(); + + let new_base = if let Some(explicit_base) = state.new_base_map.get(branch_name) { + explicit_base.clone() + } else if branch_name == state.original_branch { + state.target_branch.clone() + } else { + match state.parent_name_map.get(branch_name) { + Some(name) => name.clone(), + None => old_parent_id_str.clone(), + } + }; + + Ok((old_parent_id_str, new_base)) +} + +/// Checks rebase completion in three stages that each cover a different edge +/// case. First, `branch_rebase_target` identifies the expected base and the +/// branch tip must be a descendant of it, or equal to it, which handles branches +/// whose commits were fully replayed or intentionally emptied. Second, the +/// first-parent chain length is compared against `original_commit_count_map` so +/// a branch with hidden extra commits past the expected replay is not accepted +/// as complete. Finally, the revwalk from the current tip back to `new_base_id` +/// verifies that the first replayed commit's first parent is exactly the new +/// base, protecting against histories that contain the base but are attached +/// through an unexpected first-parent path. +fn branch_rebase_completed( + repo: &Repository, + state: &RebaseState, + branch_name: &str, +) -> Result { + let (_, new_base) = branch_rebase_target(state, branch_name)?; + let current_id = repo.revparse_single(branch_name)?.id(); + let new_base_id = repo.revparse_single(&new_base)?.id(); + let mut is_done = + repo.graph_descendant_of(current_id, new_base_id)? || current_id == new_base_id; + + if is_done + && current_id != new_base_id + && let Some(original_commit_count) = state.original_commit_count_map.get(branch_name) + { + let current_first_parent_chain = collect_first_parent_chain(repo, new_base_id, current_id)?; + if current_first_parent_chain.len() > *original_commit_count { + is_done = false; + } + } + + if is_done && current_id != new_base_id { + let mut walk = repo.revwalk()?; + walk.push(current_id)?; + walk.hide(new_base_id)?; + let mut commits: Vec = walk.filter_map(|id| id.ok()).collect(); + commits.reverse(); + + if let Some(&first_id) = commits.first() { + let first_commit = repo.find_commit(first_id)?; + if first_commit.parent_count() > 0 && first_commit.parent_id(0)? != new_base_id { + is_done = false; + } + } + } + + Ok(is_done) +} + +fn active_git_rebase_matches_state(repo: &Repository, state: &RebaseState) -> Result { + if let Some(active_branch) = active_git_rebase_branch(repo)? { + return Ok(state.in_progress_branch.as_deref() == Some(active_branch.as_str())); + } + + owned_tip_state_matches(repo, state) +} + +fn active_git_rebase_branch(repo: &Repository) -> Result> { + for rebase_dir in ["rebase-merge", "rebase-apply"] { + let head_name_path = repo.path().join(rebase_dir).join("head-name"); + if !head_name_path.exists() { + continue; + } + + let head_name = fs::read_to_string(head_name_path)?; + let branch_name = head_name + .trim() + .strip_prefix("refs/heads/") + .unwrap_or_else(|| head_name.trim()) + .to_string(); + if !branch_name.is_empty() { + return Ok(Some(branch_name)); + } + } + + Ok(None) +} + +fn sync_rebase_completed(repo: &Repository, state: &RebaseState) -> Result { + let original_tip = repo.revparse_single(&state.original_branch)?.id(); + let target_tip = repo.revparse_single(&state.target_branch)?.id(); + Ok(original_tip == target_tip || repo.graph_descendant_of(original_tip, target_tip)?) +} + +fn can_passively_clear_completed_state(repo: &Repository, state: &RebaseState) -> Result { + if state.stash_ref.is_some() + || state.unstage_on_restore + || !state.cleanup_merged_branches.is_empty() + { + return Ok(false); + } + + if state.operation != Operation::Sync { + let restore_branch = state + .caller_branch + .as_deref() + .unwrap_or(state.original_branch.as_str()); + if current_branch_name(repo)? != Some(restore_branch.to_string()) { + return Ok(false); + } + } + + Ok(true) +} + +fn current_branch_name(repo: &Repository) -> Result> { + if repo.head_detached()? { + return Ok(None); + } + + Ok(repo.head()?.shorthand().map(ToString::to_string)) +} + pub fn check_worktrees(branches: &[String], force: bool) -> Result<()> { if force { return Ok(()); @@ -393,58 +608,10 @@ pub fn run_rebase_loop(repo: &Repository, mut state: RebaseState) -> Result<()> // Check if we are resuming a rebase that was already in progress let is_resuming = state.in_progress_branch.as_ref() == Some(¤t_name); - let old_parent_id_str = state - .parent_id_map - .get(¤t_name) - .ok_or_else(|| anyhow!("Parent ID not found for branch '{}'", current_name))?; - - let new_base = if let Some(explicit_base) = state.new_base_map.get(¤t_name) { - explicit_base.clone() - } else if current_name == state.original_branch { - state.target_branch.clone() - } else { - match state.parent_name_map.get(¤t_name) { - Some(name) => name.clone(), // rebase onto the already-moved branch - None => old_parent_id_str.clone(), // rebase onto original commit - } - }; + let (old_parent_id_str, new_base) = branch_rebase_target(&state, ¤t_name)?; // Check if the branch is already rebased (e.g. by a previous --update-refs) - let current_id = repo.revparse_single(¤t_name)?.id(); - let new_base_id = repo.revparse_single(&new_base)?.id(); - let mut is_done = - repo.graph_descendant_of(current_id, new_base_id)? || current_id == new_base_id; - - if is_done - && current_id != new_base_id - && let Some(original_commit_count) = state.original_commit_count_map.get(¤t_name) - { - let current_first_parent_chain = - collect_first_parent_chain(repo, new_base_id, current_id)?; - if current_first_parent_chain.len() > *original_commit_count { - is_done = false; - } - } - - if is_done && current_id != new_base_id { - // Stricter check: the first commit in the branch's delta (relative to its new base) - // must now be a child of new_base_id. - // We walk from current_id back to new_base_id. - let mut walk = repo.revwalk()?; - walk.push(current_id)?; - walk.hide(new_base_id)?; - let mut commits: Vec = walk.filter_map(|id| id.ok()).collect(); - commits.reverse(); // Now oldest to newest - - if let Some(&first_id) = commits.first() { - let first_commit = repo.find_commit(first_id)?; - if first_commit.parent_count() > 0 && first_commit.parent_id(0)? != new_base_id { - // It's a descendant of new_base, but not immediately building on it. - // This could mean it's based on an old version of the base. - is_done = false; - } - } - } + let is_done = branch_rebase_completed(repo, &state, ¤t_name)?; if is_done && (is_resuming || started_any) && !git_rebase_in_progress(repo) { println!("Branch {} already rebased.", current_name); @@ -474,7 +641,7 @@ pub fn run_rebase_loop(repo: &Repository, mut state: RebaseState) -> Result<()> .arg("--update-refs") .arg("--onto") .arg(&new_base) - .arg(old_parent_id_str) + .arg(&old_parent_id_str) .arg(¤t_name) .status()?; diff --git a/tests/commit_tests.rs b/tests/commit_tests.rs index 5ad596e..a4121f4 100644 --- a/tests/commit_tests.rs +++ b/tests/commit_tests.rs @@ -612,7 +612,7 @@ fn test_commit_conflict_and_continue() { .stderr(predicates::str::contains("Resolve conflicts")); // Verify rebase state exists - assert!(dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(dir.path().join(".git/kindra_rebase_state.json").exists()); // Resolve conflict fs::write(dir.path().join("shared.txt"), "resolved content").unwrap(); @@ -640,7 +640,7 @@ fn test_commit_conflict_and_continue() { .success(); // Verify rebase state cleared - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); // Verify feature-b rebased let new_a_id = repo @@ -721,7 +721,7 @@ fn test_commit_abort() { .assert() .failure(); - assert!(dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(dir.path().join(".git/kindra_rebase_state.json").exists()); // Abort let mut cmd_abort = kin_cmd(); @@ -731,7 +731,7 @@ fn test_commit_abort() { .assert() .success(); - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); } #[test] @@ -778,7 +778,7 @@ fn test_abort_malformed_state() { .assert() .failure(); - let state_path = dir.path().join(".git/gits_rebase_state.json"); + let state_path = dir.path().join(".git/kindra_rebase_state.json"); assert!(state_path.exists()); fs::write(&state_path, "{ malformed json").unwrap(); @@ -795,6 +795,190 @@ fn test_abort_malformed_state() { ); } +#[test] +fn test_status_malformed_state_reports_error() { + let dir = tempdir().unwrap(); + repo_init(dir.path()); + let state_path = dir.path().join(".git/kindra_rebase_state.json"); + fs::write(&state_path, "{ malformed json").unwrap(); + let state_before = fs::read_to_string(&state_path).unwrap(); + + kin_cmd() + .arg("status") + .current_dir(dir.path()) + .assert() + .failure(); + + let state_after = fs::read_to_string(&state_path).unwrap(); + assert_eq!(state_after, state_before); + assert!( + state_path.exists(), + "Malformed state should be preserved when status fails to parse it" + ); +} + +#[test] +fn test_continue_malformed_state_does_not_advance_native_rebase() { + let dir = tempdir().unwrap(); + let repo = repo_init(dir.path()); + + let base_id = make_commit(&repo, "refs/heads/main", "file.txt", "base", "base", &[]); + let base = repo.find_commit(base_id).unwrap(); + + let feature_id = make_commit( + &repo, + "refs/heads/feature", + "file.txt", + "feature", + "feature", + &[&base], + ); + let feature = repo.find_commit(feature_id).unwrap(); + + make_commit( + &repo, + "refs/heads/main", + "file.txt", + "main", + "main", + &[&base], + ); + + repo.set_head("refs/heads/feature").unwrap(); + repo.checkout_tree( + feature.as_object(), + Some(git2::build::CheckoutBuilder::new().force()), + ) + .unwrap(); + + let rebase = std::process::Command::new("git") + .arg("rebase") + .arg("main") + .current_dir(dir.path()) + .output() + .unwrap(); + assert!( + !rebase.status.success(), + "native rebase should stop for a conflict" + ); + + fs::write(dir.path().join("file.txt"), "resolved").unwrap(); + run_ok("git", &["add", "file.txt"], dir.path()); + + let state_path = dir.path().join(".git/kindra_rebase_state.json"); + fs::write(&state_path, "{ malformed json").unwrap(); + + kin_cmd() + .arg("continue") + .current_dir(dir.path()) + .env("GIT_EDITOR", "true") + .assert() + .failure(); + + assert!( + dir.path().join(".git/rebase-merge").exists() + || dir.path().join(".git/rebase-apply").exists(), + "native rebase should remain in progress" + ); + assert!(state_path.exists()); +} + +#[test] +fn test_continue_mismatched_parseable_state_does_not_advance_native_rebase() { + let dir = tempdir().unwrap(); + let repo = repo_init(dir.path()); + + let base_id = make_commit(&repo, "refs/heads/main", "file.txt", "base", "base", &[]); + let base = repo.find_commit(base_id).unwrap(); + + let feature_id = make_commit( + &repo, + "refs/heads/feature", + "file.txt", + "feature", + "feature", + &[&base], + ); + let feature = repo.find_commit(feature_id).unwrap(); + + make_commit( + &repo, + "refs/heads/main", + "file.txt", + "main", + "main", + &[&base], + ); + + repo.set_head("refs/heads/feature").unwrap(); + repo.checkout_tree( + feature.as_object(), + Some(git2::build::CheckoutBuilder::new().force()), + ) + .unwrap(); + + let rebase = std::process::Command::new("git") + .arg("rebase") + .arg("main") + .current_dir(dir.path()) + .output() + .unwrap(); + assert!( + !rebase.status.success(), + "native rebase should stop for a conflict" + ); + + fs::write(dir.path().join("file.txt"), "resolved").unwrap(); + run_ok("git", &["add", "file.txt"], dir.path()); + + let state_path = dir.path().join(".git/kindra_rebase_state.json"); + fs::write( + &state_path, + format!( + r#"{{ + "operation": "Move", + "original_branch": "other-feature", + "target_branch": "main", + "remaining_branches": ["other-feature"], + "in_progress_branch": "other-feature", + "parent_id_map": {{"other-feature": "{}"}}, + "parent_name_map": {{}}, + "new_base_map": {{}}, + "original_commit_count_map": {{}}, + "original_tip_map": {{}}, + "owned_tip_map": {{}}, + "stash_ref": null, + "unstage_on_restore": false, + "autostash": false, + "cleanup_merged_branches": [], + "cleanup_checkout_fallback": null +}}"#, + base_id + ), + ) + .unwrap(); + + kin_cmd() + .arg("continue") + .current_dir(dir.path()) + .env("GIT_EDITOR", "true") + .assert() + .failure() + .stderr(predicates::str::contains( + "Active git rebase does not match saved Kindra rebase state", + )); + + assert!( + dir.path().join(".git/rebase-merge").exists() + || dir.path().join(".git/rebase-apply").exists(), + "native rebase should remain in progress" + ); + assert!( + state_path.exists(), + "mismatched Kindra state should remain without advancing the native rebase" + ); +} + #[test] fn test_abort_uses_exact_stash_message_match() { let (dir, repo) = setup_repo(); @@ -854,7 +1038,7 @@ fn test_abort_preserves_stash_when_owned_tip_map_mismatches() { dir.path(), ); - let state_path = dir.path().join(".git/gits_rebase_state.json"); + let state_path = dir.path().join(".git/kindra_rebase_state.json"); fs::write( &state_path, r#"{ @@ -906,7 +1090,7 @@ fn test_abort_preserves_stash_for_legacy_state_without_owned_tip_map() { dir.path(), ); - let state_path = dir.path().join(".git/gits_rebase_state.json"); + let state_path = dir.path().join(".git/kindra_rebase_state.json"); fs::write( &state_path, r#"{ @@ -945,7 +1129,7 @@ fn test_abort_preserves_stash_for_legacy_state_without_owned_tip_map() { #[test] fn test_commit_reentry_guard() { let (dir, _repo) = setup_repo(); - let state_path = dir.path().join(".git/gits_rebase_state.json"); + let state_path = dir.path().join(".git/kindra_rebase_state.json"); // Create the state file to simulate an ongoing operation fs::write(&state_path, "{}").unwrap(); @@ -1494,7 +1678,7 @@ fn test_commit_on_conflict_and_continue_restores_original_context() { .failure() .stderr(predicates::str::contains("Resolve conflicts")); - assert!(dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(dir.path().join(".git/kindra_rebase_state.json").exists()); fs::write(dir.path().join("shared.txt"), "resolved").unwrap(); run_ok("git", &["add", "shared.txt"], dir.path()); @@ -1509,7 +1693,7 @@ fn test_commit_on_conflict_and_continue_restores_original_context() { .assert() .success(); - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); assert_eq!(repo.head().unwrap().shorthand().unwrap(), "feature-a"); } @@ -1563,7 +1747,7 @@ fn test_commit_on_conflict_and_abort_restores_original_context() { .assert() .failure(); - assert!(dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(dir.path().join(".git/kindra_rebase_state.json").exists()); let mut cmd_abort = kin_cmd(); cmd_abort @@ -1572,7 +1756,7 @@ fn test_commit_on_conflict_and_abort_restores_original_context() { .assert() .success(); - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); assert!(!dir.path().join(".git/rebase-merge").exists()); assert!(!dir.path().join(".git/rebase-apply").exists()); assert_eq!(repo.head().unwrap().shorthand().unwrap(), "feature-a"); @@ -1629,7 +1813,7 @@ fn test_rebase_loop_skips_resumed_and_subsequent_done_branches() { assert!(!repo.graph_descendant_of(b_id, new_a_id).unwrap()); // 3. Setup state: resuming a (which is done), b is next (not done) - let state_path = dir.path().join(".git/gits_rebase_state.json"); + let state_path = dir.path().join(".git/kindra_rebase_state.json"); fs::write( &state_path, format!( @@ -1739,7 +1923,7 @@ fn test_commit_on_checkout_conflict_restores_original_context() { )); assert_eq!(repo.head().unwrap().shorthand().unwrap(), "feature-b"); - assert!(dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(dir.path().join(".git/kindra_rebase_state.json").exists()); // Abort should clean up the state and restore context let mut abort_cmd = kin_cmd(); @@ -1749,7 +1933,7 @@ fn test_commit_on_checkout_conflict_restores_original_context() { .assert() .success(); - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); assert_eq!( fs::read_to_string(dir.path().join("scratch.txt")).unwrap(), "scratch" @@ -2013,7 +2197,7 @@ fn test_commit_interactive_amend_tip_with_pathspec_separator() { } #[test] -fn test_commit_interactive_fixup_no_autostash_preserves_recovery_state() { +fn test_commit_interactive_fixup_no_autostash_unwinds_pre_start_rebase_failure() { let (dir, repo) = setup_repo(); let main_id = repo.revparse_single("main").unwrap().id(); let main_commit = repo.find_commit(main_id).unwrap(); @@ -2075,16 +2259,16 @@ fn test_commit_interactive_fixup_no_autostash_preserves_recovery_state() { .failure() .stderr(predicates::str::contains("rebase --autosquash failed")); - assert!(dir.path().join(".git/gits_rebase_state.json").exists()); - - let mut abort_cmd = kin_cmd(); - abort_cmd - .arg("abort") - .current_dir(dir.path()) - .assert() - .success(); - - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + let state_path = dir.path().join(".git/kindra_rebase_state.json"); + let state_content = fs::read_to_string(&state_path).unwrap(); + assert!( + state_content.contains("\"in_progress_branch\": null"), + "Pre-start autosquash failure should not persist an in-progress branch, got: {state_content}" + ); + assert!( + state_content.contains("\"stash_ref\": null"), + "Pre-start autosquash failure should unwind the temporary stash, got: {state_content}" + ); assert_eq!( fs::read_to_string(dir.path().join("file.txt")).unwrap(), "dirty tracked change" @@ -2221,7 +2405,7 @@ fn test_commit_interactive_fixup_commit_failure_does_not_persist_state() { .failure() .stderr(predicates::str::contains("git commit failed")); - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); } #[test] @@ -2370,7 +2554,7 @@ fn test_commit_interactive_fixup_conflict_and_continue() { .stderr(predicates::str::contains("rebase --autosquash failed")); // Verify the repo is in an interactive rebase state - assert!(dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(dir.path().join(".git/kindra_rebase_state.json").exists()); assert!( dir.path().join(".git/rebase-merge").exists() || dir.path().join(".git/rebase-apply").exists() @@ -2434,7 +2618,7 @@ fn test_commit_interactive_fixup_conflict_and_continue() { ); // Verify state cleared - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); assert!(!dir.path().join(".git/rebase-merge").exists()); assert!(!dir.path().join(".git/rebase-apply").exists()); diff --git a/tests/move_tests.rs b/tests/move_tests.rs index 1c23e91..7b1ff74 100644 --- a/tests/move_tests.rs +++ b/tests/move_tests.rs @@ -296,6 +296,271 @@ fn test_move_conflict_and_continue() { ); } +#[test] +#[cfg(unix)] +fn test_move_manual_git_continue_then_kin_continue_resumes_next_branch() { + let dir = tempdir().unwrap(); + let repo = repo_init(dir.path()); + + let base_id = make_commit(&repo, "refs/heads/main", "file.txt", "base", "base", &[]); + let base = repo.find_commit(base_id).unwrap(); + + make_commit( + &repo, + "refs/heads/target", + "file.txt", + "target", + "target", + &[&base], + ); + + let feature_a_id = make_commit( + &repo, + "refs/heads/feature-a", + "file.txt", + "feature-a", + "feature-a", + &[&base], + ); + let feature_a = repo.find_commit(feature_a_id).unwrap(); + + make_commit( + &repo, + "refs/heads/feature-b", + "feature-b.txt", + "feature-b", + "feature-b", + &[&feature_a], + ); + + repo.set_head("refs/heads/feature-a").unwrap(); + repo.checkout_tree( + feature_a.as_object(), + Some(git2::build::CheckoutBuilder::new().force()), + ) + .unwrap(); + + let log_path = dir.path().join("git_calls.log"); + let git_wrapper = dir.path().join("git"); + let real_git = which::which("git").unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::write( + &git_wrapper, + format!( + "#!/bin/sh\necho \"$@\" >> \"{}\"\nexec \"{}\" \"$@\"", + log_path.to_str().unwrap(), + real_git.to_str().unwrap() + ), + ) + .unwrap(); + let mut perms = fs::metadata(&git_wrapper).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&git_wrapper, perms).unwrap(); + } + + let old_path = std::env::var_os("PATH").unwrap_or_default(); + let mut new_path = dir.path().to_path_buf().into_os_string(); + new_path.push(":"); + new_path.push(old_path); + + kin_cmd() + .arg("move") + .arg("--onto") + .arg("target") + .current_dir(dir.path()) + .env("PATH", &new_path) + .assert() + .failure() + .stderr(predicates::str::contains("Resolve conflicts")); + + fs::write(dir.path().join("file.txt"), "resolved").unwrap(); + run_ok("git", &["add", "file.txt"], dir.path()); + run_ok( + "git", + &["-c", "core.editor=true", "rebase", "--continue"], + dir.path(), + ); + + kin_cmd() + .arg("continue") + .current_dir(dir.path()) + .env("PATH", &new_path) + .env("GIT_EDITOR", "true") + .assert() + .success(); + + let log_after = fs::read_to_string(&log_path).unwrap(); + let feature_a_rebases = log_after + .lines() + .filter(|line| line.contains("rebase --no-ff") && line.ends_with(" feature-a")) + .count(); + let feature_b_rebases = log_after + .lines() + .filter(|line| line.contains("rebase --no-ff") && line.ends_with(" feature-b")) + .count(); + assert_eq!( + feature_a_rebases, 1, + "kin continue should not re-run the manually completed feature-a rebase" + ); + assert_eq!(feature_b_rebases, 1, "kin continue should rebase feature-b"); + + let repo = Repository::open(dir.path()).unwrap(); + let feature_a_tip = repo + .find_branch("feature-a", git2::BranchType::Local) + .unwrap() + .get() + .target() + .unwrap(); + let feature_b_tip = repo + .find_branch("feature-b", git2::BranchType::Local) + .unwrap() + .get() + .target() + .unwrap(); + let target_tip = repo.revparse_single("target").unwrap().id(); + assert!(repo.graph_descendant_of(feature_a_tip, target_tip).unwrap()); + assert!( + repo.graph_descendant_of(feature_b_tip, feature_a_tip) + .unwrap() + ); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); +} + +#[test] +fn test_move_manual_git_continue_completed_state_clears_on_status() { + let dir = tempdir().unwrap(); + let repo = repo_init(dir.path()); + + let base_id = make_commit(&repo, "refs/heads/main", "file.txt", "base", "base", &[]); + let base = repo.find_commit(base_id).unwrap(); + + make_commit( + &repo, + "refs/heads/target", + "file.txt", + "target", + "target", + &[&base], + ); + + let feature_id = make_commit( + &repo, + "refs/heads/feature", + "file.txt", + "feature", + "feature", + &[&base], + ); + let feature = repo.find_commit(feature_id).unwrap(); + + repo.set_head("refs/heads/feature").unwrap(); + repo.checkout_tree( + feature.as_object(), + Some(git2::build::CheckoutBuilder::new().force()), + ) + .unwrap(); + + kin_cmd() + .arg("move") + .arg("--onto") + .arg("target") + .current_dir(dir.path()) + .assert() + .failure() + .stderr(predicates::str::contains("Resolve conflicts")); + + fs::write(dir.path().join("file.txt"), "resolved").unwrap(); + run_ok("git", &["add", "file.txt"], dir.path()); + run_ok( + "git", + &["-c", "core.editor=true", "rebase", "--continue"], + dir.path(), + ); + + kin_cmd() + .arg("status") + .current_dir(dir.path()) + .assert() + .success() + .stdout(predicates::str::contains("No Kindra operation active.")); + + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); + + kin_cmd() + .arg("move") + .arg("--onto") + .arg("target") + .current_dir(dir.path()) + .assert() + .success(); +} + +#[test] +fn test_move_passive_reconcile_keeps_completed_state_with_pending_finalizer() { + let dir = tempdir().unwrap(); + let repo = repo_init(dir.path()); + + let base_id = make_commit(&repo, "refs/heads/main", "file.txt", "base", "base", &[]); + let base = repo.find_commit(base_id).unwrap(); + let feature_id = make_commit( + &repo, + "refs/heads/feature", + "file.txt", + "feature", + "feature", + &[&base], + ); + let feature = repo.find_commit(feature_id).unwrap(); + + repo.set_head("refs/heads/feature").unwrap(); + repo.checkout_tree( + feature.as_object(), + Some(git2::build::CheckoutBuilder::new().force()), + ) + .unwrap(); + + let state = RebaseState { + operation: Operation::Move, + original_branch: "feature".to_string(), + target_branch: "main".to_string(), + caller_branch: None, + remaining_branches: Vec::new(), + in_progress_branch: None, + parent_id_map: HashMap::new(), + parent_name_map: HashMap::new(), + new_base_map: HashMap::new(), + original_commit_count_map: HashMap::new(), + original_tip_map: HashMap::from([("feature".to_string(), feature_id.to_string())]), + owned_tip_map: HashMap::new(), + stash_ref: None, + unstage_on_restore: true, + autostash: false, + cleanup_merged_branches: Vec::new(), + cleanup_checkout_fallback: None, + }; + save_state(&repo, &state).unwrap(); + + kin_cmd() + .arg("status") + .current_dir(dir.path()) + .assert() + .success() + .stdout(predicates::str::contains("Move in progress")); + + assert!(dir.path().join(".git/kindra_rebase_state.json").exists()); + + kin_cmd() + .arg("continue") + .current_dir(dir.path()) + .assert() + .success(); + + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); +} + #[test] fn test_move_continue_forwards_editor_env_to_git() { let dir = tempdir().unwrap(); @@ -1075,7 +1340,8 @@ exec "{}" "$@" .assert() .failure(); - let state_content = fs::read_to_string(dir.path().join(".git/gits_rebase_state.json")).unwrap(); + let state_content = + fs::read_to_string(dir.path().join(".git/kindra_rebase_state.json")).unwrap(); assert!( state_content.contains("\"in_progress_branch\": null"), "Pre-start failure should clear in_progress_branch but got: {state_content}" @@ -1216,7 +1482,7 @@ fn test_move_abort_preserves_state_on_rebase_abort_failure() { .target() .unwrap(); - let state_path = dir.path().join(".git/gits_rebase_state.json"); + let state_path = dir.path().join(".git/kindra_rebase_state.json"); write_rebase_state_fixture(&repo, &state_path, feature_tip); // 2. Manually create a rebase-merge directory to simulate an active rebase @@ -1297,7 +1563,7 @@ fn test_move_abort_leaves_manual_rebase_when_owned_tip_map_mismatches() { "Manual rebase should have failed due to conflict" ); - let state_path = dir.path().join(".git/gits_rebase_state.json"); + let state_path = dir.path().join(".git/kindra_rebase_state.json"); fs::write( &state_path, r#"{ @@ -1463,7 +1729,7 @@ fn test_move_invalid_onto() { )); // Verify no state file was created - assert!(!repo.path().join("gits_rebase_state.json").exists()); + assert!(!repo.path().join("kindra_rebase_state.json").exists()); } #[test] @@ -1582,7 +1848,7 @@ fn test_move_repo_config_enables_autostash_and_persists_in_state() { "repo config should allow move to start rebasing with autostash" ); - let state = fs::read_to_string(dir.path().join(".git/gits_rebase_state.json")).unwrap(); + let state = fs::read_to_string(dir.path().join(".git/kindra_rebase_state.json")).unwrap(); assert!( state.contains("\"autostash\": true"), "rebase state should persist autostash preference: {state}" @@ -1657,7 +1923,7 @@ exec {} "$@" .failure(); // Verify state file exists and still contains the branch in remaining_branches - let state_path = dir.path().join(".git/gits_rebase_state.json"); + let state_path = dir.path().join(".git/kindra_rebase_state.json"); assert!(state_path.exists(), "State file should exist"); let state_content = fs::read_to_string(&state_path).unwrap(); @@ -1832,7 +2098,7 @@ fn test_move_abort_cleans_up_rebase_when_state_exists() { "rebase should have failed with conflict" ); - let state_path = dir.path().join(".git/gits_rebase_state.json"); + let state_path = dir.path().join(".git/kindra_rebase_state.json"); write_rebase_state_fixture(&repo, &state_path, feature_tip); // Run kin move abort diff --git a/tests/reorder_tests.rs b/tests/reorder_tests.rs index e89a902..05f2432 100644 --- a/tests/reorder_tests.rs +++ b/tests/reorder_tests.rs @@ -479,7 +479,7 @@ fn reorder_conflict_and_abort_restores_original_graph_and_cleans_up() { assert_direct_parent_id(&repo, "feature-c", original_parent_feature_c); assert_direct_parent_id(&repo, "feature-a", original_parent_feature_a); assert_direct_parent_id(&repo, "feature-b", original_parent_feature_b); - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); assert!(!dir.path().join(".git/rebase-merge").exists()); assert!(!dir.path().join(".git/rebase-apply").exists()); } @@ -634,7 +634,7 @@ fn reorder_abort_restores_extra_local_refs_moved_by_update_refs() { let repo = Repository::open(dir.path()).unwrap(); assert_eq!(branch_tip(&repo, "feature-bookmark"), alias_tip_before); - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); assert!(!dir.path().join(".git/rebase-merge").exists()); assert!(!dir.path().join(".git/rebase-apply").exists()); } @@ -713,7 +713,7 @@ fn reorder_manual_git_continue_then_abort_clears_state_without_rewinding_refs() assert_eq!(branch_tip(&repo, "feature-a"), feature_a_tip_before_abort); assert_eq!(branch_tip(&repo, "feature-b"), feature_b_tip_before_abort); assert_eq!(branch_tip(&repo, "feature-c"), feature_c_tip_before_abort); - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); assert!(!dir.path().join(".git/rebase-merge").exists()); assert!(!dir.path().join(".git/rebase-apply").exists()); } diff --git a/tests/split_tests.rs b/tests/split_tests.rs index e2bc357..ab89fc8 100644 --- a/tests/split_tests.rs +++ b/tests/split_tests.rs @@ -676,5 +676,5 @@ mv "$file.tmp" "$file" .stderr(predicates::str::contains("must follow a commit line")); // Verify state file does NOT exist - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); } diff --git a/tests/sync_tests.rs b/tests/sync_tests.rs index c6db665..f67eae1 100644 --- a/tests/sync_tests.rs +++ b/tests/sync_tests.rs @@ -2,7 +2,9 @@ mod common; use common::{kin_cmd, make_commit, repo_init, run_ok}; use git2::{BranchType, Repository}; +use kindra::rebase_utils::{Operation, RebaseState, load_state, save_state}; use predicates::prelude::*; +use std::collections::HashMap; use std::fs; use tempfile::TempDir; use tempfile::tempdir; @@ -1010,11 +1012,119 @@ fn sync_reports_rebase_conflict() { "Expected git rebase state to remain after conflict" ); assert!( - dir.path().join(".git/gits_rebase_state.json").exists(), + dir.path().join(".git/kindra_rebase_state.json").exists(), "Expected kindra state to remain after conflict" ); } +#[test] +fn sync_no_delete_manual_continue_from_non_tip_branch_clears_passively() { + let dir = tempdir().unwrap(); + let repo = repo_init(dir.path()); + + let base_id = make_commit( + &repo, + "refs/heads/main", + "file.txt", + "base", + "base commit", + &[], + ); + let base = repo.find_commit(base_id).unwrap(); + + let a_id = make_commit( + &repo, + "refs/heads/feature-a", + "file.txt", + "feature change", + "feature a", + &[&base], + ); + let a = repo.find_commit(a_id).unwrap(); + + let b_id = make_commit( + &repo, + "refs/heads/feature-b", + "b.txt", + "b", + "feature b", + &[&a], + ); + + make_commit( + &repo, + "refs/heads/main", + "file.txt", + "main change", + "main conflicting change", + &[&base], + ); + + run_ok("git", &["checkout", "-f", "feature-a"], dir.path()); + + kin_cmd() + .arg("sync") + .arg("--no-delete") + .current_dir(dir.path()) + .assert() + .failure() + .stderr( + predicate::str::contains("rebase").or(predicate::str::contains("Resolve conflicts")), + ); + + let state_path = dir.path().join(".git/kindra_rebase_state.json"); + assert!(state_path.exists()); + + fs::write(dir.path().join("file.txt"), "resolved").unwrap(); + run_ok("git", &["add", "file.txt"], dir.path()); + run_ok( + "git", + &["-c", "core.editor=true", "rebase", "--continue"], + dir.path(), + ); + + kin_cmd() + .arg("status") + .current_dir(dir.path()) + .assert() + .success() + .stdout(predicate::str::contains("No Kindra operation active.")); + + assert!( + !state_path.exists(), + "completed sync state should be cleared without requiring kin continue" + ); + assert!( + Repository::open(dir.path()) + .unwrap() + .find_branch("feature-a", BranchType::Local) + .is_ok(), + "--no-delete sync should not delete the lower branch" + ); + let repo = Repository::open(dir.path()).unwrap(); + let feature_a_tip = repo + .find_branch("feature-a", BranchType::Local) + .unwrap() + .get() + .target() + .unwrap(); + let feature_b_tip = repo + .find_branch("feature-b", BranchType::Local) + .unwrap() + .get() + .target() + .unwrap(); + assert_ne!( + feature_b_tip, b_id, + "manual continuation should finish rebasing the upper branch" + ); + assert!( + repo.graph_descendant_of(feature_b_tip, feature_a_tip) + .unwrap(), + "upper branch should remain stacked on the updated lower branch" + ); +} + #[test] fn sync_abort_restores_original_branch_after_tip_switch_conflict() { let dir = tempdir().unwrap(); @@ -1069,7 +1179,7 @@ fn sync_abort_restores_original_branch_after_tip_switch_conflict() { predicate::str::contains("rebase").or(predicate::str::contains("Resolve conflicts")), ); - assert!(dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(dir.path().join(".git/kindra_rebase_state.json").exists()); let repo = Repository::open(dir.path()).unwrap(); assert_ne!(repo.state(), git2::RepositoryState::Clean); @@ -1093,11 +1203,89 @@ fn sync_abort_restores_original_branch_after_tip_switch_conflict() { .unwrap(), old_feature_b ); - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); assert!(!dir.path().join(".git/rebase-merge").exists()); assert!(!dir.path().join(".git/rebase-apply").exists()); } +#[test] +fn status_blocks_and_preserves_state_when_active_git_rebase_mismatches_kindra_state() { + let dir = tempdir().unwrap(); + let repo = repo_init(dir.path()); + + let base_id = make_commit( + &repo, + "refs/heads/main", + "base.txt", + "base", + "base commit", + &[], + ); + let base = repo.find_commit(base_id).unwrap(); + + let a_id = make_commit( + &repo, + "refs/heads/feature-a", + "a.txt", + "a", + "feature a", + &[&base], + ); + let b_id = make_commit( + &repo, + "refs/heads/feature-b", + "b.txt", + "b", + "feature b", + &[&base], + ); + + let state = RebaseState { + operation: Operation::Sync, + original_branch: "feature-a".to_string(), + target_branch: "main".to_string(), + caller_branch: None, + remaining_branches: vec!["feature-a".to_string()], + in_progress_branch: Some("feature-a".to_string()), + parent_id_map: HashMap::from([("feature-a".to_string(), base_id.to_string())]), + parent_name_map: HashMap::new(), + new_base_map: HashMap::new(), + original_commit_count_map: HashMap::new(), + original_tip_map: HashMap::from([("feature-a".to_string(), a_id.to_string())]), + owned_tip_map: HashMap::from([("feature-a".to_string(), a_id.to_string())]), + stash_ref: Some("stash@{0}".to_string()), + unstage_on_restore: false, + autostash: false, + cleanup_merged_branches: vec!["feature-b".to_string()], + cleanup_checkout_fallback: Some("main".to_string()), + }; + save_state(&repo, &state).unwrap(); + + repo.set_head("refs/heads/feature-b").unwrap(); + repo.checkout_tree( + repo.find_commit(b_id).unwrap().as_object(), + Some(git2::build::CheckoutBuilder::new().force()), + ) + .unwrap(); + let rebase_dir = dir.path().join(".git/rebase-merge"); + fs::create_dir_all(&rebase_dir).unwrap(); + fs::write(rebase_dir.join("head-name"), "refs/heads/feature-b\n").unwrap(); + + kin_cmd() + .arg("status") + .current_dir(dir.path()) + .assert() + .failure() + .stderr(predicate::str::contains( + "Active git rebase does not match saved Kindra rebase state", + )); + + let preserved = load_state(&repo).unwrap(); + assert_eq!(preserved.in_progress_branch.as_deref(), Some("feature-a")); + assert_eq!(preserved.stash_ref.as_deref(), Some("stash@{0}")); + assert_eq!(preserved.cleanup_merged_branches, vec!["feature-b"]); +} + #[test] fn sync_refuses_when_git_rebase_in_progress() { let dir = tempdir().unwrap(); @@ -1946,7 +2134,7 @@ fn sync_preserves_state_on_prestart_rebase_failure_after_tip_checkout() { let repo = Repository::open(dir.path()).unwrap(); assert_eq!(repo.state(), git2::RepositoryState::Clean); assert_eq!(repo.head().unwrap().shorthand(), Some("feature-b")); - assert!(dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(dir.path().join(".git/kindra_rebase_state.json").exists()); assert!(!dir.path().join(".git/rebase-merge").exists()); assert!(!dir.path().join(".git/rebase-apply").exists()); @@ -1968,7 +2156,7 @@ fn sync_preserves_state_on_prestart_rebase_failure_after_tip_checkout() { let repo = Repository::open(dir.path()).unwrap(); assert_eq!(repo.state(), git2::RepositoryState::Clean); assert_eq!(repo.head().unwrap().shorthand(), Some("feature-a")); - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); assert!(!dir.path().join(".git/rebase-merge").exists()); assert!(!dir.path().join(".git/rebase-apply").exists()); } @@ -2064,7 +2252,7 @@ fn sync_on_main_handles_rebase_conflict_and_preserves_state() { "Expected git rebase state to remain after main sync conflict" ); assert!( - dir.path().join(".git/gits_rebase_state.json").exists(), + dir.path().join(".git/kindra_rebase_state.json").exists(), "Expected kindra state to remain after main sync conflict" ); assert!(repo.find_branch("feature-a", BranchType::Local).is_ok()); @@ -2140,7 +2328,7 @@ fn sync_on_main_conflict_can_continue_with_gits() { predicate::str::contains("rebase").or(predicate::str::contains("Resolve conflicts")), ); - assert!(dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(dir.path().join(".git/kindra_rebase_state.json").exists()); fs::write(dir.path().join("shared.txt"), "resolved main").unwrap(); run_ok("git", &["add", "shared.txt"], dir.path()); @@ -2156,7 +2344,7 @@ fn sync_on_main_conflict_can_continue_with_gits() { let repo = Repository::open(dir.path()).unwrap(); assert_eq!(repo.state(), git2::RepositoryState::Clean); assert_eq!(repo.head().unwrap().shorthand(), Some("main")); - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); let main_tip = repo.find_branch("main", BranchType::Local).unwrap(); let main_tip = main_tip.get().target().unwrap(); @@ -2226,7 +2414,7 @@ fn sync_on_main_conflict_continue_after_rebase() { run_ok("git", &["reset", "--hard", "origin/main"], dir.path()); fs::write( - dir.path().join(".git/gits_rebase_state.json"), + dir.path().join(".git/kindra_rebase_state.json"), r#"{ "operation": "Sync", "original_branch": "main", @@ -2249,7 +2437,7 @@ fn sync_on_main_conflict_continue_after_rebase() { let repo = Repository::open(dir.path()).unwrap(); assert_eq!(repo.state(), git2::RepositoryState::Clean); assert_eq!(repo.head().unwrap().shorthand(), Some("main")); - assert!(!dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(!dir.path().join(".git/kindra_rebase_state.json").exists()); assert!(!dir.path().join(".git/rebase-merge").exists()); assert!(!dir.path().join(".git/rebase-apply").exists()); @@ -2343,7 +2531,7 @@ fn sync_on_main_manual_git_abort_does_not_finalize_or_delete_branches() { predicate::str::contains("rebase").or(predicate::str::contains("Resolve conflicts")), ); - assert!(dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(dir.path().join(".git/kindra_rebase_state.json").exists()); run_ok("git", &["rebase", "--abort"], dir.path()); let mut continue_cmd = kin_cmd(); @@ -2356,7 +2544,7 @@ fn sync_on_main_manual_git_abort_does_not_finalize_or_delete_branches() { let repo = Repository::open(dir.path()).unwrap(); assert_eq!(repo.head().unwrap().shorthand(), Some("main")); - assert!(dir.path().join(".git/gits_rebase_state.json").exists()); + assert!(dir.path().join(".git/kindra_rebase_state.json").exists()); assert!(repo.find_branch("feature-a", BranchType::Local).is_ok()); }