Skip to main content

git_workflow/commands/
status.rs

1//! `gw status` command - Show current repository state
2
3use crate::error::{GwError, Result};
4use crate::git;
5use crate::github::{self, PrInfo, PrState};
6use crate::output;
7use crate::state::{DetectContext, NextAction, RepoType, SyncState, WorkingDirState};
8
9/// Execute the `status` command
10pub fn run() -> Result<()> {
11    // Ensure we're in a git repo
12    if !git::is_git_repo() {
13        return Err(GwError::NotAGitRepository);
14    }
15
16    // Best-effort refresh of remote-tracking refs so "behind" numbers reflect
17    // the real remote, not the last time something else fetched. A repo with
18    // no reachable origin just reports from what it has.
19    let _ = git::fetch_prune(false);
20
21    let repo_type = RepoType::detect()?;
22    let home_branch = repo_type.home_branch();
23    let current = git::current_branch()?;
24    let working_dir = WorkingDirState::detect();
25    let sync_state = SyncState::detect(&current).unwrap_or(SyncState::NoUpstream);
26    // For status display, an unverifiable remote is reported as "not pushed".
27    let has_remote = git::remote_branch_exists(&current).unwrap_or(false);
28
29    println!();
30
31    // Repository type
32    match &repo_type {
33        RepoType::MainRepo { .. } => {
34            output::info("Repository: main repo");
35        }
36        RepoType::Worktree { home_branch } => {
37            output::info(&format!(
38                "Repository: worktree (home: {})",
39                output::bold(home_branch)
40            ));
41        }
42    }
43
44    // Current branch
45    if current == home_branch {
46        output::success(&format!("Branch: {} (home)", output::bold(&current)));
47    } else {
48        output::info(&format!(
49            "Branch: {} (home: {})",
50            output::bold(&current),
51            home_branch
52        ));
53    }
54
55    // Working directory state
56    match working_dir {
57        WorkingDirState::Clean => {
58            output::success("Working directory: clean");
59        }
60        _ => {
61            output::warn(&format!("Working directory: {}", working_dir.description()));
62        }
63    }
64
65    // Sync state
66    match &sync_state {
67        SyncState::NoUpstream => {
68            output::info("Upstream: no tracking branch");
69        }
70        SyncState::Synced => {
71            output::success("Upstream: synced");
72        }
73        SyncState::HasUnpushedCommits { count } => {
74            output::warn(&format!("Upstream: {} unpushed commit(s)", count));
75        }
76        SyncState::Behind { count } => {
77            output::warn(&format!("Upstream: {} commit(s) behind", count));
78        }
79        SyncState::Diverged { ahead, behind } => {
80            output::warn(&format!(
81                "Upstream: diverged ({} ahead, {} behind)",
82                ahead, behind
83            ));
84        }
85    }
86
87    // PR info (only for non-home branches). The default branch (main/master) is
88    // the trunk a stacked PR ultimately targets — distinct from this worktree's
89    // home branch.
90    let default_branch = git::default_branch_name()?;
91    let (pr_info, base_pr_merged) = if current != home_branch {
92        get_and_show_pr_info(&current, &default_branch)
93    } else {
94        (None, None)
95    };
96
97    // Remote branch status
98    if current != home_branch {
99        if has_remote {
100            output::info(&format!("Remote: origin/{} exists", current));
101        } else {
102            output::info("Remote: not pushed");
103        }
104    }
105
106    // Locally recorded stacked base (`gw new --stack`). Once a PR exists,
107    // GitHub's base is authoritative and shown above, so this only fills the
108    // pre-PR gap. Filtered to a real parent (not the default branch / self).
109    let recorded_base = if current != home_branch {
110        git::branch_base(&current).filter(|b| b != &default_branch && b != &current)
111    } else {
112        None
113    };
114    // If the recorded parent already merged before this branch got a PR, the
115    // stacked base is stale — the branch should rebase onto main rather than
116    // open a `-B <parent>` PR. (Only checked pre-PR; once a PR exists, GitHub's
117    // base is authoritative.)
118    let mut recorded_base_merged = false;
119    if pr_info.is_none() {
120        if let Some(base) = &recorded_base {
121            output::info(&format!("Base: {} (stacked, PR not created yet)", base));
122            recorded_base_merged = check_base_pr_merged(base).is_some();
123        }
124    }
125    // The recorded base tip is the `rebase --onto` boundary that survives the
126    // base branch being deleted (used when the base merged before this PR).
127    let recorded_base_sha = recorded_base
128        .as_ref()
129        .and_then(|_| git::branch_base_sha(&current));
130
131    // The base this branch should sit on, and how far it has moved since the
132    // branch last caught up. A stacked branch follows its parent (while the
133    // parent is in flight); everything else follows the default branch.
134    let (base_ref, behind_base) = if current != home_branch {
135        let base_ref = base_ref_for(
136            &default_branch,
137            pr_info.as_ref(),
138            recorded_base.as_deref(),
139            recorded_base_merged,
140        );
141        let behind = if git::ref_exists(&base_ref) {
142            git::behind_base_count("HEAD", &base_ref)
143        } else {
144            0
145        };
146        if behind > 0 {
147            output::warn(&format!(
148                "Behind {}: {} commit(s) (gw sync to catch up)",
149                base_ref, behind
150            ));
151        }
152        (base_ref, behind)
153    } else {
154        (format!("origin/{default_branch}"), 0)
155    };
156
157    // Stash count
158    let stash_count = git::stash_count();
159    if stash_count > 0 {
160        output::info(&format!("Stashes: {}", stash_count));
161    }
162
163    // Next action
164    let next_action = NextAction::detect(&DetectContext {
165        current_branch: &current,
166        home_branch,
167        working_dir: &working_dir,
168        sync_state: &sync_state,
169        pr_info: pr_info.as_ref(),
170        has_remote,
171        base_pr_merged: base_pr_merged.as_deref(),
172        recorded_base: recorded_base.as_deref(),
173        recorded_base_merged,
174        recorded_base_sha: recorded_base_sha.as_deref(),
175        base_ref: &base_ref,
176        behind_base,
177    });
178    next_action.display(&current);
179
180    Ok(())
181}
182
183/// The ref a feature branch should sit on.
184///
185/// - open PR stacked on a parent → `origin/<parent>` (GitHub's base is
186///   authoritative once a PR exists)
187/// - no PR, recorded stacked base still in flight → `origin/<parent>` if
188///   pushed, else the local parent
189/// - otherwise → `origin/<default>`
190fn base_ref_for(
191    default_branch: &str,
192    pr_info: Option<&PrInfo>,
193    recorded_base: Option<&str>,
194    recorded_base_merged: bool,
195) -> String {
196    if let Some(pr) = pr_info {
197        if pr.state.is_open() && pr.base_branch != default_branch {
198            return format!("origin/{}", pr.base_branch);
199        }
200        return format!("origin/{default_branch}");
201    }
202    if let Some(base) = recorded_base {
203        if !recorded_base_merged {
204            let remote_ref = format!("origin/{base}");
205            if git::ref_exists(&remote_ref) {
206                return remote_ref;
207            }
208            return base.to_string();
209        }
210    }
211    format!("origin/{default_branch}")
212}
213
214/// Get and show PR information for a branch
215///
216/// Returns:
217/// - (Some(PrInfo), Some(base_branch)) if PR exists and base PR was merged
218/// - (Some(PrInfo), None) if PR exists but base is the default branch or base PR not merged
219/// - (None, None) if no PR found
220fn get_and_show_pr_info(branch: &str, default_branch: &str) -> (Option<PrInfo>, Option<String>) {
221    if !github::is_gh_available() {
222        return (None, None);
223    }
224
225    match github::get_pr_for_branch(branch) {
226        Ok(Some(pr)) => {
227            let state_str = match &pr.state {
228                PrState::Open => "OPEN",
229                PrState::Merged { .. } => "MERGED",
230                PrState::Closed => "CLOSED",
231            };
232
233            let method_str = match &pr.state {
234                PrState::Merged { method, .. } => format!(" ({})", method),
235                _ => String::new(),
236            };
237
238            match &pr.state {
239                PrState::Open => {
240                    output::info(&format!("PR: #{} {} [{}]", pr.number, pr.title, state_str));
241                }
242                PrState::Merged { .. } => {
243                    output::success(&format!(
244                        "PR: #{} {} [{}{}]",
245                        pr.number, pr.title, state_str, method_str
246                    ));
247                }
248                PrState::Closed => {
249                    output::warn(&format!("PR: #{} {} [{}]", pr.number, pr.title, state_str));
250                }
251            }
252
253            // Show base branch info (a base other than the default branch means
254            // this is a stacked PR).
255            if pr.base_branch != default_branch {
256                output::info(&format!(
257                    "Base: {} (not {})",
258                    pr.base_branch, default_branch
259                ));
260            }
261
262            // Check if base PR is merged (only for a stacked, still-open PR)
263            let base_pr_merged = if pr.base_branch != default_branch && pr.state.is_open() {
264                check_base_pr_merged(&pr.base_branch)
265            } else {
266                None
267            };
268
269            (Some(pr), base_pr_merged)
270        }
271        Ok(None) => {
272            output::info("PR: none");
273            (None, None)
274        }
275        Err(e) => {
276            output::warn(&format!("Could not fetch PR info: {}", e));
277            (None, None)
278        }
279    }
280}
281
282/// Check if the base branch's PR has been merged
283fn check_base_pr_merged(base_branch: &str) -> Option<String> {
284    match github::get_pr_for_branch(base_branch) {
285        Ok(Some(base_pr)) => {
286            if base_pr.state.is_merged() {
287                output::success(&format!("Base PR: #{} [MERGED] ✓", base_pr.number));
288                Some(base_branch.to_string())
289            } else {
290                let state_str = if base_pr.state.is_open() {
291                    "OPEN"
292                } else {
293                    "CLOSED"
294                };
295                output::info(&format!("Base PR: #{} [{}]", base_pr.number, state_str));
296                None
297            }
298        }
299        Ok(None) => {
300            output::info(&format!("Base PR: none (for {})", base_branch));
301            None
302        }
303        Err(_) => None,
304    }
305}