Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ Examples:
Switch {
/// Branch name or PR number to switch to directly
name: Option<String>,

/// Exit 0 and print worktree path without requiring shell cd integration
#[arg(long)]
no_cd_required: bool,
},

/// Show the visual stack tree with PR status
Expand Down
27 changes: 22 additions & 5 deletions src/cmd/amend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,14 @@ 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;
}
// Instead of skipping, detach worktree HEAD so rebase can proceed.
let worktree_path = if let Ok(Some(wt_path)) = git::branch_checked_out_elsewhere(child_name, &current_root) {
ui::info(&format!("Detaching `{child_name}` in worktree `{wt_path}` for rebase..."));
git::detach_worktree_head(&wt_path)?;
Some(wt_path)
} else {
None
};

let old_parent_head = state.get_branch(child_name)?.parent_head.clone();

Expand All @@ -83,8 +86,22 @@ pub fn run(message: Option<&str>, all: bool) -> Result<()> {
let child = state.get_branch_mut(child_name)?;
child.parent_head = current_head.clone();
ui::info(&format!("Restacked `{child_name}`"));

// Reattach worktree if we detached it.
if let Some(ref wt_path) = worktree_path {
if !git::reattach_worktree(wt_path, child_name)? {
ui::warn(&format!(
"Could not reattach `{child_name}` in worktree `{wt_path}` — \
worktree may have dirty files that conflict with rebased commits.\n \
Run `cd {wt_path} && git checkout {child_name}` to reattach manually."
));
}
}
}
git::RebaseOutcome::Conflict(conflict) => {
if let Some(ref wt_path) = worktree_path {
let _ = git::reattach_worktree(wt_path, child_name);
}
git::checkout(&current)?;
state.save()?;
rebase_conflict::report("amend", child_name, &current, &conflict, "ez restack");
Expand Down
71 changes: 65 additions & 6 deletions src/cmd/checkout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub(crate) fn worktree_map() -> HashMap<String, String> {
branch_worktree_map(git::worktree_list().unwrap_or_default())
}

#[cfg(test)]
fn worktree_edit_hint(wt_path: &str) -> String {
if wt_path.contains("/.worktrees/") {
format!(
Expand Down Expand Up @@ -70,13 +71,26 @@ pub(crate) fn switch_to(
state: &StackState,
target: &str,
wt_map: &HashMap<String, String>,
no_cd_required: bool,
) -> Result<()> {
let stale_warning = stale_switch_target_warning(state, target)?;

if let Some(wt_path) = wt_map.get(target) {
// Branch is in a worktree — print path to stdout for shell wrapper to cd.
ui::success(&format!("Switching to `{target}` in worktree `{wt_path}`"));
ui::hint(&worktree_edit_hint(wt_path));
if no_cd_required {
// Caller handles cd — just print path.
println!("{wt_path}");
return Ok(());
}
// Print path for shell wrapper to intercept.
// The wrapper does `cd $path`. Without the wrapper, this just prints the path.
ui::info(&format!(
"Branch `{target}` is in worktree `{wt_path}`"
));
ui::hint(&format!(
"If your shell didn't change directory, run:\n \
cd {wt_path}\n \
Or enable auto-cd: eval \"$(ez shell-init)\""
));
println!("{wt_path}");
} else {
git::checkout(target)?;
Expand All @@ -91,7 +105,7 @@ pub(crate) fn switch_to(
Ok(())
}

pub fn run(name: Option<&str>) -> Result<()> {
pub fn run(name: Option<&str>, no_cd_required: bool) -> Result<()> {
let state = StackState::load()?;
let current = git::current_branch()?;
let wt_map = worktree_map();
Expand Down Expand Up @@ -121,7 +135,7 @@ pub fn run(name: Option<&str>) -> Result<()> {
return Ok(());
}

switch_to(&state, &target, &wt_map)?;
switch_to(&state, &target, &wt_map, no_cd_required)?;
return Ok(());
}

Expand Down Expand Up @@ -174,7 +188,7 @@ pub fn run(name: Option<&str>) -> Result<()> {
return Ok(());
}

switch_to(&state, selected, &wt_map)?;
switch_to(&state, selected, &wt_map, false)?;

Ok(())
}
Expand Down Expand Up @@ -302,4 +316,49 @@ mod tests {
);
assert!(!wt_map.contains_key("detached"));
}

#[test]
fn switch_to_no_cd_required_prints_path_and_returns() {
let _guard = take_env_lock();
let repo = init_git_repo("checkout-no-cd-required");
let _cwd = CwdGuard::enter(&repo);

let parent_head = git::rev_parse("main").expect("main head");
git::create_branch_at("feat/test", "main").expect("create branch");

let mut state = StackState::new("main".to_string());
state.add_branch("feat/test", "main", &parent_head, None, None);
state.save().expect("save state");

// Create a worktree so the branch appears in wt_map.
let wt_path = git::worktree_path("feat/test").expect("worktree path");
git::worktree_add(&wt_path, "feat/test").expect("add worktree");

let wt_map = worktree_map();
assert!(wt_map.contains_key("feat/test"));

// With no_cd_required=true, switch_to should return Ok without error.
switch_to(&state, "feat/test", &wt_map, true).expect("no_cd_required switch should succeed");

// We should still be on main (no actual checkout happened).
assert_eq!(git::current_branch().expect("branch"), "main");
}

#[test]
fn switch_to_trunk_with_no_cd_required_does_plain_checkout() {
let _guard = take_env_lock();
let repo = init_git_repo("checkout-trunk-no-cd");
let _cwd = CwdGuard::enter(&repo);

git::create_branch("temp-branch").expect("create temp");

let state = StackState::new("main".to_string());
state.save().expect("save state");

let wt_map = worktree_map();

// Trunk is not in wt_map as a worktree target, so no_cd_required shouldn't matter.
switch_to(&state, "main", &wt_map, true).expect("switch to trunk should succeed");
assert_eq!(git::current_branch().expect("branch"), "main");
}
}
29 changes: 23 additions & 6 deletions src/cmd/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,19 +71,36 @@ 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;
}
// Instead of skipping, detach worktree HEAD so rebase can proceed.
let worktree_path = if let Ok(Some(wt_path)) = git::branch_checked_out_elsewhere(child, &current_root) {
ui::info(&format!("Detaching `{child}` in worktree `{wt_path}` for rebase..."));
git::detach_worktree_head(&wt_path)?;
Some(wt_path)
} else {
None
};

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)? {
git::RebaseOutcome::RebasingComplete => {}
git::RebaseOutcome::RebasingComplete => {
// Reattach worktree if we detached it.
if let Some(ref wt_path) = worktree_path {
if !git::reattach_worktree(wt_path, child)? {
ui::warn(&format!(
"Could not reattach `{child}` in worktree `{wt_path}` — \
worktree may have dirty files that conflict with rebased commits.\n \
Run `cd {wt_path} && git checkout {child}` to reattach manually."
));
}
}
}
git::RebaseOutcome::Conflict(conflict) => {
if let Some(ref wt_path) = worktree_path {
let _ = git::reattach_worktree(wt_path, child);
}
// Save progress so the user can fix conflicts and continue with `ez restack`.
state.save()?;
git::checkout(&current)?;
Expand Down
30 changes: 26 additions & 4 deletions src/cmd/move_branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,23 @@ pub fn run(onto: &str, force: bool) -> 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;
}
// Instead of skipping, detach worktree HEAD so rebase can proceed.
let worktree_path = if let Ok(Some(wt_path)) = git::branch_checked_out_elsewhere(child_name, &current_root) {
ui::info(&format!("Detaching `{child_name}` in worktree `{wt_path}` for rebase..."));
git::detach_worktree_head(&wt_path)?;
Some(wt_path)
} else {
None
};

let child = state.get_branch(child_name)?;
let child_parent_head = child.parent_head.clone();

if child_parent_head == new_tip {
// Reattach if we detached but no rebase needed.
if let Some(ref wt_path) = worktree_path {
let _ = git::reattach_worktree(wt_path, child_name);
}
continue;
}

Expand All @@ -124,8 +132,22 @@ pub fn run(onto: &str, force: bool) -> Result<()> {
child.parent_head = new_tip.clone();
restacked += 1;
ui::info(&format!("Restacked `{child_name}` onto `{current}`"));

// Reattach worktree if we detached it.
if let Some(ref wt_path) = worktree_path {
if !git::reattach_worktree(wt_path, child_name)? {
ui::warn(&format!(
"Could not reattach `{child_name}` in worktree `{wt_path}` — \
worktree may have dirty files that conflict with rebased commits.\n \
Run `cd {wt_path} && git checkout {child_name}` to reattach manually."
));
}
}
}
git::RebaseOutcome::Conflict(conflict) => {
if let Some(ref wt_path) = worktree_path {
let _ = git::reattach_worktree(wt_path, child_name);
}
state.save()?;
rebase_conflict::report("move", child_name, &current, &conflict, "ez restack");
bail!(EzError::RebaseConflict(child_name.clone()));
Expand Down
8 changes: 4 additions & 4 deletions src/cmd/navigate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pub fn up() -> Result<()> {

let children = state.children_of(&current);
let target = up_target(&children)?;
switch_to(&state, &target, &worktree_map())?;
switch_to(&state, &target, &worktree_map(), false)?;
ui::success(&format!(
"Moved up: {} → {}",
ui::branch_display(&current, false),
Expand All @@ -65,7 +65,7 @@ pub fn down() -> Result<()> {
let current = git::current_branch()?;

let parent = down_target(&state, &current)?;
switch_to(&state, &parent, &worktree_map())?;
switch_to(&state, &parent, &worktree_map(), false)?;
ui::success(&format!(
"Moved down: {} → {}",
ui::branch_display(&current, false),
Expand All @@ -80,7 +80,7 @@ pub fn top() -> Result<()> {
let current = git::current_branch()?;

let target = top_target(&state, &current)?;
switch_to(&state, &target, &worktree_map())?;
switch_to(&state, &target, &worktree_map(), false)?;
ui::success(&format!(
"Jumped to top: {} → {}",
ui::branch_display(&current, false),
Expand All @@ -95,7 +95,7 @@ pub fn bottom() -> Result<()> {
let current = git::current_branch()?;

let target = bottom_target(&state, &current)?;
switch_to(&state, &target, &worktree_map())?;
switch_to(&state, &target, &worktree_map(), false)?;
ui::success(&format!(
"Jumped to bottom: {} → {}",
ui::branch_display(&current, false),
Expand Down
35 changes: 27 additions & 8 deletions src/cmd/restack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ pub fn run(force: bool) -> 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 @@ -65,12 +64,14 @@ pub fn run(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, &current_root) {
ui::warn(&format!("Skipped `{branch_name}` (in worktree)"));
skipped += 1;
continue;
}
// Instead of skipping, detach worktree HEAD so rebase can proceed.
let worktree_path = if let Ok(Some(wt_path)) = git::branch_checked_out_elsewhere(branch_name, &current_root) {
ui::info(&format!("Detaching `{branch_name}` in worktree `{wt_path}` for rebase..."));
git::detach_worktree_head(&wt_path)?;
Some(wt_path)
} else {
None
};

// If all commits are redundant, skip rebase and just update metadata.
if redundant_branches.contains(branch_name) {
Expand All @@ -85,6 +86,10 @@ pub fn run(force: bool) -> Result<()> {
"action": "redundant_skip",
"parent": parent,
}));
// Reattach worktree if we detached it.
if let Some(ref wt_path) = worktree_path {
let _ = git::reattach_worktree(wt_path, branch_name);
}
restacked += 1;
continue;
}
Expand Down Expand Up @@ -166,8 +171,22 @@ pub fn run(force: bool) -> Result<()> {
"redundant_commits": redundant_count,
"safe_rebase_mode": has_merges,
}));

// Reattach worktree if we detached it.
if let Some(ref wt_path) = worktree_path {
if !git::reattach_worktree(wt_path, branch_name)? {
ui::warn(&format!(
"Could not reattach `{branch_name}` in worktree `{wt_path}` — \
worktree may have dirty files that conflict with rebased commits.\n \
Run `cd {wt_path} && git checkout {branch_name}` to reattach manually."
));
}
}
}
git::RebaseOutcome::Conflict(conflict) => {
if let Some(ref wt_path) = worktree_path {
let _ = git::reattach_worktree(wt_path, branch_name);
}
git::checkout(&original_branch)?;
state.save()?;
rebase_conflict::report("restack", branch_name, &parent, &conflict, "ez restack");
Expand All @@ -181,7 +200,7 @@ pub fn run(force: bool) -> Result<()> {

state.save()?;

if restacked == 0 && skipped == 0 {
if restacked == 0 {
ui::info("All branches are up to date — nothing to restack");
}

Expand Down
Loading