Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions src/cmd/amend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &current_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(&current_head, &old_parent_head, child_name)?;
let outcome = git::rebase_onto_for_branch(
&current_head,
&old_parent_head,
child_name,
&current_root,
)?;
sp.finish_and_clear();

match outcome {
Expand Down
8 changes: 1 addition & 7 deletions src/cmd/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &current_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, &current_root)? {
git::RebaseOutcome::RebasingComplete => {}
git::RebaseOutcome::Conflict(conflict) => {
// Save progress so the user can fix conflicts and continue with `ez restack`.
Expand Down
12 changes: 6 additions & 6 deletions src/cmd/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,13 @@ fn merge_branch(
continue;
}

if let Ok(Some(_wt_path)) = git::branch_checked_out_elsewhere(branch_name, &current_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(&current_parent_tip, &stored_parent_head, branch_name)?;
let outcome = git::rebase_onto_for_branch(
&current_parent_tip,
&stored_parent_head,
branch_name,
&current_root,
)?;
sp.finish_and_clear();

match outcome {
Expand Down
8 changes: 2 additions & 6 deletions src/cmd/move_branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &current_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();

Expand All @@ -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, &current_root)?;
sp.finish_and_clear();

match outcome {
Expand Down
21 changes: 9 additions & 12 deletions src/cmd/restack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand All @@ -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, &current_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(&current_parent_tip, &stored_parent_head, branch_name)?;
let outcome = git::rebase_onto_for_branch(
&current_parent_tip,
&stored_parent_head,
branch_name,
&current_root,
)?;
sp.finish_and_clear();

match outcome {
Expand All @@ -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, &current_root) {
Ok(true) => {
ui::info(&format!(
"Dropped redundant commits from `{branch_name}`"
Expand Down Expand Up @@ -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");
}

Expand Down
15 changes: 7 additions & 8 deletions src/cmd/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&current_parent_tip, &stored_parent_head, branch_name)?;
let outcome = git::rebase_onto_for_branch(
&current_parent_tip,
&stored_parent_head,
branch_name,
&original_root,
)?;
sp.finish_and_clear();

match outcome {
Expand All @@ -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}`"
Expand Down
123 changes: 113 additions & 10 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RebaseOutcome> {
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<RebaseOutcome> {
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<RebaseOutcome> {
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<RebaseOutcome> {
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<RebaseOutcome> {
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),
Expand Down Expand Up @@ -376,14 +419,29 @@ fn parse_conflicting_files(stderr: &str) -> Vec<String> {
files.into_iter().collect()
}

/// Plain `git rebase <upstream> <branch>` — 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<bool> {
let (success, _, stderr) = run_git_with_status(&["rebase", upstream, branch])?;
fn rebase_impl(scope: Option<&str>, upstream: &str, branch: Option<&str>) -> Result<bool> {
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 {
Expand All @@ -392,6 +450,26 @@ pub fn rebase(upstream: &str, branch: &str) -> Result<bool> {
}
}

/// Plain `git rebase <upstream> <branch>` — 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<bool> {
rebase_impl(None, upstream, Some(branch))
}

/// Rebase the branch checked out in `dir` onto `upstream`.
pub fn rebase_at(dir: &str, upstream: &str) -> Result<bool> {
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<bool> {
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(())
Expand Down Expand Up @@ -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();
Expand Down
Loading