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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
/target
problems.md
.opencode/
.worktrees/
169 changes: 161 additions & 8 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ Examples:
ez push --stack
ez push -am \"feat: add auth\"
ez push -Am \"feat: add auth and new snapshots\"
ez push --repo owner/repo")]
ez push --repo owner/repo
ez push --remote fork --repo owner/repo")]
Push {
/// Create a draft PR
#[arg(long, conflicts_with = "no_pr")]
Expand Down Expand Up @@ -193,6 +194,10 @@ Examples:
/// Target repository for PR creation (owner/repo), overrides config
#[arg(long)]
repo: Option<String>,

/// Git remote to push to (overrides config), stored per-branch on first use
#[arg(long)]
remote: Option<String>,
},

/// Push and create/update PRs for the entire stack
Expand All @@ -201,8 +206,10 @@ Examples:
ez submit
ez submit --draft
ez submit --repo owner/repo
ez submit --remote fork --repo owner/repo

Note: --draft only affects newly created PRs. Existing PRs are not changed.
--remote and --repo apply to the first (bottom) branch only; children inherit.
Use `ez ready` to undraft an existing PR.")]
Submit {
/// Create draft PRs (only affects new PRs, not existing ones)
Expand All @@ -228,6 +235,10 @@ Use `ez ready` to undraft an existing PR.")]
/// Target repository for PR creation (owner/repo), overrides config
#[arg(long)]
repo: Option<String>,

/// Git remote to push to (overrides config), applied to first branch only
#[arg(long)]
remote: Option<String>,
},

/// Fetch trunk, detect merged PRs, clean up, and restack
Expand Down Expand Up @@ -387,26 +398,59 @@ Examples:
force: bool,
},

/// Merge the bottom PR of the current stack via GitHub
/// Merge the bottom PR of the current stack via GitHub, or merge locally with --local
#[command(after_help = "\
Examples:
ez merge
ez merge --yes
ez merge --stack --yes
ez merge --method squash
ez merge --method rebase")]
ez merge --method rebase
ez merge --local
ez merge --local --strategy rebase
ez merge --local --into main")]
Merge {
/// Merge method: merge, squash, or rebase
/// Merge method for GitHub merge: merge, squash, or rebase
#[arg(long, default_value = "squash")]
method: String,

/// Skip confirmation prompt (for agents and scripts)
#[arg(short, long)]
yes: bool,

/// Merge the current linear stack bottom-to-top
#[arg(long)]
/// Merge the current linear stack bottom-to-top (GitHub only)
#[arg(long, conflicts_with = "local")]
stack: bool,

/// Merge locally without GitHub (squash by default)
#[arg(long)]
local: bool,

/// Local merge strategy: squash (default), rebase, or merge
#[arg(long, default_value = "squash", requires = "local")]
strategy: String,

/// Target branch to merge into (default: parent)
#[arg(long, requires = "local")]
into: Option<String>,

/// Force merge even with warnings (e.g., merge strategy)
#[arg(long)]
force: bool,
},

/// Fold a range of branches into one (squash commits)
#[command(after_help = "\
Examples:
ez fold feat/a..feat/c
ez fold feat/a..feat/c --name feat/combined")]
Fold {
/// Branch range to fold (e.g., feat/a..feat/c)
range: String,

/// Name for the surviving branch (default: bottom of range)
#[arg(long)]
name: Option<String>,
},

/// Edit the PR for the current branch
Expand Down Expand Up @@ -885,15 +929,83 @@ mod tests {
.expect("parse merge");

match cli.command {
Commands::Merge { method, yes, stack } => {
Commands::Merge { method, yes, stack, local, .. } => {
assert_eq!(method, "rebase");
assert!(yes);
assert!(stack);
assert!(!local);
}
_ => panic!("expected merge command"),
}
}

#[test]
fn parses_merge_local_with_strategy() {
let cli = Cli::try_parse_from(["ez", "merge", "--local", "--strategy", "rebase"])
.expect("parse merge --local");

match cli.command {
Commands::Merge { local, strategy, into, force, stack, .. } => {
assert!(local);
assert_eq!(strategy, "rebase");
assert!(into.is_none());
assert!(!force);
assert!(!stack);
}
_ => panic!("expected merge command"),
}
}

#[test]
fn parses_merge_local_into_with_force() {
let cli = Cli::try_parse_from(["ez", "merge", "--local", "--into", "main", "--force"])
.expect("parse merge --local --into");

match cli.command {
Commands::Merge { local, into, force, strategy, .. } => {
assert!(local);
assert_eq!(into, Some("main".to_string()));
assert!(force);
assert_eq!(strategy, "squash"); // default
}
_ => panic!("expected merge command"),
}
}

#[test]
fn merge_stack_conflicts_with_local() {
let result = Cli::try_parse_from(["ez", "merge", "--stack", "--local"]);
assert!(result.is_err(), "--stack and --local should conflict");
}

#[test]
fn parses_fold_command() {
let cli = Cli::try_parse_from(["ez", "fold", "feat/a..feat/c"])
.expect("parse fold");

match cli.command {
Commands::Fold { range, name } => {
assert_eq!(range, "feat/a..feat/c");
assert!(name.is_none());
}
_ => panic!("expected fold command"),
}
}

#[test]
fn parses_fold_with_name() {
let cli = Cli::try_parse_from(["ez", "fold", "feat/a..feat/c", "--name", "feat/combined"])
.expect("parse fold --name");

match cli.command {
Commands::Fold { range, name } => {
assert_eq!(range, "feat/a..feat/c");
assert_eq!(name, Some("feat/combined".to_string()));
}
_ => panic!("expected fold command"),
}
}

#[test]
fn parses_push_repo_flag() {
let cli = Cli::try_parse_from(["ez", "push", "--repo", "owner/repo"])
Expand Down Expand Up @@ -946,10 +1058,51 @@ mod tests {
let cli = Cli::try_parse_from(["ez", "push"]).expect("parse push without --repo");

match cli.command {
Commands::Push { repo, .. } => {
Commands::Push { repo, remote, .. } => {
assert!(repo.is_none(), "--repo should default to None");
assert!(remote.is_none(), "--remote should default to None");
}
_ => panic!("expected push command"),
}
}

#[test]
fn parses_push_remote_flag() {
let cli = Cli::try_parse_from(["ez", "push", "--remote", "fork"])
.expect("parse push --remote");
match cli.command {
Commands::Push { remote, .. } => {
assert_eq!(remote.as_deref(), Some("fork"));
}
_ => panic!("expected push command"),
}
}

#[test]
fn parses_push_remote_and_repo_together() {
let cli = Cli::try_parse_from([
"ez", "push", "--remote", "fork", "--repo", "upstream/repo",
])
.expect("parse push --remote --repo");
match cli.command {
Commands::Push { remote, repo, .. } => {
assert_eq!(remote.as_deref(), Some("fork"));
assert_eq!(repo.as_deref(), Some("upstream/repo"));
}
_ => panic!("expected push command"),
}
}

#[test]
fn parses_submit_remote_flag() {
let cli = Cli::try_parse_from(["ez", "submit", "--remote", "fork", "--repo", "org/repo"])
.expect("parse submit --remote --repo");
match cli.command {
Commands::Submit { remote, repo, .. } => {
assert_eq!(remote.as_deref(), Some("fork"));
assert_eq!(repo.as_deref(), Some("org/repo"));
}
_ => panic!("expected submit command"),
}
}
}
142 changes: 142 additions & 0 deletions src/cmd/fold.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//! `ez fold` — combine a range of branches into one.

use anyhow::{Result, bail};

use crate::error::EzError;
use crate::git;
use crate::stack::StackState;
use crate::ui;

pub fn run(range: &str, name: Option<&str>) -> Result<()> {
let mut state = StackState::load()?;
if let Some(root) = git::current_linked_worktree_root()? {
ui::linked_worktree_warning(&root);
}

// Parse range: "feat/a..feat/b"
let parts: Vec<&str> = range.split("..").collect();
if parts.len() != 2 {
bail!(EzError::UserMessage(format!(
"Invalid range `{range}` — expected format: branch_a..branch_b"
)));
}
let bottom = parts[0];
let top = parts[1];

// Both must be managed
if !state.is_managed(bottom) {
bail!(EzError::BranchNotInStack(bottom.to_string()));
}
if !state.is_managed(top) {
bail!(EzError::BranchNotInStack(top.to_string()));
}

// top must be a descendant of bottom (walk up from top, check if bottom is in the path)
let path = state.path_to_trunk(top);
if !path.contains(&bottom.to_string()) {
bail!(EzError::UserMessage(format!(
"`{top}` is not a descendant of `{bottom}`"
)));
}

// Collect all branches in the range (top back to bottom, then reverse)
let mut branches_to_fold = Vec::new();
let mut current = top.to_string();
loop {
branches_to_fold.push(current.clone());
if current == bottom {
break;
}
let parent = state.get_branch(&current)?.parent.clone();
current = parent;
}
branches_to_fold.reverse(); // bottom first

if branches_to_fold.len() < 2 {
bail!(EzError::UserMessage(
"Fold range must include at least 2 branches".to_string(),
));
}

let current_root = git::repo_root()?;

// The surviving branch is bottom
let survivor = bottom;
let top_tip = git::rev_parse(top)?;

// Move bottom's branch pointer to top's tip so bottom now has all commits
// We need to be on a different branch to update the ref.
let checked_out = git::current_branch()?;
let need_detach = branches_to_fold.contains(&checked_out);
if need_detach {
// Checkout bottom's parent first
let parent_of_bottom = state.get_branch(bottom)?.parent.clone();
git::checkout(&parent_of_bottom)?;
}

// Force-update bottom to top's tip
git::update_branch_ref(bottom, &top_tip)?;

// Reparent children of all folded branches (except bottom) to bottom
for branch in &branches_to_fold[1..] {
let children = state.children_of(branch);
for child in children {
if !branches_to_fold.contains(&child) {
let m = state.get_branch_mut(&child)?;
m.parent = survivor.to_string();
m.parent_head = top_tip.clone();
}
}
state.remove_branch(branch);
// Delete the git branch ref and any worktree
if let Ok(Some(wt_path)) = git::branch_checked_out_elsewhere(branch, &current_root) {
if let Err(e) = git::worktree_remove(&wt_path) {
ui::warn(&format!("Could not remove worktree for `{branch}`: {e}"));
}
}
let _ = git::delete_branch(branch, true);
}

// Rename if --name provided and different from bottom
let final_name = if let Some(new_name) = name {
if new_name != bottom {
git::rename_branch(bottom, new_name)?;
// Update state: re-key the branch entry
if let Some(mut meta) = state.branches.remove(bottom) {
meta.name = new_name.to_string();
state.branches.insert(new_name.to_string(), meta);
}
// Reparent any children that point to old bottom name
let children = state.children_of(bottom);
for child in children {
let m = state.get_branch_mut(&child)?;
m.parent = new_name.to_string();
}
new_name
} else {
bottom
}
} else {
bottom
};

state.save()?;

// Switch to the surviving branch
git::checkout(final_name)?;

ui::success(&format!(
"Folded {} branches into `{final_name}`",
branches_to_fold.len()
));
ui::hint("Run `ez restack` to update any child branches");

ui::receipt(&serde_json::json!({
"cmd": "fold",
"range": range,
"survivor": final_name,
"branches_folded": branches_to_fold.len(),
}));

Ok(())
}
Loading