From 7ae238017c5356ac0ed8609daccebba075cf4d91 Mon Sep 17 00:00:00 2001 From: zawakin Date: Mon, 29 Jun 2026 19:51:54 +0900 Subject: [PATCH 1/2] feat: add `gw new --stack` to branch off the current branch `gw new` always based the new branch on origin/main, so there was no way to start a stacked PR from gw. --stack bases the branch on the current branch's HEAD instead and emits a `gh pr create -B ` hint so the PR uses the right base. Refused on the home branch (plain `gw new` already covers that). Default behavior is unchanged. Also surface a warning when uncommitted changes move onto the new branch instead of carrying them silently. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli.rs | 15 ++++- src/commands/new.rs | 88 +++++++++++++++++++++---- src/main.rs | 2 +- tests/new_test.rs | 154 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 243 insertions(+), 16 deletions(-) create mode 100644 tests/new_test.rs diff --git a/src/cli.rs b/src/cli.rs index 63cff15..90630bd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -53,18 +53,27 @@ Example: gw home")] Home, - /// Create new branch from origin/main + /// Create new branch from origin/main (or the current branch with --stack) #[command(long_about = "\ Create a new branch off a freshly fetched origin/main. If you already edited on your home branch, gw new carries those changes onto the new branch -- nothing is stranded on `main`. -Example: - gw new feature/add-login")] +Use --stack to base the new branch on the CURRENT branch instead, for stacked +PRs. The new branch starts from your current branch's HEAD, and the create-PR +hint becomes `gh pr create -B ` so GitHub uses the right base. + +Examples: + gw new feature/add-login # branch off a fresh origin/main + gw new feature/child --stack # stack on top of the current branch")] New { /// Name of the branch to create (e.g., feature/add-login) branch: Option, + + /// Base the new branch on the current branch instead of origin/main (stacked PRs) + #[arg(long)] + stack: bool, }, /// Delete merged branch and return to home diff --git a/src/commands/new.rs b/src/commands/new.rs index c012631..2627552 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -1,16 +1,31 @@ -//! `gw new` command - Create new branch from origin/main +//! `gw new` command - Create new branch from origin/main (or the current branch with --stack) use crate::error::{GwError, Result}; use crate::git; use crate::output; +use crate::state::{RepoType, WorkingDirState}; /// Execute the `new` command -pub fn run(branch_name: Option, verbose: bool) -> Result<()> { +/// +/// By default the branch is created from a freshly fetched `origin/main`. With +/// `stack = true` it is created from the *current* branch's HEAD instead, so the +/// new branch stacks on top of an in-flight PR. Stacking from the current HEAD +/// carries any uncommitted changes over without a rebase, so it can't conflict +/// the way basing on `origin/main` can. +pub fn run(branch_name: Option, stack: bool, verbose: bool) -> Result<()> { // Ensure we're in a git repo if !git::is_git_repo() { return Err(GwError::NotAGitRepository); } + // --stack reads the current branch as the base; a detached HEAD has no + // branch to stack on, so refuse early with an actionable message. + if stack && git::is_detached_head() { + return Err(GwError::Other( + "Cannot use --stack from detached HEAD. Checkout a branch first.".to_string(), + )); + } + let branch_name = branch_name.ok_or(GwError::BranchNameRequired)?; println!(); @@ -31,20 +46,62 @@ pub fn run(branch_name: Option, verbose: bool) -> Result<()> { return Err(GwError::BranchAlreadyExists(branch_name)); } - // Fetch latest - output::info("Fetching from origin..."); - git::fetch_prune(verbose)?; - output::success("Fetched"); + // Resolve the base before mutating anything so we can fail fast (e.g. + // --stack on the home branch) without leaving a half-created branch. + let base = if stack { + let current = git::current_branch()?; + let repo_type = RepoType::detect()?; + let home_branch = repo_type.home_branch(); + + // --stack means "stack on top of the feature branch I'm on". On the + // home branch that's meaningless -- plain `gw new` already starts fresh + // from the default branch -- so refuse rather than create a branch that + // tracks `main` under a stacked PR hint. + if current == home_branch { + output::error(&format!( + "--stack requires a non-home branch, but you are on '{}'.", + current + )); + output::hints(&[ + "gw new feature/your-feature # start fresh from the default branch", + "git checkout && gw new feature/child --stack # stack on a feature branch", + ]); + return Err(GwError::Other( + "--stack requires a non-home current branch".to_string(), + )); + } + + current + } else { + // Fetch so the default branch base is current. (--stack bases on the + // local current branch, so it needs no fetch.) + output::info("Fetching from origin..."); + git::fetch_prune(verbose)?; + output::success("Fetched"); - // Detect default remote branch (origin/main or origin/master) - let default_remote = git::get_default_remote_branch()?; + git::get_default_remote_branch()? + }; + + output::info(&format!("Base branch: {}", output::bold(&base))); + + // Surface that uncommitted work is moving onto the new branch instead of + // doing it silently. `git checkout -b` carries the working tree along; for + // --stack the start point is the current HEAD so this never conflicts. + let working_dir = WorkingDirState::detect(); + if !working_dir.is_clean() { + output::warn(&format!( + "Working directory has changes ({}); they will move onto {}", + working_dir.description(), + output::bold(&branch_name) + )); + } - // Create branch from default remote - git::checkout_new_branch(&branch_name, &default_remote, verbose)?; + // Create branch from the resolved base + git::checkout_new_branch(&branch_name, &base, verbose)?; output::success(&format!( "Created branch {} from {}", output::bold(&branch_name), - default_remote + base )); // Show current position @@ -54,11 +111,18 @@ pub fn run(branch_name: Option, verbose: bool) -> Result<()> { output::ready("Ready to work", &branch_name); println!("Base: {commit_short} {commit_msg}"); + // Stacked branches need an explicit PR base: a branch cut from the current + // branch doesn't make GitHub default the PR base to that parent. + let pr_create = if stack { + format!("gh pr create -a \"@me\" -B {base} -t \"Title\"") + } else { + "gh pr create -a \"@me\" -t \"Title\"".to_string() + }; output::hints(&[ "# Make changes, then:", "git add && git commit -m \"feat: description\"", &format!("git push -u origin {branch_name}"), - "gh pr create -a \"@me\" -t \"Title\"", + &pr_create, ]); Ok(()) diff --git a/src/main.rs b/src/main.rs index 383a568..3e890b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ fn main() -> ExitCode { let result = match cli.command { Commands::Home => commands::home::run(cli.verbose), - Commands::New { branch } => commands::new::run(branch, cli.verbose), + Commands::New { branch, stack } => commands::new::run(branch, stack, cli.verbose), Commands::Cleanup { branch } => commands::cleanup::run(branch, cli.verbose), Commands::Status => commands::status::run(), Commands::Pause { message } => commands::pause::run(message, cli.verbose), diff --git a/tests/new_test.rs b/tests/new_test.rs new file mode 100644 index 0000000..66fd376 --- /dev/null +++ b/tests/new_test.rs @@ -0,0 +1,154 @@ +//! Integration tests for `gw new`, focused on the `--stack` flag. +//! +//! Plain `gw new` branches off `origin/main`; `--stack` branches off the +//! CURRENT branch instead so stacked PRs can be built. `--stack` on the home +//! branch is refused, since plain `gw new` already covers that case. + +use std::path::Path; +use std::process::{Command, Output}; + +use regex::Regex; +use tempfile::TempDir; + +fn strip_ansi(s: &str) -> String { + let re = Regex::new(r"\x1b\[[0-9;]*m").unwrap(); + re.replace_all(s, "").to_string() +} + +fn run_git(dir: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("Failed to run git command"); + if !output.status.success() { + panic!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + } + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +fn run_gw(dir: &Path, args: &[&str]) -> Output { + let gw_path = env!("CARGO_BIN_EXE_gw"); + Command::new(gw_path) + .args(args) + .current_dir(dir) + .env("NO_COLOR", "1") + .output() + .expect("Failed to run gw command") +} + +/// A local repo on `main` with an `origin` it can fetch from. +fn setup_repo() -> TempDir { + let origin = TempDir::new().unwrap(); + run_git(origin.path(), &["init", "--bare", "--initial-branch=main"]); + + let local = TempDir::new().unwrap(); + run_git(local.path(), &["init", "--initial-branch=main"]); + run_git(local.path(), &["config", "user.email", "test@example.com"]); + run_git(local.path(), &["config", "user.name", "Test User"]); + std::fs::write(local.path().join("README.md"), "# Test").unwrap(); + run_git(local.path(), &["add", "."]); + run_git(local.path(), &["commit", "-m", "Initial commit"]); + let origin_url = format!("file://{}", origin.path().display()); + run_git(local.path(), &["remote", "add", "origin", &origin_url]); + run_git(local.path(), &["push", "-u", "origin", "main"]); + + // Keep both TempDirs alive for the duration of the test by leaking the + // origin; the local dir is what the caller drives. + std::mem::forget(origin); + local +} + +fn current_branch(dir: &Path) -> String { + run_git(dir, &["rev-parse", "--abbrev-ref", "HEAD"]) +} + +#[test] +fn test_stack_branches_off_current_branch() { + let local = setup_repo(); + let dir = local.path(); + + // Build a parent feature branch with its own commit. + assert!(run_gw(dir, &["new", "feature/parent"]).status.success()); + std::fs::write(dir.join("parent.txt"), "parent work").unwrap(); + run_git(dir, &["add", "."]); + run_git(dir, &["commit", "-m", "feat: parent work"]); + let parent_head = run_git(dir, &["rev-parse", "HEAD"]); + + // Stack a child on top of the parent. + let output = run_gw(dir, &["new", "feature/child", "--stack"]); + assert!( + output.status.success(), + "gw new --stack failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let out = strip_ansi(&String::from_utf8_lossy(&output.stdout)); + assert!( + out.contains("Base branch: feature/parent"), + "expected base to be the parent branch: {out}" + ); + // PR hint must carry the explicit base so GitHub uses the parent. + assert!( + out.contains("-B feature/parent"), + "expected stacked PR hint with -B feature/parent: {out}" + ); + + assert_eq!(current_branch(dir), "feature/child"); + // The child must start at the parent's HEAD, not origin/main. + assert_eq!(run_git(dir, &["rev-parse", "HEAD"]), parent_head); +} + +#[test] +fn test_stack_on_home_branch_is_refused() { + let local = setup_repo(); + let dir = local.path(); + + let output = run_gw(dir, &["new", "feature/child", "--stack"]); + assert!( + !output.status.success(), + "gw new --stack on home should fail" + ); + + let err = strip_ansi(&String::from_utf8_lossy(&output.stderr)); + assert!( + err.contains("--stack requires a non-home branch"), + "expected refusal message on stderr: {err}" + ); + // The branch must not have been created. + assert_eq!(current_branch(dir), "main"); +} + +#[test] +fn test_plain_new_branches_off_origin_main() { + let local = setup_repo(); + let dir = local.path(); + + // A parent branch with a commit that is NOT on origin/main. + assert!(run_gw(dir, &["new", "feature/parent"]).status.success()); + std::fs::write(dir.join("parent.txt"), "parent work").unwrap(); + run_git(dir, &["add", "."]); + run_git(dir, &["commit", "-m", "feat: parent work"]); + let origin_main = run_git(dir, &["rev-parse", "origin/main"]); + + // Plain `gw new` (no --stack) from the feature branch must still base on + // origin/main, not the current branch. + let output = run_gw(dir, &["new", "feature/sibling"]); + assert!( + output.status.success(), + "gw new failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let out = strip_ansi(&String::from_utf8_lossy(&output.stdout)); + assert!( + !out.contains("-B "), + "plain new should not emit a -B PR hint: {out}" + ); + assert_eq!(current_branch(dir), "feature/sibling"); + assert_eq!(run_git(dir, &["rev-parse", "HEAD"]), origin_main); +} From e21c154c5f3660b082c2bd2a75341aa680738045 Mon Sep 17 00:00:00 2001 From: zawakin Date: Mon, 29 Jun 2026 20:09:50 +0900 Subject: [PATCH 2/2] refactor: make gw new base selection structurally unambiguous Enforce three invariants so wrong moves can't happen: - No ambiguous base: auto-base on origin/main only from home. On a feature branch, plain `gw new` refuses and points at --stack or `gw home`. - No implicit merge: a dirty tree always bases on the current HEAD, so branch creation never performs a working-tree merge (no cryptic conflict). If local main lagged origin/main, defer to a clean rebase after committing. - No silent displacement: carried uncommitted changes are always reported. Also refuse from detached HEAD (no branch context to reason about). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli.rs | 19 ++--- src/commands/new.rs | 166 +++++++++++++++++++++++++++----------------- tests/new_test.rs | 72 ++++++++++++++++--- 3 files changed, 176 insertions(+), 81 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 90630bd..5f55d88 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -55,18 +55,21 @@ Example: /// Create new branch from origin/main (or the current branch with --stack) #[command(long_about = "\ -Create a new branch off a freshly fetched origin/main. +Create a new branch with an unambiguous base. -If you already edited on your home branch, gw new carries those changes onto the -new branch -- nothing is stranded on `main`. +From your home branch, gw new branches off a freshly fetched origin/main. If you +already edited there, those changes are carried onto the new branch -- nothing is +stranded on `main`. (Dirty changes are based on the current HEAD, so creating the +branch never hits a merge conflict; if local main lagged, gw points you at a +rebase afterwards.) -Use --stack to base the new branch on the CURRENT branch instead, for stacked -PRs. The new branch starts from your current branch's HEAD, and the create-PR -hint becomes `gh pr create -B ` so GitHub uses the right base. +From a feature branch, the base is ambiguous, so gw new refuses unless you say +which you mean: --stack bases on the CURRENT branch (for stacked PRs, with a +`gh pr create -B ` hint), or run `gw home` first to start fresh from main. Examples: - gw new feature/add-login # branch off a fresh origin/main - gw new feature/child --stack # stack on top of the current branch")] + gw new feature/add-login # from home: branch off a fresh origin/main + gw new feature/child --stack # from a feature branch: stack on top of it")] New { /// Name of the branch to create (e.g., feature/add-login) branch: Option, diff --git a/src/commands/new.rs b/src/commands/new.rs index 2627552..cbfab29 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -1,4 +1,17 @@ -//! `gw new` command - Create new branch from origin/main (or the current branch with --stack) +//! `gw new` command - Create a new branch with a structurally unambiguous base. +//! +//! Three invariants make accidental mistakes unrepresentable: +//! +//! 1. **No ambiguous base.** A base is auto-chosen only where exactly one makes +//! sense: the home branch (→ `origin/main`). From any other branch the base +//! is ambiguous (sibling vs stack), so `gw new` refuses and demands `--stack` +//! (base on the current branch) or returning home first. +//! 2. **No implicit merge.** When the working tree is dirty, the start point is +//! the current HEAD, so creating the branch never performs a working-tree +//! merge and therefore can never conflict or fail cryptically. +//! 3. **No silent displacement.** Uncommitted work only ever travels onto a +//! branch whose base the user explicitly established, and it is always +//! reported. use crate::error::{GwError, Result}; use crate::git; @@ -6,23 +19,17 @@ use crate::output; use crate::state::{RepoType, WorkingDirState}; /// Execute the `new` command -/// -/// By default the branch is created from a freshly fetched `origin/main`. With -/// `stack = true` it is created from the *current* branch's HEAD instead, so the -/// new branch stacks on top of an in-flight PR. Stacking from the current HEAD -/// carries any uncommitted changes over without a rebase, so it can't conflict -/// the way basing on `origin/main` can. pub fn run(branch_name: Option, stack: bool, verbose: bool) -> Result<()> { // Ensure we're in a git repo if !git::is_git_repo() { return Err(GwError::NotAGitRepository); } - // --stack reads the current branch as the base; a detached HEAD has no - // branch to stack on, so refuse early with an actionable message. - if stack && git::is_detached_head() { + // A detached HEAD has no branch context, so we can't tell home from a + // feature branch nor stack on anything. Refuse rather than guess. + if git::is_detached_head() { return Err(GwError::Other( - "Cannot use --stack from detached HEAD. Checkout a branch first.".to_string(), + "Cannot run gw new from detached HEAD. Checkout a branch first.".to_string(), )); } @@ -46,49 +53,73 @@ pub fn run(branch_name: Option, stack: bool, verbose: bool) -> Result<() return Err(GwError::BranchAlreadyExists(branch_name)); } - // Resolve the base before mutating anything so we can fail fast (e.g. - // --stack on the home branch) without leaving a half-created branch. - let base = if stack { - let current = git::current_branch()?; - let repo_type = RepoType::detect()?; - let home_branch = repo_type.home_branch(); - - // --stack means "stack on top of the feature branch I'm on". On the - // home branch that's meaningless -- plain `gw new` already starts fresh - // from the default branch -- so refuse rather than create a branch that - // tracks `main` under a stacked PR hint. - if current == home_branch { - output::error(&format!( - "--stack requires a non-home branch, but you are on '{}'.", - current - )); - output::hints(&[ - "gw new feature/your-feature # start fresh from the default branch", - "git checkout && gw new feature/child --stack # stack on a feature branch", - ]); - return Err(GwError::Other( - "--stack requires a non-home current branch".to_string(), - )); - } + let current = git::current_branch()?; + let repo_type = RepoType::detect()?; + let home_branch = repo_type.home_branch(); + let on_home = current == home_branch; + + // Invariant 1: the base must be unambiguous. + if stack && on_home { + // --stack means "stack on the feature branch I'm on"; on home that's + // meaningless -- plain `gw new` already starts fresh from origin/main. + output::error(&format!( + "--stack requires a non-home branch, but you are on '{}'.", + current + )); + output::hints(&[ + "gw new feature/your-feature # start fresh from origin/main", + "git checkout && gw new feature/child --stack # stack on a feature branch", + ]); + return Err(GwError::Other( + "--stack requires a non-home current branch".to_string(), + )); + } + if !stack && !on_home { + // Refuse to silently base on origin/main from a feature branch: that + // would strip uncommitted work off the branch and pick a base the user + // never chose. Force the explicit decision instead. + output::error(&format!( + "You are on '{}', not the home branch '{}'.", + current, home_branch + )); + output::hints(&[ + &format!("gw new {branch_name} --stack # stack on {current}"), + &format!("gw home && gw new {branch_name} # start fresh from {home_branch}"), + ]); + return Err(GwError::Other( + "gw new outside the home branch needs --stack (or run gw home first)".to_string(), + )); + } - current + let working_dir = WorkingDirState::detect(); + let dirty = !working_dir.is_clean(); + + // Resolve the start point per invariants 1 & 2. + // + // - --stack: base on the current branch's HEAD (local; no fetch needed). + // - home + clean: base on a freshly fetched origin/main. + // - home + dirty: base on the current HEAD so carrying the working tree + // needs no merge; if local main lags origin/main, defer the catch-up to a + // clean rebase after committing. + let mut behind_count = 0usize; + let (start_point, base_label, pr_base): (String, String, Option) = if stack { + (current.clone(), current.clone(), Some(current.clone())) } else { - // Fetch so the default branch base is current. (--stack bases on the - // local current branch, so it needs no fetch.) output::info("Fetching from origin..."); git::fetch_prune(verbose)?; output::success("Fetched"); + let default_remote = git::get_default_remote_branch()?; - git::get_default_remote_branch()? + if dirty { + behind_count = git::commit_count(¤t, &default_remote).unwrap_or(0); + (current.clone(), current.clone(), None) + } else { + (default_remote.clone(), default_remote, None) + } }; - output::info(&format!("Base branch: {}", output::bold(&base))); - - // Surface that uncommitted work is moving onto the new branch instead of - // doing it silently. `git checkout -b` carries the working tree along; for - // --stack the start point is the current HEAD so this never conflicts. - let working_dir = WorkingDirState::detect(); - if !working_dir.is_clean() { + // Invariant 3: surface that uncommitted work is moving onto the new branch. + if dirty { output::warn(&format!( "Working directory has changes ({}); they will move onto {}", working_dir.description(), @@ -96,14 +127,22 @@ pub fn run(branch_name: Option, stack: bool, verbose: bool) -> Result<() )); } - // Create branch from the resolved base - git::checkout_new_branch(&branch_name, &base, verbose)?; + // Create the branch. The start point is always the current HEAD when dirty, + // so this never performs a working-tree merge. + git::checkout_new_branch(&branch_name, &start_point, verbose)?; output::success(&format!( "Created branch {} from {}", output::bold(&branch_name), - base + base_label )); + if behind_count > 0 { + output::warn(&format!( + "local {} is behind origin/{} ({} commit(s)); rebase after committing", + home_branch, home_branch, behind_count + )); + } + // Show current position let commit_short = git::short_commit()?; let commit_msg = git::head_commit_message()?; @@ -111,19 +150,22 @@ pub fn run(branch_name: Option, stack: bool, verbose: bool) -> Result<() output::ready("Ready to work", &branch_name); println!("Base: {commit_short} {commit_msg}"); - // Stacked branches need an explicit PR base: a branch cut from the current - // branch doesn't make GitHub default the PR base to that parent. - let pr_create = if stack { - format!("gh pr create -a \"@me\" -B {base} -t \"Title\"") - } else { - "gh pr create -a \"@me\" -t \"Title\"".to_string() - }; - output::hints(&[ - "# Make changes, then:", - "git add && git commit -m \"feat: description\"", - &format!("git push -u origin {branch_name}"), - &pr_create, - ]); + // Build the next-step hints, inserting a rebase step when local main lagged + // and a `-B ` PR base for stacked branches. + let mut hint_lines: Vec = vec![ + "# Make changes, then:".to_string(), + "git add && git commit -m \"feat: description\"".to_string(), + ]; + if behind_count > 0 { + hint_lines.push("git rebase origin/main # local main was behind; catch up".to_string()); + } + hint_lines.push(format!("git push -u origin {branch_name}")); + hint_lines.push(match &pr_base { + Some(base) => format!("gh pr create -a \"@me\" -B {base} -t \"Title\""), + None => "gh pr create -a \"@me\" -t \"Title\"".to_string(), + }); + let hint_refs: Vec<&str> = hint_lines.iter().map(String::as_str).collect(); + output::hints(&hint_refs); Ok(()) } diff --git a/tests/new_test.rs b/tests/new_test.rs index 66fd376..3feffcc 100644 --- a/tests/new_test.rs +++ b/tests/new_test.rs @@ -89,7 +89,7 @@ fn test_stack_branches_off_current_branch() { let out = strip_ansi(&String::from_utf8_lossy(&output.stdout)); assert!( - out.contains("Base branch: feature/parent"), + out.contains("from feature/parent"), "expected base to be the parent branch: {out}" ); // PR hint must carry the explicit base so GitHub uses the parent. @@ -124,20 +124,15 @@ fn test_stack_on_home_branch_is_refused() { } #[test] -fn test_plain_new_branches_off_origin_main() { +fn test_plain_new_from_home_bases_on_origin_main() { let local = setup_repo(); let dir = local.path(); - // A parent branch with a commit that is NOT on origin/main. - assert!(run_gw(dir, &["new", "feature/parent"]).status.success()); - std::fs::write(dir.join("parent.txt"), "parent work").unwrap(); - run_git(dir, &["add", "."]); - run_git(dir, &["commit", "-m", "feat: parent work"]); let origin_main = run_git(dir, &["rev-parse", "origin/main"]); - // Plain `gw new` (no --stack) from the feature branch must still base on - // origin/main, not the current branch. - let output = run_gw(dir, &["new", "feature/sibling"]); + // Plain `gw new` from the home branch bases on origin/main and emits no + // `-B` PR hint. + let output = run_gw(dir, &["new", "feature/x"]); assert!( output.status.success(), "gw new failed: {}", @@ -149,6 +144,61 @@ fn test_plain_new_branches_off_origin_main() { !out.contains("-B "), "plain new should not emit a -B PR hint: {out}" ); - assert_eq!(current_branch(dir), "feature/sibling"); + assert_eq!(current_branch(dir), "feature/x"); assert_eq!(run_git(dir, &["rev-parse", "HEAD"]), origin_main); } + +#[test] +fn test_plain_new_on_feature_branch_is_refused() { + let local = setup_repo(); + let dir = local.path(); + + // Move onto a feature branch. + assert!(run_gw(dir, &["new", "feature/parent"]).status.success()); + + // Plain `gw new` (no --stack) from a feature branch must refuse rather than + // silently base on origin/main. + let output = run_gw(dir, &["new", "feature/sibling"]); + assert!( + !output.status.success(), + "plain gw new on a feature branch should fail" + ); + + let err = strip_ansi(&String::from_utf8_lossy(&output.stderr)); + assert!( + err.contains("not the home branch"), + "expected refusal pointing at the home branch: {err}" + ); + // Nothing created; still on the feature branch. + assert_eq!(current_branch(dir), "feature/parent"); + assert!( + run_gw(dir, &["status"]).status.success(), + "feature/sibling should not exist" + ); +} + +#[test] +fn test_new_from_home_carries_uncommitted_changes() { + let local = setup_repo(); + let dir = local.path(); + + // Dirty the home branch, then start a branch. The work must travel onto the + // new branch (still uncommitted), not be stranded on main. + std::fs::write(dir.join("wip.txt"), "in progress").unwrap(); + + let output = run_gw(dir, &["new", "feature/x"]); + assert!( + output.status.success(), + "gw new with dirty tree failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + assert_eq!(current_branch(dir), "feature/x"); + // The untracked file is still present and uncommitted on the new branch. + assert!(dir.join("wip.txt").exists()); + let status = run_git(dir, &["status", "--porcelain"]); + assert!( + status.contains("wip.txt"), + "uncommitted change should remain on the new branch: {status}" + ); +}