diff --git a/src/cmd/amend.rs b/src/cmd/amend.rs index 74303de..4670181 100644 --- a/src/cmd/amend.rs +++ b/src/cmd/amend.rs @@ -66,16 +66,15 @@ pub fn run(message: Option<&str>, all: bool) -> Result<()> { let current_root = git::repo_root()?; for child_name in &children { - // Guard FIRST — before extracting old_parent_head. - if let Ok(Some(_wt_path)) = git::branch_checked_out_elsewhere(child_name, ¤t_root) { - ui::info(&format!("Skipped `{child_name}` (in worktree)")); - continue; - } - let old_parent_head = state.get_branch(child_name)?.parent_head.clone(); let sp = ui::spinner(&format!("Restacking `{child_name}`...")); - let outcome = git::rebase_onto(¤t_head, &old_parent_head, child_name)?; + let outcome = git::rebase_onto_for_branch( + ¤t_head, + &old_parent_head, + child_name, + ¤t_root, + )?; sp.finish_and_clear(); match outcome { diff --git a/src/cmd/commit.rs b/src/cmd/commit.rs index e7baad7..d00e318 100644 --- a/src/cmd/commit.rs +++ b/src/cmd/commit.rs @@ -71,17 +71,11 @@ pub fn run( let mut restacked_count = 0; for child in &children { - // Guard FIRST — before extracting old_base (avoids unused-variable warning when skipping). - if let Ok(Some(_wt_path)) = git::branch_checked_out_elsewhere(child, ¤t_root) { - ui::info(&format!("Skipped `{child}` (in worktree)")); - continue; - } - let meta = state.get_branch(child)?; let old_base = meta.parent_head.clone(); ui::info(&format!("Restacking `{child}`...")); - match git::rebase_onto(&new_head, &old_base, child)? { + match git::rebase_onto_for_branch(&new_head, &old_base, child, ¤t_root)? { git::RebaseOutcome::RebasingComplete => {} git::RebaseOutcome::Conflict(conflict) => { // Save progress so the user can fix conflicts and continue with `ez restack`. diff --git a/src/cmd/merge.rs b/src/cmd/merge.rs index ef1c5b2..14e3ef2 100644 --- a/src/cmd/merge.rs +++ b/src/cmd/merge.rs @@ -111,13 +111,13 @@ fn merge_branch( continue; } - if let Ok(Some(_wt_path)) = git::branch_checked_out_elsewhere(branch_name, ¤t_root) { - ui::warn(&format!("Skipped `{branch_name}` (in worktree)")); - continue; - } - let sp = ui::spinner(&format!("Restacking `{branch_name}` onto `{parent}`...")); - let outcome = git::rebase_onto(¤t_parent_tip, &stored_parent_head, branch_name)?; + let outcome = git::rebase_onto_for_branch( + ¤t_parent_tip, + &stored_parent_head, + branch_name, + ¤t_root, + )?; sp.finish_and_clear(); match outcome { diff --git a/src/cmd/move_branch.rs b/src/cmd/move_branch.rs index 5ad2bae..1b7918b 100644 --- a/src/cmd/move_branch.rs +++ b/src/cmd/move_branch.rs @@ -95,11 +95,6 @@ pub fn run(onto: Option<&str>) -> Result<()> { let current_root = git::repo_root()?; for child_name in &children { - if let Ok(Some(_wt_path)) = git::branch_checked_out_elsewhere(child_name, ¤t_root) { - ui::warn(&format!("Skipped `{child_name}` (in worktree)")); - continue; - } - let child = state.get_branch(child_name)?; let child_parent_head = child.parent_head.clone(); @@ -108,7 +103,8 @@ pub fn run(onto: Option<&str>) -> Result<()> { } let sp = ui::spinner(&format!("Restacking `{child_name}` onto `{current}`...")); - let outcome = git::rebase_onto(&new_tip, &child_parent_head, child_name)?; + let outcome = + git::rebase_onto_for_branch(&new_tip, &child_parent_head, child_name, ¤t_root)?; sp.finish_and_clear(); match outcome { diff --git a/src/cmd/restack.rs b/src/cmd/restack.rs index a7606ce..6c0909b 100644 --- a/src/cmd/restack.rs +++ b/src/cmd/restack.rs @@ -29,7 +29,6 @@ pub fn run() -> Result<()> { let order = state.topo_order(); let mut restacked = 0; - let mut skipped = 0; for branch_name in &order { let meta = state.get_branch(branch_name)?; @@ -42,18 +41,16 @@ pub fn run() -> Result<()> { continue; } - // Guard: skip branches checked out in another worktree. - if let Ok(Some(_wt_path)) = git::branch_checked_out_elsewhere(branch_name, ¤t_root) { - ui::warn(&format!("Skipped `{branch_name}` (in worktree)")); - skipped += 1; - continue; - } - - // Branch is stale — rebase onto the new parent tip. + // Branch is stale — rebase onto the new parent tip (in its worktree if needed). let before_sha = git::rev_parse(branch_name).unwrap_or_default(); let sp = ui::spinner(&format!("Restacking `{branch_name}` onto `{parent}`...")); - let outcome = git::rebase_onto(¤t_parent_tip, &stored_parent_head, branch_name)?; + let outcome = git::rebase_onto_for_branch( + ¤t_parent_tip, + &stored_parent_head, + branch_name, + ¤t_root, + )?; sp.finish_and_clear(); match outcome { @@ -73,7 +70,7 @@ pub fn run() -> Result<()> { ui::info(&format!( "Dropping {redundant_count} redundant commit(s) from `{branch_name}` (already in `{parent}`)", )); - match git::rebase(&parent, branch_name) { + match git::rebase_for_branch(&parent, branch_name, ¤t_root) { Ok(true) => { ui::info(&format!( "Dropped redundant commits from `{branch_name}`" @@ -121,7 +118,7 @@ pub fn run() -> Result<()> { state.save()?; - if restacked == 0 && skipped == 0 { + if restacked == 0 { ui::info("All branches are up to date — nothing to restack"); } diff --git a/src/cmd/sync.rs b/src/cmd/sync.rs index a852a6e..8a96aa9 100644 --- a/src/cmd/sync.rs +++ b/src/cmd/sync.rs @@ -397,16 +397,15 @@ fn run_sync_inner(force: bool) -> Result<()> { continue; } - // Guard: skip branches checked out in another worktree. - if let Ok(Some(_wt_path)) = git::branch_checked_out_elsewhere(branch_name, &original_root) { - ui::warn(&format!("Skipped `{branch_name}` (in worktree)")); - continue; - } - let before_sha = git::rev_parse(branch_name).unwrap_or_default(); let sp = ui::spinner(&format!("Restacking `{branch_name}` onto `{parent}`...")); - let outcome = git::rebase_onto(¤t_parent_tip, &stored_parent_head, branch_name)?; + let outcome = git::rebase_onto_for_branch( + ¤t_parent_tip, + &stored_parent_head, + branch_name, + &original_root, + )?; sp.finish_and_clear(); match outcome { @@ -426,7 +425,7 @@ fn run_sync_inner(force: bool) -> Result<()> { ui::info(&format!( "Dropping {redundant_count} redundant commit(s) from `{branch_name}` (already in `{parent}`)", )); - match git::rebase(&parent, branch_name) { + match git::rebase_for_branch(&parent, branch_name, &original_root) { Ok(true) => { ui::info(&format!( "Dropped redundant commits from `{branch_name}`" diff --git a/src/git.rs b/src/git.rs index cf44c43..361c227 100644 --- a/src/git.rs +++ b/src/git.rs @@ -322,22 +322,65 @@ fn fetch_args(remote: &str) -> [&str; 3] { ["fetch", "--progress", remote] } -pub fn rebase_onto(new_base: &str, old_base: &str, branch: &str) -> Result { - let (success, _, stderr) = - run_git_with_status(&["rebase", "--onto", new_base, old_base, branch])?; +fn rebase_onto_impl( + scope: Option<&str>, + new_base: &str, + old_base: &str, + branch: Option<&str>, +) -> Result { + let mut args: Vec<&str> = Vec::new(); + if let Some(dir) = scope { + args.extend(["-C", dir]); + } + args.extend(["rebase", "--onto", new_base, old_base]); + if let Some(branch_name) = branch { + args.push(branch_name); + } + + let (success, _, stderr) = run_git_with_status(&args)?; + + let mut abort_args: Vec<&str> = Vec::new(); + if let Some(dir) = scope { + abort_args.extend(["-C", dir]); + } + abort_args.extend(["rebase", "--abort"]); + if success { Ok(RebaseOutcome::RebasingComplete) } else if stderr.contains("CONFLICT") || stderr.contains("conflict") { // Abort the rebase so we leave the repo in a clean state - let _ = run_git(&["rebase", "--abort"]); + let _ = run_git(&abort_args); Ok(RebaseOutcome::Conflict(parse_rebase_conflict(&stderr))) } else { // Some other rebase failure — try to abort and report - let _ = run_git(&["rebase", "--abort"]); + let _ = run_git(&abort_args); bail!(EzError::GitError(stderr)); } } +pub fn rebase_onto(new_base: &str, old_base: &str, branch: &str) -> Result { + rebase_onto_impl(None, new_base, old_base, Some(branch)) +} + +/// Rebase the branch checked out in `dir` onto `new_base`, dropping commits up to `old_base`. +pub fn rebase_onto_at(dir: &str, new_base: &str, old_base: &str) -> Result { + rebase_onto_impl(Some(dir), new_base, old_base, None) +} + +/// Rebase `branch` onto `new_base`, running the rebase in its worktree when checked out elsewhere. +pub fn rebase_onto_for_branch( + new_base: &str, + old_base: &str, + branch: &str, + current_root: &str, +) -> Result { + if let Some(wt_path) = branch_checked_out_elsewhere(branch, current_root)? { + rebase_onto_at(&wt_path, new_base, old_base) + } else { + rebase_onto(new_base, old_base, branch) + } +} + fn parse_rebase_conflict(stderr: &str) -> RebaseConflict { RebaseConflict { conflicting_files: parse_conflicting_files(stderr), @@ -376,14 +419,29 @@ fn parse_conflicting_files(stderr: &str) -> Vec { files.into_iter().collect() } -/// Plain `git rebase ` — uses git's built-in patch-id detection -/// to auto-skip commits already applied upstream. Returns true on success. -pub fn rebase(upstream: &str, branch: &str) -> Result { - let (success, _, stderr) = run_git_with_status(&["rebase", upstream, branch])?; +fn rebase_impl(scope: Option<&str>, upstream: &str, branch: Option<&str>) -> Result { + let mut args: Vec<&str> = Vec::new(); + if let Some(dir) = scope { + args.extend(["-C", dir]); + } + args.push("rebase"); + args.push(upstream); + if let Some(branch_name) = branch { + args.push(branch_name); + } + + let (success, _, stderr) = run_git_with_status(&args)?; + + let mut abort_args: Vec<&str> = Vec::new(); + if let Some(dir) = scope { + abort_args.extend(["-C", dir]); + } + abort_args.extend(["rebase", "--abort"]); + if success { Ok(true) } else { - let _ = run_git(&["rebase", "--abort"]); + let _ = run_git(&abort_args); if stderr.contains("CONFLICT") || stderr.contains("conflict") { Ok(false) } else { @@ -392,6 +450,26 @@ pub fn rebase(upstream: &str, branch: &str) -> Result { } } +/// Plain `git rebase ` — uses git's built-in patch-id detection +/// to auto-skip commits already applied upstream. Returns true on success. +pub fn rebase(upstream: &str, branch: &str) -> Result { + rebase_impl(None, upstream, Some(branch)) +} + +/// Rebase the branch checked out in `dir` onto `upstream`. +pub fn rebase_at(dir: &str, upstream: &str) -> Result { + rebase_impl(Some(dir), upstream, None) +} + +/// Rebase `branch` onto `upstream`, running the rebase in its worktree when checked out elsewhere. +pub fn rebase_for_branch(upstream: &str, branch: &str, current_root: &str) -> Result { + if let Some(wt_path) = branch_checked_out_elsewhere(branch, current_root)? { + rebase_at(&wt_path, upstream) + } else { + rebase(upstream, branch) + } +} + pub fn fast_forward_merge(remote_ref: &str) -> Result<()> { run_git(&["merge", "--ff-only", remote_ref])?; Ok(()) @@ -876,6 +954,31 @@ CONFLICT (modify/delete): src/old.ts deleted in HEAD and modified in abc123.\n"; ); } + #[test] + fn rebase_onto_at_uses_worktree_directory() { + let _guard = take_env_lock(); + let log_dir = crate::test_support::temp_dir("git-rebase-at"); + let log_path = log_dir.join("calls.log"); + let fake_dir = install_fake_bin( + "git-rebase-at-bin", + "git", + &format!( + r#"#!/bin/sh +echo "$@" >> "{}" +exit 0 +"#, + log_path.display() + ), + ); + let _path = PathGuard::install(&fake_dir); + + rebase_onto_at("/repo/.worktrees/feat-a", "main", "old-base").expect("rebase at"); + assert_eq!( + std::fs::read_to_string(log_path).expect("log"), + "-C /repo/.worktrees/feat-a rebase --onto main old-base\n" + ); + } + #[test] fn rebase_onto_aborts_and_returns_conflict_details() { let _guard = take_env_lock();