Skip to main content

git_workflow/commands/
new.rs

1//! `gw new` command - Create a new branch with a structurally unambiguous base.
2//!
3//! Three invariants make accidental mistakes unrepresentable:
4//!
5//! 1. **No ambiguous base.** A base is auto-chosen only where exactly one makes
6//!    sense: the home branch (→ `origin/main`). From any other branch the base
7//!    is ambiguous (sibling vs stack), so `gw new` refuses and demands `--stack`
8//!    (base on the current branch) or returning home first.
9//! 2. **No implicit merge.** When the working tree is dirty, the start point is
10//!    the current HEAD, so creating the branch never performs a working-tree
11//!    merge and therefore can never conflict or fail cryptically.
12//! 3. **No silent displacement.** Uncommitted work only ever travels onto a
13//!    branch whose base the user explicitly established, and it is always
14//!    reported.
15
16use crate::error::{GwError, Result};
17use crate::git;
18use crate::output;
19use crate::state::{RepoType, WorkingDirState};
20
21/// Execute the `new` command
22pub fn run(branch_name: Option<String>, stack: bool, verbose: bool) -> Result<()> {
23    // Ensure we're in a git repo
24    if !git::is_git_repo() {
25        return Err(GwError::NotAGitRepository);
26    }
27
28    // A detached HEAD has no branch context, so we can't tell home from a
29    // feature branch nor stack on anything. Refuse rather than guess.
30    if git::is_detached_head() {
31        return Err(GwError::Other(
32            "Cannot run gw new from detached HEAD. Checkout a branch first.".to_string(),
33        ));
34    }
35
36    let branch_name = branch_name.ok_or(GwError::BranchNameRequired)?;
37
38    println!();
39    output::info(&format!("Creating branch: {}", output::bold(&branch_name)));
40
41    // Check if branch already exists
42    if git::branch_exists(&branch_name) {
43        output::error(&format!("Branch '{}' already exists locally", branch_name));
44        println!();
45        output::action(&format!(
46            "git checkout {}  # Switch to existing branch",
47            branch_name
48        ));
49        output::action(&format!(
50            "git branch -d {}  # Delete and recreate",
51            branch_name
52        ));
53        return Err(GwError::BranchAlreadyExists(branch_name));
54    }
55
56    let current = git::current_branch()?;
57    let repo_type = RepoType::detect()?;
58    let home_branch = repo_type.home_branch();
59    let on_home = current == home_branch;
60
61    // Invariant 1: the base must be unambiguous.
62    if stack && on_home {
63        // --stack means "stack on the feature branch I'm on"; on home that's
64        // meaningless -- plain `gw new` already starts fresh from origin/main.
65        output::error(&format!(
66            "--stack requires a non-home branch, but you are on '{}'.",
67            current
68        ));
69        output::hints(&[
70            "gw new feature/your-feature                            # start fresh from origin/main",
71            "git checkout <parent> && gw new feature/child --stack  # stack on a feature branch",
72        ]);
73        return Err(GwError::Other(
74            "--stack requires a non-home current branch".to_string(),
75        ));
76    }
77    if !stack && !on_home {
78        // Refuse to silently base on origin/main from a feature branch: that
79        // would strip uncommitted work off the branch and pick a base the user
80        // never chose. Force the explicit decision instead.
81        output::error(&format!(
82            "You are on '{}', not the home branch '{}'.",
83            current, home_branch
84        ));
85        output::hints(&[
86            &format!("gw new {branch_name} --stack          # stack on {current}"),
87            &format!("gw home && gw new {branch_name}        # start fresh from {home_branch}"),
88        ]);
89        return Err(GwError::Other(
90            "gw new outside the home branch needs --stack (or run gw home first)".to_string(),
91        ));
92    }
93
94    let working_dir = WorkingDirState::detect();
95    let dirty = !working_dir.is_clean();
96
97    // Resolve the start point per invariants 1 & 2.
98    //
99    // - --stack: base on the current branch's HEAD (local; no fetch needed).
100    // - home + clean: base on a freshly fetched origin/main.
101    // - home + dirty: base on the current HEAD so carrying the working tree
102    //   needs no merge; if local main lags origin/main, defer the catch-up to a
103    //   clean rebase after committing.
104    let mut behind_count = 0usize;
105    let (start_point, base_label, pr_base): (String, String, Option<String>) = if stack {
106        (current.clone(), current.clone(), Some(current.clone()))
107    } else {
108        output::info("Fetching from origin...");
109        git::fetch_prune(verbose)?;
110        output::success("Fetched");
111        let default_remote = git::get_default_remote_branch()?;
112
113        if dirty {
114            behind_count = git::commit_count(&current, &default_remote).unwrap_or(0);
115            (current.clone(), current.clone(), None)
116        } else {
117            (default_remote.clone(), default_remote, None)
118        }
119    };
120
121    // Invariant 3: surface that uncommitted work is moving onto the new branch.
122    if dirty {
123        output::warn(&format!(
124            "Working directory has changes ({}); they will move onto {}",
125            working_dir.description(),
126            output::bold(&branch_name)
127        ));
128    }
129
130    // Create the branch. The start point is always the current HEAD when dirty,
131    // so this never performs a working-tree merge.
132    git::checkout_new_branch(&branch_name, &start_point, verbose)?;
133    output::success(&format!(
134        "Created branch {} from {}",
135        output::bold(&branch_name),
136        base_label
137    ));
138
139    // Record the stacked base locally so `gw status` can suggest the right PR
140    // base (`-B <parent>`) before the PR exists. (A stale entry -- e.g. parent
141    // merged before this branch's PR is opened -- is left for the parent/child
142    // guard work to handle; `gh pr create` errors loudly on a missing base.)
143    if let Some(base) = &pr_base {
144        git::set_branch_base(&branch_name, base, verbose)?;
145        // Record the base tip SHA (= the fork point, since --stack branches off
146        // the current HEAD) so a later restack can `rebase --onto` even if the
147        // base branch has since been deleted.
148        if let Ok(sha) = git::head_commit() {
149            git::set_branch_base_sha(&branch_name, &sha, verbose)?;
150        }
151    }
152
153    if behind_count > 0 {
154        output::warn(&format!(
155            "local {} is behind origin/{} ({} commit(s)); rebase after committing",
156            home_branch, home_branch, behind_count
157        ));
158    }
159
160    // Show current position
161    let commit_short = git::short_commit()?;
162    let commit_msg = git::head_commit_message()?;
163
164    output::ready("Ready to work", &branch_name);
165    println!("Base: {commit_short} {commit_msg}");
166
167    // Build the next-step hints, inserting a rebase step when local main lagged
168    // and a `-B <parent>` PR base for stacked branches.
169    let mut hint_lines: Vec<String> = vec![
170        "# Make changes, then:".to_string(),
171        "git add <files> && git commit -m \"feat: description\"".to_string(),
172    ];
173    if behind_count > 0 {
174        hint_lines.push("gw sync  # local main was behind; catch up".to_string());
175    }
176    hint_lines.push(format!("git push -u origin {branch_name}"));
177    hint_lines.push(match &pr_base {
178        Some(base) => format!("gh pr create -a \"@me\" -B {base} -t \"Title\""),
179        None => "gh pr create -a \"@me\" -t \"Title\"".to_string(),
180    });
181    let hint_refs: Vec<&str> = hint_lines.iter().map(String::as_str).collect();
182    output::hints(&hint_refs);
183
184    Ok(())
185}