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
46 changes: 43 additions & 3 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,11 +270,30 @@ Examples:
/// Fetch trunk, refresh it locally, and rebase stale branches onto their latest parent tips
Restack,

/// Move up one branch in the stack
Up,
/// Move up one branch in the stack (toward child branches)
#[command(after_help = "\
Examples:
ez up
ez up feat/auth
ez up 42

When multiple branches stack on the current branch, use the menu in a terminal or pass the child name or PR number in scripts.")]
Up {
/// Child branch name or PR number (required when multiple children exist without a TTY)
branch: Option<String>,
},

/// Move down one branch in the stack (toward trunk)
Down,
#[command(after_help = "\
Examples:
ez down
ez down main

Optional branch must match the stack parent — useful for scripts to assert the destination.")]
Down {
/// Parent branch name (must match `ez parent`); omit to move to the stack parent
branch: Option<String>,
},

/// Move to the top of the stack
Top,
Expand Down Expand Up @@ -900,6 +919,27 @@ mod tests {
}
}

#[test]
fn parses_up_down_optional_branch() {
let up = Cli::try_parse_from(["ez", "up", "feat/child"]).expect("parse up");
match up.command {
Commands::Up { branch } => assert_eq!(branch.as_deref(), Some("feat/child")),
_ => panic!("expected up"),
}

let up_bare = Cli::try_parse_from(["ez", "up"]).expect("parse up bare");
match up_bare.command {
Commands::Up { branch } => assert!(branch.is_none()),
_ => panic!("expected up"),
}

let down = Cli::try_parse_from(["ez", "down", "main"]).expect("parse down");
match down.command {
Commands::Down { branch } => assert_eq!(branch.as_deref(), Some("main")),
_ => panic!("expected down"),
}
}

#[test]
fn parses_move_onto_without_value_for_custom_error() {
let cli = Cli::try_parse_from(["ez", "move", "--onto"])
Expand Down
196 changes: 182 additions & 14 deletions src/cmd/navigate.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,120 @@
use anyhow::{Result, bail};
use dialoguer::Select;
use std::collections::HashMap;
use std::io::{self, IsTerminal};
use std::path::Path;

use crate::cmd::checkout::{switch_to, worktree_map};
use crate::error::EzError;
use crate::git;
use crate::stack::StackState;
use crate::ui;

fn up_target(children: &[String]) -> Result<String> {
fn worktree_picker_suffix(wt_map: &HashMap<String, String>, name: &str) -> String {
wt_map
.get(name)
.filter(|p| p.contains("/.worktrees/"))
.map(|path| {
let label = Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path.as_str());
format!(" {}", ui::dim(&format!("[wt: {label}]")))
})
.unwrap_or_default()
}

fn child_picker_labels(
state: &StackState,
children: &[String],
wt_map: &HashMap<String, String>,
) -> Vec<String> {
children
.first()
.cloned()
.ok_or_else(|| EzError::AlreadyAtTop.into())
.iter()
.map(|name| {
let branch_text = ui::branch_display(name, false);
let wt = worktree_picker_suffix(wt_map, name);
if let Some(meta) = state.branches.get(name)
&& let Some(n) = meta.pr_number
{
format!("{} {}{}", branch_text, ui::pr_badge(n, "OPEN", false), wt)
} else {
format!("{branch_text}{wt}")
}
})
.collect()
}

fn resolve_explicit_child(
state: &StackState,
current: &str,
arg: &str,
children: &[String],
) -> Result<String> {
if let Ok(pr_num) = arg.parse::<u64>() {
let found = state
.branches
.values()
.find(|m| m.pr_number == Some(pr_num) && children.contains(&m.name))
.map(|m| m.name.clone());

return found.ok_or_else(|| {
let listed = children.join(", ");
EzError::UserMessage(format!(
"no child of `{current}` has PR #{pr_num}\n → Child branches: {listed}\n → Run `ez up <branch>` or `ez up <pr-number>` for one of these"
))
.into()
});
}

if children.iter().any(|c| c == arg) {
return Ok(arg.to_string());
}

let listed = children.join(", ");
bail!(EzError::UserMessage(format!(
"`{arg}` is not a child branch of `{current}`\n → Child branches: {listed}\n → Run `ez up <branch>`"
)));
}

fn pick_upstream_child(
state: &StackState,
current: &str,
children: &[String],
explicit: Option<&str>,
wt_map: &HashMap<String, String>,
) -> Result<String> {
if children.is_empty() {
bail!(EzError::AlreadyAtTop);
}

if let Some(arg) = explicit {
return resolve_explicit_child(state, current, arg, children);
}

if children.len() == 1 {
return Ok(children[0].clone());
}

let stdin_tty = io::stdin().is_terminal();
let stderr_tty = io::stderr().is_terminal();
if stdin_tty && stderr_tty {
let labels = child_picker_labels(state, children, wt_map);
let selection = Select::new()
.with_prompt(format!(
"Multiple branches stack on `{}` — move up to",
current
))
.items(&labels)
.default(0)
.interact()?;
return Ok(children[selection].clone());
}

let listed = children.join(", ");
bail!(EzError::UserMessage(format!(
"multiple child branches stack on `{current}`: {listed}\n → Run `ez up <branch>` or `ez up <pr-number>` to choose one (no TTY for interactive pick)"
)));
}

fn down_target(state: &StackState, current: &str) -> Result<String> {
Expand All @@ -34,7 +138,10 @@ fn top_target(state: &StackState, current: &str) -> Result<String> {
fn bottom_target(state: &StackState, current: &str) -> Result<String> {
if state.is_trunk(current) {
let children = state.children_of(current);
return up_target(&children).map_err(|_| EzError::AlreadyAtBottom.into());
if children.is_empty() {
bail!(EzError::AlreadyAtBottom);
}
return pick_upstream_child(state, current, &children, None, &HashMap::new());
}

let bottom = state.stack_bottom(current);
Expand All @@ -44,13 +151,14 @@ fn bottom_target(state: &StackState, current: &str) -> Result<String> {
Ok(bottom)
}

pub fn up() -> Result<()> {
pub fn up(explicit_child: Option<&str>) -> Result<()> {
let state = StackState::load()?;
let current = git::current_branch()?;
let wt_map = worktree_map();

let children = state.children_of(&current);
let target = up_target(&children)?;
switch_to(&state, &target, &worktree_map())?;
let target = pick_upstream_child(&state, &current, &children, explicit_child, &wt_map)?;
switch_to(&state, &target, &wt_map)?;
ui::success(&format!(
"Moved up: {} → {}",
ui::branch_display(&current, false),
Expand All @@ -60,12 +168,21 @@ pub fn up() -> Result<()> {
Ok(())
}

pub fn down() -> Result<()> {
pub fn down(explicit_parent: Option<&str>) -> Result<()> {
let state = StackState::load()?;
let current = git::current_branch()?;

let parent = down_target(&state, &current)?;
switch_to(&state, &parent, &worktree_map())?;
if let Some(exp) = explicit_parent {
if exp != parent {
bail!(EzError::UserMessage(format!(
"`{exp}` is not the stack parent of `{current}` (expected `{parent}`)\n → Run `ez down` or `ez down {parent}`"
)));
}
}

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

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

let target = bottom_target(&state, &current)?;
switch_to(&state, &target, &worktree_map())?;
let wt_map = worktree_map();
switch_to(&state, &target, &wt_map)?;
ui::success(&format!(
"Jumped to bottom: {} → {}",
ui::branch_display(&current, false),
Expand All @@ -117,12 +236,55 @@ mod tests {
state
}

fn fork_state() -> StackState {
let mut state = StackState::new("main".to_string());
state.add_branch("line-a", "main", "aaa", None, None);
state.add_branch("line-b", "main", "bbb", None, None);
state.branches.get_mut("line-a").unwrap().pr_number = Some(10);
state.branches.get_mut("line-b").unwrap().pr_number = Some(20);
state
}

#[test]
fn up_target_errors_without_children() {
let err = up_target(&[]).expect_err("expected no children");
fn pick_upstream_errors_without_children() {
let state = sample_state();
let err = pick_upstream_child(&state, "feat/c", &[], None, &HashMap::new())
.expect_err("expected no children");
assert!(err.to_string().contains("already at the top"));
}

#[test]
fn pick_upstream_single_child_without_explicit() {
let state = sample_state();
let children = state.children_of("feat/b");
let got = pick_upstream_child(&state, "feat/b", &children, None, &HashMap::new())
.expect("one child");
assert_eq!(got, "feat/c");
}

#[test]
fn resolve_explicit_child_by_name() {
let state = fork_state();
let children = state.children_of("main");
let got = resolve_explicit_child(&state, "main", "line-b", &children).expect("resolve");
assert_eq!(got, "line-b");
}

#[test]
fn resolve_explicit_child_by_pr() {
let state = fork_state();
let children = state.children_of("main");
let got = resolve_explicit_child(&state, "main", "20", &children).expect("resolve pr");
assert_eq!(got, "line-b");
}

#[test]
fn resolve_explicit_child_rejects_wrong_name() {
let state = fork_state();
let children = state.children_of("main");
assert!(resolve_explicit_child(&state, "main", "nope", &children).is_err());
}

#[test]
fn down_target_validates_trunk_and_unmanaged() {
let state = sample_state();
Expand All @@ -143,4 +305,10 @@ mod tests {
assert!(top_target(&state, "feat/c").is_err());
assert!(bottom_target(&state, "feat/a").is_err());
}

#[test]
fn bottom_from_trunk_errors_when_no_children() {
let state = StackState::new("main".to_string());
assert!(bottom_target(&state, "main").is_err());
}
}
4 changes: 2 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,8 @@ fn run(cli: Cli) -> Result<()> {
force,
} => cmd::sync::run(dry_run, autostash, force),
Commands::Restack => cmd::restack::run(),
Commands::Up => cmd::navigate::up(),
Commands::Down => cmd::navigate::down(),
Commands::Up { branch } => cmd::navigate::up(branch.as_deref()),
Commands::Down { branch } => cmd::navigate::down(branch.as_deref()),
Commands::Top => cmd::navigate::top(),
Commands::Bottom => cmd::navigate::bottom(),
Commands::Switch { name } => cmd::checkout::run(name.as_deref()),
Expand Down
Loading