From e5b00a8e721b200c66cc12f009a8cacc02dccc19 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 3 Jun 2026 12:35:10 +0200 Subject: [PATCH] feat(rust): port stack new to native Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust binary now serves ``mergify stack new `` natively. The Python implementation (``mergify_cli/stack/new.py``, its click registration in ``mergify_cli/stack/cli.py``, and the unit suite in ``mergify_cli/tests/stack/test_new.py``) is removed in the same PR — the port-and-delete rule keeps a single live copy of every command. This is the first ``stack`` subcommand to land natively; the rest still flow through the Python shim. The dispatcher peeks at ``argv[0]`` after ``stack`` — when it's ``new``, the rest is secondary-parsed by clap and dispatched into ``mergify_stack::commands::new``; everything else continues to forward to ``mergify-py-shim`` verbatim. ``mergify stack new [-b REMOTE/BRANCH] [--checkout/--no-checkout]``: 1. Resolves the base ref. ``--base origin/main`` is used verbatim; otherwise ``mergify_stack::trunk::get_trunk`` mirrors the Python ``utils.get_trunk`` chain: ``branch..{remote,merge}`` tracking → ``refs/remotes/origin/HEAD`` fallback → set tracking on the current branch with a yellow notice → ``StackNotFound`` when both fail. 2. ``git fetch `` brings the base up to date. 3. ``git checkout --track -b `` (default) or ``git branch --track `` (``--no-checkout``) creates the branch. Exit-code mapping matches Python: ``StackNotFound`` when the trunk can't be resolved, ``GenericError`` for fetch/branch failures, ``Success`` on the happy path. The new ``mergify-stack::trunk`` module is reusable by future ``stack drop``/``stack edit``/``stack sync`` ports — the helpers ``git_get_branch_name``, ``git_get_target_branch``, ``git_get_target_remote``, and ``get_trunk`` are public. ``mergify stack new --help`` is now served by clap; the bare ``mergify stack --help`` still shims to Python and lists the remaining Python-only subcommands. Co-Authored-By: Claude Opus 4.7 Change-Id: Ia8c23565155a0a64bd4f8b4b66fd493ab1fc0cc5 --- crates/mergify-cli/src/main.rs | 141 +++++++++- crates/mergify-stack/src/commands/mod.rs | 6 + crates/mergify-stack/src/commands/new.rs | 338 +++++++++++++++++++++++ crates/mergify-stack/src/lib.rs | 25 +- crates/mergify-stack/src/trunk.rs | 295 ++++++++++++++++++++ mergify_cli/stack/cli.py | 31 --- mergify_cli/stack/new.py | 79 ------ mergify_cli/tests/stack/test_new.py | 206 -------------- 8 files changed, 794 insertions(+), 327 deletions(-) create mode 100644 crates/mergify-stack/src/commands/mod.rs create mode 100644 crates/mergify-stack/src/commands/new.rs create mode 100644 crates/mergify-stack/src/trunk.rs delete mode 100644 mergify_cli/stack/new.py delete mode 100644 mergify_cli/tests/stack/test_new.py diff --git a/crates/mergify-cli/src/main.rs b/crates/mergify-cli/src/main.rs index 83e6c887..4cbfa7ee 100644 --- a/crates/mergify-cli/src/main.rs +++ b/crates/mergify-cli/src/main.rs @@ -156,6 +156,7 @@ const NATIVE_COMMANDS: &[(&str, &str)] = &[ ("freeze", "create"), ("freeze", "update"), ("freeze", "delete"), + ("stack", "new"), // Internal Python migration helpers. Listed so `looks_native` // routes `mergify _internal …` past the shim fallback when // clap rejects it, but they stay hidden from `--help` (see @@ -209,6 +210,19 @@ enum NativeCommand { /// JSON array of `{change_id, pull}` records. Wire format is /// not stable. InternalStackRemoteChanges(InternalStackRemoteChangesOpts), + /// `mergify stack new [--base REMOTE/BRANCH] + /// [--checkout/--no-checkout]` — create a new stack branch + /// tracking the resolved trunk. First stack subcommand to land + /// natively; the rest still shim to Python. + StackNew(StackNewOpts), +} + +struct StackNewOpts { + name: String, + /// `Some((remote, branch))` for an explicit `--base`; `None` + /// means "resolve the trunk". + base: Option<(String, String)>, + checkout: bool, } struct InternalStackLocalCommitsOpts { @@ -451,13 +465,40 @@ fn detect_dispatch(argv: &[String]) -> Option { Some(dispatch_from_parsed(parsed)) } +/// Route a captured `mergify stack ` invocation to either +/// the native stack subcommand handler or the Python shim. +/// +/// `stack` is a hybrid group during the port: today only `new` is +/// native, every other subcommand still runs through `mergify-py-shim`. +/// The decision is made by inspecting the first positional arg +/// after `stack` — if it names a natively-ported subcommand, we +/// secondary-parse the rest with clap and dispatch native; +/// otherwise we forward the whole argv to Python verbatim. +/// +/// `--help` for shimmed subcommands (and the bare `stack --help`) +/// falls through to Python, which prints the full help listing +/// including the Python-only subcommands. Adding new native stack +/// subcommands later means adding a branch here and a matching +/// `NATIVE_COMMANDS` entry. +fn dispatch_stack(debug: bool, args: Vec) -> Dispatch { + if args.first().is_some_and(|a| a == "new") { + // `args[0]` is `"new"` — clap consumes it as the program + // name in the secondary parse, leaving `args[1..]` as the + // actual arguments. + let parsed = match StackNewCli::try_parse_from(&args) { + Ok(parsed) => parsed, + Err(err) => err.exit(), + }; + return Dispatch::Native(NativeCommand::StackNew(StackNewOpts::from(parsed))); + } + Dispatch::Shim(inject_global_flags(debug, prepend_one("stack", args))) +} + #[allow(clippy::too_many_lines)] // mostly mechanical match arms fn dispatch_from_parsed(parsed: CliRoot) -> Dispatch { let debug = parsed.debug; match parsed.command { - Subcommands::Stack(ShimmedArgs { args }) => { - Dispatch::Shim(inject_global_flags(debug, prepend_one("stack", args))) - } + Subcommands::Stack(ShimmedArgs { args }) => dispatch_stack(debug, args), Subcommands::Internal(InternalArgs { command: InternalSubcommand::StackLocalCommits(InternalStackLocalCommitsArgs { @@ -1094,6 +1135,40 @@ fn run_native(cmd: NativeCommand) -> ExitCode { ) .await .map(|()| mergify_core::ExitCode::Success), + NativeCommand::StackNew(opts) => { + let base = opts + .base + .map(|(remote, branch)| mergify_stack::commands::new::Base { + remote, + branch, + }); + let outcome = + mergify_stack::commands::new::run(None, &opts.name, base, opts.checkout)?; + if let Some(auto_set) = &outcome.upstream_auto_set { + // Yellow notice — matches `utils.get_trunk`'s + // print when it auto-sets upstream tracking. + eprintln!( + "Upstream not set for {branch}, automatically set to {remote}/{target}", + branch = auto_set.current_branch, + remote = auto_set.remote, + target = auto_set.branch, + ); + } + println!( + "Created branch '{name}' tracking {base}", + name = outcome.branch_name, + base = outcome.base_refspec, + ); + if outcome.checked_out { + println!("Switched to branch '{}'", outcome.branch_name); + } else { + println!( + "Run 'git checkout {}' to switch to the new branch", + outcome.branch_name, + ); + } + Ok(mergify_core::ExitCode::Success) + } NativeCommand::InternalStackLocalCommits(opts) => { // Run `git log` for the stack range, parse each // commit's `Change-Id:` trailer, emit a JSON array @@ -1239,6 +1314,66 @@ enum InternalSubcommand { StackRemoteChanges(InternalStackRemoteChangesArgs), } +/// `mergify stack new ` — clap definition for the natively- +/// ported `stack new` subcommand. Parsed as a side step after the +/// top-level clap pass captures `Stack(ShimmedArgs)`, so the rest +/// of the `stack` group still flows through the Python shim. +#[derive(Parser)] +#[command(name = "new", about = "Create a new stack branch")] +struct StackNewCli { + /// Name of the new branch. + name: String, + + /// Base branch to fork from, formatted as `REMOTE/BRANCH` + /// (e.g. `origin/main`). When omitted, the trunk is resolved + /// from the current branch's tracking info or + /// `refs/remotes/origin/HEAD`. + #[arg(long, short = 'b', value_parser = parse_remote_branch)] + base: Option<(String, String)>, + + /// Checkout the new branch after creation. This is the default. + /// Pass `--no-checkout` to keep the current branch checked out. + #[arg( + long = "checkout", + action = clap::ArgAction::SetTrue, + conflicts_with = "no_checkout", + )] + #[allow(dead_code)] // default-on; only consumed for `conflicts_with` + checkout: bool, + + /// Leave the current branch checked out and just create the + /// new branch ref. + #[arg(long = "no-checkout", action = clap::ArgAction::SetTrue)] + no_checkout: bool, +} + +impl From for StackNewOpts { + fn from(cli: StackNewCli) -> Self { + Self { + name: cli.name, + base: cli.base, + // Default is checkout-on; `--no-checkout` is the only + // way to flip it. `--checkout` is accepted for parity + // with the Python click flag pair but is a no-op since + // it matches the default. + checkout: !cli.no_checkout, + } + } +} + +/// Parse a `REMOTE/BRANCH` argument into its two parts. Matches +/// the Python `trunk_type` click callback in +/// `mergify_cli/stack/cli.py`: split on the first `/`, so branch +/// names containing `/` (e.g. `release/2026.06`) survive intact; +/// the error message is kept verbatim so users see the same text +/// regardless of which `stack` subcommand is parsing. +fn parse_remote_branch(value: &str) -> Result<(String, String), String> { + value + .split_once('/') + .map(|(r, b)| (r.to_string(), b.to_string())) + .ok_or_else(|| "Trunk is invalid. It must be origin/branch-name".to_string()) +} + #[derive(clap::Args)] struct InternalStackLocalCommitsArgs { /// Base revision — anything `git` accepts (typically a merge- diff --git a/crates/mergify-stack/src/commands/mod.rs b/crates/mergify-stack/src/commands/mod.rs new file mode 100644 index 00000000..61b75561 --- /dev/null +++ b/crates/mergify-stack/src/commands/mod.rs @@ -0,0 +1,6 @@ +//! Native implementations of `mergify stack `. One +//! module per subcommand. The `Stack(StackArgs)` variant in the +//! main binary dispatches into here for ported subcommands; the +//! rest still shim to Python. + +pub mod new; diff --git a/crates/mergify-stack/src/commands/new.rs b/crates/mergify-stack/src/commands/new.rs new file mode 100644 index 00000000..49bc2e62 --- /dev/null +++ b/crates/mergify-stack/src/commands/new.rs @@ -0,0 +1,338 @@ +//! `mergify stack new ` — create a new stack branch tracking +//! the resolved trunk (or an explicit `--base /`). +//! Port of `mergify_cli/stack/new.py::stack_new`. + +use std::path::Path; +use std::process::Command; + +use mergify_core::CliError; + +use crate::trunk; + +/// Base ref the new branch should fork from. `None` means "resolve +/// it via [`trunk::get_trunk`] using the current branch's tracking +/// info or `origin/HEAD`". +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Base { + pub remote: String, + pub branch: String, +} + +impl Base { + fn refspec(&self) -> String { + format!("{}/{}", self.remote, self.branch) + } +} + +/// Side-effect log we surface to the human-mode caller so it can +/// render the same messages the Python implementation did. Keeps +/// `run` itself printing-free (callers handle stdout/stderr). +#[derive(Debug, Default, Clone)] +pub struct Outcome { + /// Set when `get_trunk` had to write `branch..{remote,merge}` + /// — Python prints a yellow "Upstream not set …" notice in + /// that case. + pub upstream_auto_set: Option, + pub branch_name: String, + pub base_refspec: String, + pub checked_out: bool, +} + +#[derive(Debug, Clone)] +pub struct UpstreamAutoSet { + pub current_branch: String, + pub remote: String, + pub branch: String, +} + +/// Create a new stack branch. +/// +/// `name` — the branch to create. +/// `base` — `Some((remote, branch))` to fork from an explicit ref, +/// `None` to resolve the trunk (and lazily set tracking on the +/// current branch as a side effect, mirroring Python). +/// `checkout` — when true, `git checkout --track -b`; otherwise +/// `git branch --track` and the user is left on the original +/// branch. +/// +/// Errors: +/// - [`CliError::StackNotFound`] if the trunk can't be resolved +/// (no upstream tracking AND no `origin/HEAD`). Matches Python's +/// `sys.exit(ExitCode.STACK_NOT_FOUND)`. +/// - [`CliError::Generic`] for `git fetch` / `git checkout` / +/// `git branch` failures. Matches Python's +/// `sys.exit(ExitCode.GENERIC_ERROR)` after a failed branch +/// create, plus the raise on a failed fetch. +pub fn run( + repo_dir: Option<&Path>, + name: &str, + base: Option, + checkout: bool, +) -> Result { + let (base, upstream_auto_set) = if let Some(b) = base { + (b, None) + } else { + let trunk = trunk::get_trunk(repo_dir).map_err(|e| { + // Preserve the underlying error so users see *why* the + // trunk couldn't be resolved (missing `origin/HEAD`, + // failed `git branch --set-upstream-to`, not a git + // repo, …). The wrapper exit code stays + // `STACK_NOT_FOUND`, matching the Python flow. + CliError::StackNotFound(format!( + "could not determine trunk branch ({e}). Please set \ + upstream tracking or use --base to specify the base \ + branch." + )) + })?; + let auto_set = trunk.tracking_set.then(|| UpstreamAutoSet { + current_branch: trunk.current_branch.clone(), + remote: trunk.remote.clone(), + branch: trunk.branch.clone(), + }); + ( + Base { + remote: trunk.remote, + branch: trunk.branch, + }, + auto_set, + ) + }; + + run_git(repo_dir, &["fetch", &base.remote, &base.branch]).map_err(|e| { + CliError::Generic(format!( + "failed to fetch from {remote}: {e}", + remote = base.remote + )) + })?; + + let base_ref = base.refspec(); + let create_args: Vec<&str> = if checkout { + vec!["checkout", "--track", "-b", name, &base_ref] + } else { + vec!["branch", "--track", name, &base_ref] + }; + run_git(repo_dir, &create_args) + .map_err(|e| CliError::Generic(format!("failed to create branch '{name}': {e}")))?; + + Ok(Outcome { + upstream_auto_set, + branch_name: name.to_string(), + base_refspec: base_ref, + checked_out: checkout, + }) +} + +fn run_git(repo_dir: Option<&Path>, args: &[&str]) -> Result<(), CliError> { + let mut cmd = Command::new("git"); + if let Some(dir) = repo_dir { + cmd.arg("-C").arg(dir); + } + cmd.args(args); + let output = cmd + .output() + .map_err(|e| CliError::Generic(format!("failed to spawn `git {}`: {e}", args.join(" "))))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(CliError::Generic(if stderr.is_empty() { + format!("`git {}` failed", args.join(" ")) + } else { + stderr + })); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command as StdCommand; + use tempfile::TempDir; + + /// Build `` containing two repos: + /// - `upstream.git` — a bare repo with `main` (one commit) + /// - `local` — a clone of `upstream.git` with `origin` wired up + fn make_repo_pair() -> TempDir { + let workdir = tempfile::tempdir().unwrap(); + + let upstream = workdir.path().join("upstream.git"); + run_status(&[ + "init", + "-q", + "--bare", + "-b", + "main", + upstream.to_str().unwrap(), + ]); + + let seed = workdir.path().join("seed"); + std::fs::create_dir(&seed).unwrap(); + for args in [ + &["init", "-q", "-b", "main"][..], + &["config", "user.email", "t@e.com"], + &["config", "user.name", "T"], + &["commit", "--allow-empty", "-m", "root"], + &["remote", "add", "origin", upstream.to_str().unwrap()], + &["push", "-q", "origin", "main"], + ] { + run_in(&seed, args); + } + + let local = workdir.path().join("local"); + run_status(&[ + "clone", + "-q", + upstream.to_str().unwrap(), + local.to_str().unwrap(), + ]); + run_in(&local, &["config", "user.email", "t@e.com"]); + run_in(&local, &["config", "user.name", "T"]); + workdir + } + + fn local_path(workdir: &Path) -> std::path::PathBuf { + workdir.join("local") + } + + fn run_in(dir: &Path, args: &[&str]) { + let ok = StdCommand::new("git") + .arg("-C") + .arg(dir) + .args(args) + .status() + .unwrap() + .success(); + assert!(ok, "git -C {dir:?} {args:?} failed"); + } + + fn run_status(args: &[&str]) { + let ok = StdCommand::new("git") + .args(args) + .status() + .unwrap() + .success(); + assert!(ok, "git {args:?} failed"); + } + + fn branch_exists(dir: &Path, name: &str) -> bool { + StdCommand::new("git") + .arg("-C") + .arg(dir) + .args(["rev-parse", "--verify", "--quiet", name]) + .status() + .unwrap() + .success() + } + + fn current_branch(dir: &Path) -> String { + let out = StdCommand::new("git") + .arg("-C") + .arg(dir) + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .unwrap(); + String::from_utf8(out.stdout).unwrap().trim().to_string() + } + + #[test] + fn creates_branch_with_checkout_using_trunk() { + let work = make_repo_pair(); + let local = local_path(work.path()); + + let outcome = run(Some(&local), "feature-x", None, true).unwrap(); + assert_eq!(outcome.branch_name, "feature-x"); + assert_eq!(outcome.base_refspec, "origin/main"); + assert!(outcome.checked_out); + assert!(branch_exists(&local, "feature-x")); + assert_eq!(current_branch(&local), "feature-x"); + } + + #[test] + fn creates_branch_without_checkout() { + let work = make_repo_pair(); + let local = local_path(work.path()); + + let outcome = run(Some(&local), "feature-y", None, false).unwrap(); + assert!(!outcome.checked_out); + assert!(branch_exists(&local, "feature-y")); + assert_eq!(current_branch(&local), "main"); + } + + #[test] + fn creates_branch_from_explicit_base() { + let work = make_repo_pair(); + let local = local_path(work.path()); + // Add a second branch to the upstream so we have a non-main + // base to fork from. + let upstream = work.path().join("upstream.git"); + // Push a "develop" branch from the local clone. + run_in(&local, &["checkout", "-q", "-b", "develop"]); + run_in(&local, &["commit", "--allow-empty", "-m", "dev"]); + run_in(&local, &["push", "-q", "origin", "develop"]); + run_in(&local, &["checkout", "-q", "main"]); + run_in(&local, &["branch", "-q", "-D", "develop"]); + // sanity + assert!( + StdCommand::new("git") + .arg("-C") + .arg(&upstream) + .args(["rev-parse", "--verify", "develop"]) + .status() + .unwrap() + .success() + ); + + let outcome = run( + Some(&local), + "feature-z", + Some(Base { + remote: "origin".to_string(), + branch: "develop".to_string(), + }), + true, + ) + .unwrap(); + assert_eq!(outcome.base_refspec, "origin/develop"); + assert!(branch_exists(&local, "feature-z")); + } + + #[test] + fn errors_when_branch_already_exists() { + let work = make_repo_pair(); + let local = local_path(work.path()); + run_in(&local, &["branch", "existing"]); + + let err = run(Some(&local), "existing", None, true).unwrap_err(); + match err { + CliError::Generic(msg) => { + assert!( + msg.contains("already exists") || msg.contains("existing"), + "unexpected error: {msg}" + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn errors_when_trunk_cannot_be_resolved() { + // Repo with no remotes at all -> origin/HEAD doesn't exist + // and the current branch has no upstream. + let dir = tempfile::tempdir().unwrap(); + for args in [ + &["init", "-q", "-b", "main"][..], + &["config", "user.email", "t@e.com"], + &["config", "user.name", "T"], + &["commit", "--allow-empty", "-m", "root"], + ] { + run_in(dir.path(), args); + } + + let err = run(Some(dir.path()), "feature", None, true).unwrap_err(); + match err { + CliError::StackNotFound(msg) => { + assert!(msg.contains("could not determine trunk branch")); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/crates/mergify-stack/src/lib.rs b/crates/mergify-stack/src/lib.rs index 488737a9..bfcbcc6a 100644 --- a/crates/mergify-stack/src/lib.rs +++ b/crates/mergify-stack/src/lib.rs @@ -1,16 +1,25 @@ //! Native pieces of `mergify stack`, ported from //! `mergify_cli/stack/`. //! -//! Today this crate ships the stack-discovery walker that backs -//! every stack subcommand: read the local commits in -//! `..`, parse each commit's `Change-Id:` trailer, -//! and return one structured record per commit. The Python side -//! reaches it via the hidden `_internal stack-local-commits` -//! subcommand on the `mergify` binary; once `mergify stack list` -//! itself is native the same module is reused without the -//! subprocess hop. +//! Today this crate ships: +//! - the stack-discovery walker that backs every stack subcommand: +//! read the local commits in `..`, parse each +//! commit's `Change-Id:` trailer, and return one structured +//! record per commit. The Python side reaches it via the hidden +//! `_internal stack-local-commits` subcommand on the `mergify` +//! binary; once `mergify stack list` itself is native the same +//! module is reused without the subprocess hop. +//! - [`trunk::get_trunk`] — resolve `/` for the +//! current branch, ported from `utils.get_trunk`. Used by +//! `stack new` and reusable by future `stack drop`/`stack edit` +//! ports. +//! - [`commands::new`] — the native implementation of +//! `mergify stack new`. First stack subcommand to land natively +//! (the rest still shim to Python). pub mod change_id; +pub mod commands; pub mod local_commits; pub mod remote_changes; pub mod slug; +pub mod trunk; diff --git a/crates/mergify-stack/src/trunk.rs b/crates/mergify-stack/src/trunk.rs new file mode 100644 index 00000000..74e2ff7f --- /dev/null +++ b/crates/mergify-stack/src/trunk.rs @@ -0,0 +1,295 @@ +//! Resolve the "trunk" branch — the `/` a stack +//! should land on. Ported from `mergify_cli/utils.py::get_trunk` +//! and the small `git` helpers it leans on. +//! +//! Precedence matches the Python implementation: +//! 1. `branch..remote` + `branch..merge` git +//! config keys (set when the local branch tracks an upstream). +//! 2. Either or both missing → fall back to `origin/HEAD` via +//! `git symbolic-ref refs/remotes/origin/HEAD`, then *set* the +//! upstream tracking on the current branch and print a notice. +//! 3. Origin HEAD also missing → `CliError::Generic` so callers can +//! point the user at `git branch --set-upstream-to`. +//! +//! The set-upstream side effect mirrors `utils.get_trunk` exactly: +//! a Python regression test (`tests/test_utils.py`) pins the +//! behavior, so the Rust port has to preserve it. + +use std::path::Path; +use std::process::Command; + +use mergify_core::CliError; + +/// Outcome of [`get_trunk`] — the resolved `/` plus +/// whether tracking was auto-set as a side effect. Callers print a +/// notice when `tracking_set` is true. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Trunk { + pub remote: String, + pub branch: String, + /// True when we had to set `branch..{remote,merge}` as + /// a side effect (Python prints a yellow "Upstream not set …" + /// notice in that case). + pub tracking_set: bool, + /// The local branch the trunk was resolved for. Carried so the + /// caller can render the notice without re-querying git. + pub current_branch: String, +} + +impl Trunk { + /// `/` — the form callers pass to `git fetch` + /// and `git checkout --track -b`. + #[must_use] + pub fn refspec(&self) -> String { + format!("{}/{}", self.remote, self.branch) + } +} + +/// Resolve the trunk for the current branch in *`repo_dir`* (or +/// the process CWD when `None`). See module docs for the +/// precedence chain. +pub fn get_trunk(repo_dir: Option<&Path>) -> Result { + let current_branch = git_get_branch_name(repo_dir) + .map_err(|e| CliError::Generic(format!("can't get the current branch: {e}")))?; + + let target_branch = git_get_target_branch(repo_dir, ¤t_branch).ok(); + let target_remote = git_get_target_remote(repo_dir, ¤t_branch).ok(); + + if let (Some(branch), Some(remote)) = (target_branch.clone(), target_remote.clone()) { + return Ok(Trunk { + remote, + branch, + tracking_set: false, + current_branch, + }); + } + + let (default_remote, default_branch) = get_default_remote_branch(repo_dir).map_err(|e| { + CliError::Generic(format!( + "can't detect the remote target branch for {current_branch}: {e}" + )) + })?; + + let branch = target_branch.unwrap_or(default_branch); + let remote = target_remote.unwrap_or(default_remote); + + git_set_upstream(repo_dir, ¤t_branch, &remote, &branch).map_err(|e| { + CliError::Generic(format!("failed to set upstream on {current_branch}: {e}")) + })?; + + Ok(Trunk { + remote, + branch, + tracking_set: true, + current_branch, + }) +} + +/// `git rev-parse --abbrev-ref HEAD` — the short name of the +/// currently checked out branch. +pub fn git_get_branch_name(repo_dir: Option<&Path>) -> Result { + run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"]) +} + +/// `branch..merge` — the upstream ref the branch tracks, with +/// the `refs/heads/` prefix stripped to match Python's behavior. +pub fn git_get_target_branch(repo_dir: Option<&Path>, branch: &str) -> Result { + let key = format!("branch.{branch}.merge"); + let value = run_git(repo_dir, &["config", "--get", &key])?; + Ok(value + .strip_prefix("refs/heads/") + .map_or_else(|| value.clone(), str::to_owned)) +} + +/// `branch..remote` — the remote the branch tracks. +pub fn git_get_target_remote(repo_dir: Option<&Path>, branch: &str) -> Result { + let key = format!("branch.{branch}.remote"); + run_git(repo_dir, &["config", "--get", &key]) +} + +/// `git symbolic-ref refs/remotes/origin/HEAD` → `(remote, branch)`. +/// Used when the current branch has no upstream tracking set. +fn get_default_remote_branch(repo_dir: Option<&Path>) -> Result<(String, String), CliError> { + let raw = run_git(repo_dir, &["symbolic-ref", "refs/remotes/origin/HEAD"])?; + let stripped = raw.strip_prefix("refs/remotes/").unwrap_or(&raw); + let (remote, branch) = stripped + .split_once('/') + .ok_or_else(|| CliError::Generic(format!("unexpected origin/HEAD ref shape: {raw}")))?; + Ok((remote.to_owned(), branch.to_owned())) +} + +fn git_set_upstream( + repo_dir: Option<&Path>, + branch: &str, + remote: &str, + target_branch: &str, +) -> Result<(), CliError> { + let upstream = format!("{remote}/{target_branch}"); + run_git( + repo_dir, + &["branch", branch, "--set-upstream-to", &upstream], + ) + .map(|_| ()) +} + +fn run_git(repo_dir: Option<&Path>, args: &[&str]) -> Result { + let mut cmd = Command::new("git"); + if let Some(dir) = repo_dir { + cmd.arg("-C").arg(dir); + } + cmd.args(args); + let output = cmd + .output() + .map_err(|e| CliError::Generic(format!("failed to spawn `git {}`: {e}", args.join(" "))))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(CliError::Generic(format!( + "`git {}` failed: {}", + args.join(" "), + if stderr.is_empty() { + "no stderr".to_string() + } else { + stderr + }, + ))); + } + let stdout = String::from_utf8(output.stdout).map_err(|e| { + CliError::Generic(format!("`git {}` output is not UTF-8: {e}", args.join(" "))) + })?; + Ok(stdout.trim_end().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command as StdCommand; + use tempfile::TempDir; + + fn init_repo() -> TempDir { + let dir = tempfile::tempdir().unwrap(); + for args in [ + &["init", "-q", "-b", "main"][..], + &["config", "user.email", "test@example.com"], + &["config", "user.name", "Test"], + &["commit", "--allow-empty", "-m", "root"], + ] { + let ok = StdCommand::new("git") + .arg("-C") + .arg(dir.path()) + .args(args) + .status() + .unwrap() + .success(); + assert!(ok, "git {args:?} failed"); + } + dir + } + + fn run(dir: &Path, args: &[&str]) { + let ok = StdCommand::new("git") + .arg("-C") + .arg(dir) + .args(args) + .status() + .unwrap() + .success(); + assert!(ok, "git {args:?} failed"); + } + + #[test] + fn branch_name_returns_short_name() { + let dir = init_repo(); + let name = git_get_branch_name(Some(dir.path())).unwrap(); + assert_eq!(name, "main"); + } + + #[test] + fn target_branch_strips_refs_heads_prefix() { + let dir = init_repo(); + run( + dir.path(), + &["config", "branch.main.merge", "refs/heads/develop"], + ); + let v = git_get_target_branch(Some(dir.path()), "main").unwrap(); + assert_eq!(v, "develop"); + } + + #[test] + fn target_remote_reads_config() { + let dir = init_repo(); + run(dir.path(), &["config", "branch.main.remote", "upstream"]); + let v = git_get_target_remote(Some(dir.path()), "main").unwrap(); + assert_eq!(v, "upstream"); + } + + #[test] + fn get_trunk_uses_tracking_when_set() { + let dir = init_repo(); + run( + dir.path(), + &["config", "branch.main.merge", "refs/heads/release"], + ); + run(dir.path(), &["config", "branch.main.remote", "upstream"]); + let trunk = get_trunk(Some(dir.path())).unwrap(); + assert_eq!(trunk.remote, "upstream"); + assert_eq!(trunk.branch, "release"); + assert!(!trunk.tracking_set); + assert_eq!(trunk.refspec(), "upstream/release"); + } + + #[test] + fn get_trunk_falls_back_to_origin_head() { + // Simulate `origin/HEAD -> origin/main` with a bare upstream. + let upstream_dir = tempfile::tempdir().unwrap(); + let ok = StdCommand::new("git") + .arg("-C") + .arg(upstream_dir.path()) + .args(["init", "-q", "--bare", "-b", "main"]) + .status() + .unwrap() + .success(); + assert!(ok); + + let dir = init_repo(); + // Wire up `origin` and seed it with a commit on `main`. + run( + dir.path(), + &[ + "remote", + "add", + "origin", + upstream_dir.path().to_str().unwrap(), + ], + ); + run(dir.path(), &["push", "-q", "origin", "main"]); + // `git remote set-head` writes refs/remotes/origin/HEAD. + run(dir.path(), &["remote", "set-head", "origin", "main"]); + // Now create a local branch with no tracking and check it out. + run(dir.path(), &["checkout", "-q", "-b", "feature"]); + + let trunk = get_trunk(Some(dir.path())).unwrap(); + assert_eq!(trunk.remote, "origin"); + assert_eq!(trunk.branch, "main"); + assert!(trunk.tracking_set); + assert_eq!(trunk.current_branch, "feature"); + + // Side effect: `branch.feature.{remote,merge}` should now be + // set. Re-running should report `tracking_set = false`. + let trunk2 = get_trunk(Some(dir.path())).unwrap(); + assert!(!trunk2.tracking_set); + assert_eq!(trunk2.remote, "origin"); + assert_eq!(trunk2.branch, "main"); + } + + #[test] + fn get_trunk_errors_when_no_upstream_and_no_origin_head() { + let dir = init_repo(); + let err = get_trunk(Some(dir.path())).unwrap_err(); + match err { + CliError::Generic(msg) => { + assert!(msg.contains("can't detect the remote target branch")); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/mergify_cli/stack/cli.py b/mergify_cli/stack/cli.py index 42964464..bd9de2a9 100644 --- a/mergify_cli/stack/cli.py +++ b/mergify_cli/stack/cli.py @@ -16,7 +16,6 @@ from mergify_cli.stack import edit as stack_edit_mod from mergify_cli.stack import list as stack_list_mod from mergify_cli.stack import move as stack_move_mod -from mergify_cli.stack import new as stack_new_mod from mergify_cli.stack import note as stack_note_mod from mergify_cli.stack import open as stack_open_mod from mergify_cli.stack import push as stack_push_mod @@ -291,36 +290,6 @@ async def move( ) -@stack.command(help="Create a new stack branch") -@click.argument("name") -@click.option( - "--base", - "-b", - type=click.UNPROCESSED, - metavar="REMOTE/BRANCH", - default=None, - callback=trunk_type, - help="Base branch to create from (default: current trunk)", -) -@click.option( - "--checkout/--no-checkout", - default=True, - help="Whether to checkout the new branch after creation (default: checkout)", -) -@utils.run_with_asyncio -async def new( - *, - name: str, - base: tuple[str, str] | None, - checkout: bool, -) -> None: - await stack_new_mod.stack_new( - name=name, - base=base, - checkout=checkout, - ) - - @stack.command( help=( "Push/sync the pull requests stack. " diff --git a/mergify_cli/stack/new.py b/mergify_cli/stack/new.py deleted file mode 100644 index 2a617f04..00000000 --- a/mergify_cli/stack/new.py +++ /dev/null @@ -1,79 +0,0 @@ -# -# Copyright © 2021-2026 Mergify SAS -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -from __future__ import annotations - -import sys - -from mergify_cli import console -from mergify_cli import console_error -from mergify_cli import utils -from mergify_cli.exit_codes import ExitCode - - -async def stack_new( - *, - name: str, - base: tuple[str, str] | None, - checkout: bool, -) -> None: - """Create a new stack branch. - - Args: - name: The name of the new branch. - base: The base branch as (remote, branch) tuple, or None for default trunk. - checkout: Whether to checkout the new branch after creation. - """ - # Determine base branch - if base is None: - try: - trunk = await utils.get_trunk() - except utils.CommandError: - console_error( - "could not determine trunk branch. " - "Please set upstream tracking or use --base to specify the base branch.", - ) - sys.exit(ExitCode.STACK_NOT_FOUND) - else: - remote, base_branch = trunk.split("/", maxsplit=1) - else: - remote, base_branch = base - - # Fetch latest from remote - with console.status(f"Fetching latest from {remote}..."): - try: - await utils.git("fetch", remote, base_branch) - except utils.CommandError as e: - console_error(f"failed to fetch from {remote}: {e}") - raise - - # Create the branch from the fetched base - base_ref = f"{remote}/{base_branch}" - with console.status(f"Creating branch '{name}' from {base_ref}..."): - try: - if checkout: - await utils.git("checkout", "--track", "-b", name, base_ref) - else: - await utils.git("branch", "--track", name, base_ref) - except utils.CommandError as e: - console_error(f"failed to create branch '{name}': {e}") - sys.exit(ExitCode.GENERIC_ERROR) - - console.print(f"[green]Created branch '{name}' tracking {base_ref}[/]") - - if checkout: - console.print(f"[green]Switched to branch '{name}'[/]") - else: - console.print(f"[dim]Run 'git checkout {name}' to switch to the new branch[/]") diff --git a/mergify_cli/tests/stack/test_new.py b/mergify_cli/tests/stack/test_new.py deleted file mode 100644 index f41378ce..00000000 --- a/mergify_cli/tests/stack/test_new.py +++ /dev/null @@ -1,206 +0,0 @@ -# -# Copyright © 2021-2026 Mergify SAS -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest import mock - -import pytest - -from mergify_cli import utils -from mergify_cli.exit_codes import ExitCode -from mergify_cli.stack import new as stack_new_mod - - -if TYPE_CHECKING: - from mergify_cli.tests import utils as test_utils - - -class TestStackNew: - """Tests for the stack_new function.""" - - async def test_stack_new_creates_branch( - self, - git_mock: test_utils.GitMock, - ) -> None: - """Should create a new branch with upstream tracking.""" - git_mock.mock( - "config", - "--get", - "branch.current-branch.merge", - output="refs/heads/main", - ) - git_mock.mock( - "config", - "--get", - "branch.current-branch.remote", - output="origin", - ) - git_mock.mock("fetch", "origin", "main", output="") - git_mock.mock( - "checkout", - "--track", - "-b", - "my-feature", - "origin/main", - output="", - ) - - await stack_new_mod.stack_new( - name="my-feature", - base=None, - checkout=True, - ) - - assert git_mock.has_been_called_with("fetch", "origin", "main") - assert git_mock.has_been_called_with( - "checkout", - "--track", - "-b", - "my-feature", - "origin/main", - ) - - async def test_stack_new_with_custom_base( - self, - git_mock: test_utils.GitMock, - ) -> None: - """Should create branch from custom base.""" - git_mock.mock("fetch", "upstream", "develop", output="") - git_mock.mock( - "checkout", - "--track", - "-b", - "my-feature", - "upstream/develop", - output="", - ) - - await stack_new_mod.stack_new( - name="my-feature", - base=("upstream", "develop"), - checkout=True, - ) - - assert git_mock.has_been_called_with("fetch", "upstream", "develop") - assert git_mock.has_been_called_with( - "checkout", - "--track", - "-b", - "my-feature", - "upstream/develop", - ) - - async def test_stack_new_no_checkout( - self, - git_mock: test_utils.GitMock, - ) -> None: - """Should create branch without checking out when checkout=False.""" - git_mock.mock( - "config", - "--get", - "branch.current-branch.merge", - output="refs/heads/main", - ) - git_mock.mock( - "config", - "--get", - "branch.current-branch.remote", - output="origin", - ) - git_mock.mock("fetch", "origin", "main", output="") - git_mock.mock("branch", "--track", "my-feature", "origin/main", output="") - - await stack_new_mod.stack_new( - name="my-feature", - base=None, - checkout=False, - ) - - assert git_mock.has_been_called_with("fetch", "origin", "main") - assert git_mock.has_been_called_with( - "branch", - "--track", - "my-feature", - "origin/main", - ) - assert not git_mock.has_been_called_with( - "checkout", - "--track", - "-b", - "my-feature", - "origin/main", - ) - - async def test_stack_new_branch_already_exists( - self, - git_mock: test_utils.GitMock, - ) -> None: - """Should exit with code 1 when branch already exists.""" - git_mock.mock( - "config", - "--get", - "branch.current-branch.merge", - output="refs/heads/main", - ) - git_mock.mock( - "config", - "--get", - "branch.current-branch.remote", - output="origin", - ) - git_mock.mock("fetch", "origin", "main", output="") - - async def patched_git(*args: str) -> str: - if args == ("checkout", "--track", "-b", "existing-branch", "origin/main"): - raise utils.CommandError( - args, - 128, - b"fatal: a branch named 'existing-branch' already exists", - ) - return await git_mock(*args) - - with ( - mock.patch("mergify_cli.utils.git", patched_git), - pytest.raises(SystemExit) as exc_info, - ): - await stack_new_mod.stack_new( - name="existing-branch", - base=None, - checkout=True, - ) - - assert exc_info.value.code == ExitCode.GENERIC_ERROR - assert git_mock.has_been_called_with("fetch", "origin", "main") - - async def test_stack_new_trunk_not_found( - self, - ) -> None: - """Should exit with STACK_NOT_FOUND when trunk cannot be determined.""" - - async def patched_get_trunk() -> str: - raise utils.CommandError(("config", "--get"), 1, b"") - - with ( - mock.patch("mergify_cli.utils.get_trunk", patched_get_trunk), - pytest.raises(SystemExit) as exc_info, - ): - await stack_new_mod.stack_new( - name="my-feature", - base=None, - checkout=True, - ) - - assert exc_info.value.code == ExitCode.STACK_NOT_FOUND