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 git directory (.git or .git/worktrees/xxx)
43pub fn git_dir() -> Result<PathBuf> {
44    git_output(&["rev-parse", "--git-dir"]).map(PathBuf::from)
45}
46
47/// Get the common git directory (always .git of main repo)
48pub fn git_common_dir() -> Result<PathBuf> {
49    git_output(&["rev-parse", "--git-common-dir"]).map(PathBuf::from)
50}
51
52/// Check if we're in a worktree (not the main repo)
53pub fn is_worktree() -> Result<bool> {
54    let git_dir = git_dir()?;
55    let common_dir = git_common_dir()?;
56    Ok(git_dir != common_dir)
57}
58
59/// Check if a branch exists locally
60pub fn branch_exists(branch: &str) -> bool {
61    git_check(&[
62        "show-ref",
63        "--verify",
64        "--quiet",
65        &format!("refs/heads/{branch}"),
66    ])
67}
68
69/// Check if a branch exists on remote origin
70pub fn remote_branch_exists(branch: &str) -> bool {
71    git_check(&["ls-remote", "--exit-code", "--heads", "origin", branch])
72}
73
74/// Get the current HEAD commit hash
75pub fn head_commit() -> Result<String> {
76    git_output(&["rev-parse", "HEAD"])
77}
78
79/// Get the short commit hash
80pub fn short_commit() -> Result<String> {
81    git_output(&["rev-parse", "--short", "HEAD"])
82}
83
84/// Get the commit message of HEAD
85pub fn head_commit_message() -> Result<String> {
86    git_output(&["log", "-1", "--format=%s"])
87}
88
89/// Check if working directory has unstaged changes
90pub fn has_unstaged_changes() -> bool {
91    !git_check(&["diff", "--quiet"])
92}
93
94/// Check if working directory has staged changes
95pub fn has_staged_changes() -> bool {
96    !git_check(&["diff", "--cached", "--quiet"])
97}
98
99/// Check if working directory has any uncommitted changes (staged or unstaged)
100pub fn has_uncommitted_changes() -> bool {
101    has_unstaged_changes() || has_staged_changes()
102}
103
104/// Check if working directory has untracked files
105pub fn has_untracked_files() -> bool {
106    git_output(&["ls-files", "--others", "--exclude-standard"])
107        .map(|s| !s.is_empty())
108        .unwrap_or(false)
109}
110
111/// Get the upstream tracking branch for a local branch, if any
112pub fn get_upstream(branch: &str) -> Option<String> {
113    git_output(&[
114        "rev-parse",
115        "--abbrev-ref",
116        &format!("{branch}@{{upstream}}"),
117    ])
118    .ok()
119    .filter(|s| !s.is_empty())
120}
121
122/// Check if a branch has a remote tracking branch
123pub fn has_remote_tracking(branch: &str) -> bool {
124    get_upstream(branch).is_some()
125}
126
127/// Count commits between two refs (exclusive..inclusive)
128pub fn commit_count(from: &str, to: &str) -> Result<usize> {
129    let output = git_output(&["rev-list", "--count", &format!("{from}..{to}")])?;
130    output
131        .parse()
132        .map_err(|_| GwError::GitCommandFailed("Failed to parse commit count".to_string()))
133}
134
135/// Get number of unpushed commits on a branch (compared to its upstream)
136pub fn unpushed_commit_count(branch: &str) -> Result<usize> {
137    let upstream = get_upstream(branch)
138        .ok_or_else(|| GwError::Other(format!("Branch '{branch}' has no upstream")))?;
139    commit_count(&upstream, branch)
140}
141
142/// Get number of commits behind upstream
143pub fn behind_upstream_count(branch: &str) -> Result<usize> {
144    let upstream = get_upstream(branch)
145        .ok_or_else(|| GwError::Other(format!("Branch '{branch}' has no upstream")))?;
146    commit_count(branch, &upstream)
147}
148
149/// Count stashes
150pub fn stash_count() -> usize {
151    git_output(&["stash", "list"])
152        .map(|s| if s.is_empty() { 0 } else { s.lines().count() })
153        .unwrap_or(0)
154}
155
156/// Get the message of the latest stash (stash@{0})
157pub fn get_latest_stash_message() -> Option<String> {
158    git_output(&["stash", "list", "-1", "--format=%gs"])
159        .ok()
160        .filter(|s| !s.is_empty())
161}
162
163/// Check if HEAD has a parent commit (i.e., we can undo)
164pub fn has_commits_to_undo() -> bool {
165    git_check(&["rev-parse", "HEAD~1"])
166}
167
168/// Get the current working directory name
169pub fn current_dir_name() -> Result<String> {
170    std::env::current_dir()
171        .map_err(GwError::Io)?
172        .file_name()
173        .and_then(|s| s.to_str())
174        .map(String::from)
175        .ok_or_else(|| GwError::Other("Could not determine current directory name".to_string()))
176}
177
178/// Get the default remote branch (origin/main or origin/master)
179pub fn get_default_remote_branch() -> Result<String> {
180    if remote_branch_exists("main") {
181        Ok("origin/main".to_string())
182    } else if remote_branch_exists("master") {
183        Ok("origin/master".to_string())
184    } else {
185        Err(GwError::Other(
186            "Neither origin/main nor origin/master exists".to_string(),
187        ))
188    }
189}
190
191/// Check if HEAD is detached
192pub fn is_detached_head() -> bool {
193    current_branch().map(|b| b == "HEAD").unwrap_or(false)
194}