Skip to main content

git_workflow/git/
query.rs

1//! Read-only git operations
2
3use std::path::PathBuf;
4use std::process::Command;
5
6use crate::error::{GwError, Result};
7
8/// Execute a git command and return stdout as string
9fn git_output(args: &[&str]) -> Result<String> {
10    let output = Command::new("git")
11        .args(args)
12        .output()
13        .map_err(|e| GwError::GitCommandFailed(format!("Failed to execute git: {e}")))?;
14
15    if output.status.success() {
16        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
17    } else {
18        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
19        Err(GwError::GitCommandFailed(stderr))
20    }
21}
22
23/// Execute a git command and check if it succeeded (ignoring output)
24fn git_check(args: &[&str]) -> bool {
25    Command::new("git")
26        .args(args)
27        .output()
28        .map(|o| o.status.success())
29        .unwrap_or(false)
30}
31
32/// Check if we're in a git repository
33pub fn is_git_repo() -> bool {
34    git_check(&["rev-parse", "--git-dir"])
35}
36
37/// Get the current branch name
38pub fn current_branch() -> Result<String> {
39    git_output(&["rev-parse", "--abbrev-ref", "HEAD"])
40}
41
42/// Get the worktree root (the top-level working directory of the current worktree)
43pub fn worktree_root() -> Result<PathBuf> {
44    git_output(&["rev-parse", "--show-toplevel"]).map(PathBuf::from)
45}
46
47/// Get the git directory (.git or .git/worktrees/xxx)
48pub fn git_dir() -> Result<PathBuf> {
49    git_output(&["rev-parse", "--git-dir"]).map(PathBuf::from)
50}
51
52/// Get the common git directory (always .git of main repo)
53pub fn git_common_dir() -> Result<PathBuf> {
54    git_output(&["rev-parse", "--git-common-dir"]).map(PathBuf::from)
55}
56
57/// Check if we're in a worktree (not the main repo)
58pub fn is_worktree() -> Result<bool> {
59    let git_dir = git_dir()?;
60    let common_dir = git_common_dir()?;
61    Ok(git_dir != common_dir)
62}
63
64/// Check if a branch exists locally
65pub fn branch_exists(branch: &str) -> bool {
66    git_check(&[
67        "show-ref",
68        "--verify",
69        "--quiet",
70        &format!("refs/heads/{branch}"),
71    ])
72}
73
74/// Check if a branch exists on remote origin.
75///
76/// Returns `Ok(true)` / `Ok(false)` only when the answer is definitive:
77/// `git ls-remote --exit-code` exits 0 when the ref exists and 2 when it
78/// provably does not. Any other exit (no `origin`, network/auth failure) is
79/// returned as an `Err` so callers can distinguish "not there" from "couldn't
80/// check" — the latter must never be treated as safe-to-delete.
81pub fn remote_branch_exists(branch: &str) -> Result<bool> {
82    let output = Command::new("git")
83        .args(["ls-remote", "--exit-code", "--heads", "origin", branch])
84        .output()
85        .map_err(|e| GwError::GitCommandFailed(format!("Failed to execute git: {e}")))?;
86
87    match output.status.code() {
88        Some(0) => Ok(true),
89        Some(2) => Ok(false),
90        _ => {
91            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
92            Err(GwError::GitCommandFailed(format!(
93                "Could not query remote branch '{branch}': {stderr}"
94            )))
95        }
96    }
97}
98
99/// Read the recorded base branch for a branch (`branch.<name>.gwBase`).
100///
101/// `gw new --stack` records the parent here so the workflow knows a branch is
102/// stacked *before* its PR exists; once a PR exists, GitHub's base is the source
103/// of truth instead. Returns `None` when unset (the branch targets the default
104/// branch). It is a local config read — no network.
105pub fn branch_base(branch: &str) -> Option<String> {
106    let value = git_output(&["config", "--get", &format!("branch.{branch}.gwBase")]).ok()?;
107    if value.is_empty() { None } else { Some(value) }
108}
109
110/// Read the recorded base tip SHA for a branch (`branch.<name>.gwBaseSha`).
111///
112/// Recorded by `gw new --stack` as the parent's HEAD at stack time. Serves as a
113/// `git rebase --onto` boundary that survives even if the base branch ref is
114/// later deleted (e.g. cleaned up after its PR merged). `None` when unset.
115pub fn branch_base_sha(branch: &str) -> Option<String> {
116    let value = git_output(&["config", "--get", &format!("branch.{branch}.gwBaseSha")]).ok()?;
117    if value.is_empty() { None } else { Some(value) }
118}
119
120/// Get the current HEAD commit hash
121pub fn head_commit() -> Result<String> {
122    git_output(&["rev-parse", "HEAD"])
123}
124
125/// Resolve a ref to its full commit SHA
126pub fn rev_parse(reference: &str) -> Result<String> {
127    git_output(&["rev-parse", "--verify", &format!("{reference}^{{commit}}")])
128}
129
130/// Get the short commit hash
131pub fn short_commit() -> Result<String> {
132    git_output(&["rev-parse", "--short", "HEAD"])
133}
134
135/// Get the commit message of HEAD
136pub fn head_commit_message() -> Result<String> {
137    git_output(&["log", "-1", "--format=%s"])
138}
139
140/// Check if working directory has unstaged changes
141pub fn has_unstaged_changes() -> bool {
142    !git_check(&["diff", "--quiet"])
143}
144
145/// Check if working directory has staged changes
146pub fn has_staged_changes() -> bool {
147    !git_check(&["diff", "--cached", "--quiet"])
148}
149
150/// Check if working directory has any uncommitted changes (staged or unstaged)
151pub fn has_uncommitted_changes() -> bool {
152    has_unstaged_changes() || has_staged_changes()
153}
154
155/// Check if working directory has untracked files
156pub fn has_untracked_files() -> bool {
157    git_output(&["ls-files", "--others", "--exclude-standard"])
158        .map(|s| !s.is_empty())
159        .unwrap_or(false)
160}
161
162/// Get the upstream tracking branch for a local branch, if any
163pub fn get_upstream(branch: &str) -> Option<String> {
164    git_output(&[
165        "rev-parse",
166        "--abbrev-ref",
167        &format!("{branch}@{{upstream}}"),
168    ])
169    .ok()
170    .filter(|s| !s.is_empty())
171}
172
173/// Check if a branch has a remote tracking branch
174pub fn has_remote_tracking(branch: &str) -> bool {
175    get_upstream(branch).is_some()
176}
177
178/// Count commits between two refs (exclusive..inclusive)
179pub fn commit_count(from: &str, to: &str) -> Result<usize> {
180    let output = git_output(&["rev-list", "--count", &format!("{from}..{to}")])?;
181    output
182        .parse()
183        .map_err(|_| GwError::GitCommandFailed("Failed to parse commit count".to_string()))
184}
185
186/// Get number of unpushed commits on a branch (compared to its upstream)
187pub fn unpushed_commit_count(branch: &str) -> Result<usize> {
188    let upstream = get_upstream(branch)
189        .ok_or_else(|| GwError::Other(format!("Branch '{branch}' has no upstream")))?;
190    commit_count(&upstream, branch)
191}
192
193/// Get number of commits behind upstream
194pub fn behind_upstream_count(branch: &str) -> Result<usize> {
195    let upstream = get_upstream(branch)
196        .ok_or_else(|| GwError::Other(format!("Branch '{branch}' has no upstream")))?;
197    commit_count(branch, &upstream)
198}
199
200/// Whether a ref (branch, remote-tracking ref, SHA) resolves locally.
201///
202/// Local lookup only (`rev-parse --verify`) — no network. Use after a fetch to
203/// check e.g. `origin/<branch>` without another round-trip.
204pub fn ref_exists(reference: &str) -> bool {
205    git_check(&[
206        "rev-parse",
207        "--verify",
208        "--quiet",
209        &format!("{reference}^{{commit}}"),
210    ])
211}
212
213/// Whether `ancestor` is reachable from `descendant` (`merge-base --is-ancestor`).
214///
215/// `is_ancestor("origin/main", "HEAD")` answers "is this branch already on top
216/// of the latest main?" — the check behind "already up to date" in `gw sync`
217/// and the behind-main detection in `gw status`.
218pub fn is_ancestor(ancestor: &str, descendant: &str) -> bool {
219    git_check(&["merge-base", "--is-ancestor", ancestor, descendant])
220}
221
222/// How many commits `base` has that `branch` does not (`branch..base`).
223///
224/// For a feature branch and `origin/main`, this is how far the trunk moved
225/// since the branch last caught up; 0 means the branch sits on the latest base.
226/// Returns 0 when either ref is unresolvable.
227pub fn behind_base_count(branch: &str, base: &str) -> usize {
228    commit_count(branch, base).unwrap_or(0)
229}
230
231/// Count stashes
232pub fn stash_count() -> usize {
233    git_output(&["stash", "list"])
234        .map(|s| if s.is_empty() { 0 } else { s.lines().count() })
235        .unwrap_or(0)
236}
237
238/// Get the message of the latest stash (stash@{0})
239pub fn get_latest_stash_message() -> Option<String> {
240    git_output(&["stash", "list", "-1", "--format=%gs"])
241        .ok()
242        .filter(|s| !s.is_empty())
243}
244
245/// Check if HEAD has a parent commit (i.e., we can undo)
246pub fn has_commits_to_undo() -> bool {
247    git_check(&["rev-parse", "HEAD~1"])
248}
249
250/// Get the current working directory name
251pub fn current_dir_name() -> Result<String> {
252    std::env::current_dir()
253        .map_err(GwError::Io)?
254        .file_name()
255        .and_then(|s| s.to_str())
256        .map(String::from)
257        .ok_or_else(|| GwError::Other("Could not determine current directory name".to_string()))
258}
259
260/// Get the default remote branch (origin/main or origin/master)
261pub fn get_default_remote_branch() -> Result<String> {
262    // An unreachable remote is treated as "not found" here; default_branch_name()
263    // falls back to local branches when this returns an error.
264    if remote_branch_exists("main").unwrap_or(false) {
265        Ok("origin/main".to_string())
266    } else if remote_branch_exists("master").unwrap_or(false) {
267        Ok("origin/master".to_string())
268    } else {
269        Err(GwError::Other(
270            "Neither origin/main nor origin/master exists".to_string(),
271        ))
272    }
273}
274
275/// Get the default branch name (e.g. "main" or "master"), without the remote prefix.
276///
277/// Prefers the remote's default branch; falls back to a local `main`/`master`
278/// for repositories without a configured `origin`. This is the single source of
279/// truth for "the trunk branch", replacing scattered hardcoded "main" literals.
280pub fn default_branch_name() -> Result<String> {
281    if let Ok(remote) = get_default_remote_branch() {
282        return Ok(remote
283            .strip_prefix("origin/")
284            .unwrap_or(&remote)
285            .to_string());
286    }
287    if branch_exists("main") {
288        return Ok("main".to_string());
289    }
290    if branch_exists("master") {
291        return Ok("master".to_string());
292    }
293    Err(GwError::Other(
294        "Could not determine the default branch (no origin/main, origin/master, or local main/master)"
295            .to_string(),
296    ))
297}
298
299/// Check if HEAD is detached
300pub fn is_detached_head() -> bool {
301    current_branch().map(|b| b == "HEAD").unwrap_or(false)
302}
303
304/// Get the top-level working directory of the repository
305pub fn repo_root() -> Result<PathBuf> {
306    git_output(&["rev-parse", "--show-toplevel"]).map(PathBuf::from)
307}