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
24 changes: 18 additions & 6 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,30 @@ 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.
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.)

Example:
gw new feature/add-login")]
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 <parent>` hint), or run `gw home` first to start fresh from main.

Examples:
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<String>,

/// 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
Expand Down
140 changes: 123 additions & 17 deletions src/commands/new.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
//! `gw new` command - Create new branch from origin/main
//! `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;
use crate::output;
use crate::state::{RepoType, WorkingDirState};

/// Execute the `new` command
pub fn run(branch_name: Option<String>, verbose: bool) -> Result<()> {
pub fn run(branch_name: Option<String>, stack: bool, verbose: bool) -> Result<()> {
// Ensure we're in a git repo
if !git::is_git_repo() {
return Err(GwError::NotAGitRepository);
}

// 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 run gw new from detached HEAD. Checkout a branch first.".to_string(),
));
}

let branch_name = branch_name.ok_or(GwError::BranchNameRequired)?;

println!();
Expand All @@ -31,35 +53,119 @@ pub fn run(branch_name: Option<String>, verbose: bool) -> Result<()> {
return Err(GwError::BranchAlreadyExists(branch_name));
}

// Fetch latest
output::info("Fetching from origin...");
git::fetch_prune(verbose)?;
output::success("Fetched");
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 <parent> && 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(),
));
}

// Detect default remote branch (origin/main or origin/master)
let default_remote = git::get_default_remote_branch()?;
let working_dir = WorkingDirState::detect();
let dirty = !working_dir.is_clean();

// Create branch from default remote
git::checkout_new_branch(&branch_name, &default_remote, verbose)?;
// 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<String>) = if stack {
(current.clone(), current.clone(), Some(current.clone()))
} else {
output::info("Fetching from origin...");
git::fetch_prune(verbose)?;
output::success("Fetched");
let default_remote = git::get_default_remote_branch()?;

if dirty {
behind_count = git::commit_count(&current, &default_remote).unwrap_or(0);
(current.clone(), current.clone(), None)
} else {
(default_remote.clone(), default_remote, None)
}
};

// 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(),
output::bold(&branch_name)
));
}

// 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),
default_remote
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()?;

output::ready("Ready to work", &branch_name);
println!("Base: {commit_short} {commit_msg}");

output::hints(&[
"# Make changes, then:",
"git add <files> && git commit -m \"feat: description\"",
&format!("git push -u origin {branch_name}"),
"gh pr create -a \"@me\" -t \"Title\"",
]);
// Build the next-step hints, inserting a rebase step when local main lagged
// and a `-B <parent>` PR base for stacked branches.
let mut hint_lines: Vec<String> = vec![
"# Make changes, then:".to_string(),
"git add <files> && 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(())
}
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading