1use serde::{Deserialize, Serialize};
6use std::path::Path;
7use std::process::Command;
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct GitStatus {
12 pub tracked: Vec<String>,
14 pub untracked: Vec<String>,
16 pub is_clean: bool,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct GitInfo {
23 pub commit_hash: String,
25 pub branch_name: String,
27 pub remote_url: Option<String>,
29 pub is_clean: bool,
31 pub tracked_files: Vec<String>,
33 pub untracked_files: Vec<String>,
35 pub default_branch: String,
37 pub recent_commits: Vec<String>,
39}
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct PushStatus {
44 pub has_upstream: bool,
46 pub needs_push: bool,
48 pub commits_ahead: u32,
50 pub commits_ahead_of_default: u32,
52}
53
54pub struct GitUtils;
56
57impl GitUtils {
58 fn exec_git(args: &[&str], cwd: &Path) -> Result<String, String> {
60 let output = Command::new("git")
61 .args(args)
62 .current_dir(cwd)
63 .output()
64 .map_err(|e| format!("执行 git 命令失败: {}", e))?;
65
66 if output.status.success() {
67 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
68 } else {
69 Err(format!("git {} 失败", args.join(" ")))
70 }
71 }
72
73 fn exec_git_ok(args: &[&str], cwd: &Path) -> bool {
75 Command::new("git")
76 .args(args)
77 .current_dir(cwd)
78 .output()
79 .map(|o| o.status.success())
80 .unwrap_or(false)
81 }
82}
83
84pub fn is_git_repository(cwd: &Path) -> bool {
86 GitUtils::exec_git_ok(&["rev-parse", "--is-inside-work-tree"], cwd)
87}
88
89pub fn get_current_branch(cwd: &Path) -> Result<String, String> {
91 GitUtils::exec_git(&["rev-parse", "--abbrev-ref", "HEAD"], cwd)
92}
93
94pub fn get_default_branch(cwd: &Path) -> String {
96 if let Ok(head) = GitUtils::exec_git(&["symbolic-ref", "refs/remotes/origin/HEAD"], cwd) {
98 if let Some(branch) = head.strip_prefix("refs/remotes/origin/") {
99 return branch.to_string();
100 }
101 }
102
103 if let Ok(branches) = GitUtils::exec_git(&["branch", "-r"], cwd) {
105 for name in ["main", "master"] {
106 if branches.contains(&format!("origin/{}", name)) {
107 return name.to_string();
108 }
109 }
110 }
111
112 "main".to_string()
113}
114
115pub fn get_remote_url(cwd: &Path, remote: &str) -> Option<String> {
117 GitUtils::exec_git(&["remote", "get-url", remote], cwd).ok()
118}
119
120pub fn get_current_commit(cwd: &Path) -> Result<String, String> {
122 GitUtils::exec_git(&["rev-parse", "HEAD"], cwd)
123}
124
125pub fn get_git_status(cwd: &Path) -> Result<GitStatus, String> {
127 let output = GitUtils::exec_git(&["status", "--porcelain"], cwd)?;
128
129 let mut tracked = Vec::new();
130 let mut untracked = Vec::new();
131
132 for line in output.lines() {
133 if line.is_empty() {
134 continue;
135 }
136
137 let status = line.get(..2).unwrap_or("");
138 let file = line.get(3..).unwrap_or("").trim().to_string();
139
140 if status == "??" {
141 untracked.push(file);
142 } else if !file.is_empty() {
143 tracked.push(file);
144 }
145 }
146
147 let is_clean = tracked.is_empty() && untracked.is_empty();
148
149 Ok(GitStatus {
150 tracked,
151 untracked,
152 is_clean,
153 })
154}
155
156#[allow(dead_code)]
158pub fn has_upstream(cwd: &Path) -> bool {
159 GitUtils::exec_git_ok(&["rev-parse", "@{u}"], cwd)
160}
161
162#[allow(dead_code)]
164pub fn get_commits_ahead(cwd: &Path) -> u32 {
165 GitUtils::exec_git(&["rev-list", "--count", "@{u}..HEAD"], cwd)
166 .ok()
167 .and_then(|s| s.parse().ok())
168 .unwrap_or(0)
169}
170
171pub fn get_recent_commits(cwd: &Path, count: u32) -> Vec<String> {
173 GitUtils::exec_git(&["log", "--oneline", "-n", &count.to_string()], cwd)
174 .ok()
175 .map(|s| s.lines().map(|l| l.to_string()).collect())
176 .unwrap_or_default()
177}
178
179pub fn get_git_info(cwd: &Path) -> Option<GitInfo> {
181 if !is_git_repository(cwd) {
182 return None;
183 }
184
185 let commit_hash = get_current_commit(cwd).ok()?;
186 let branch_name = get_current_branch(cwd).ok()?;
187 let remote_url = get_remote_url(cwd, "origin");
188 let status = get_git_status(cwd).ok()?;
189 let default_branch = get_default_branch(cwd);
190 let recent_commits = get_recent_commits(cwd, 5);
191
192 Some(GitInfo {
193 commit_hash,
194 branch_name,
195 remote_url,
196 is_clean: status.is_clean,
197 tracked_files: status.tracked,
198 untracked_files: status.untracked,
199 default_branch,
200 recent_commits,
201 })
202}
203
204#[allow(dead_code)]
206pub fn get_push_status(cwd: &Path) -> PushStatus {
207 let has_up = has_upstream(cwd);
208 let commits_ahead = if has_up { get_commits_ahead(cwd) } else { 0 };
209
210 let default_branch = get_default_branch(cwd);
212 let commits_ahead_of_default = GitUtils::exec_git(
213 &[
214 "rev-list",
215 "--count",
216 &format!("origin/{}..HEAD", default_branch),
217 ],
218 cwd,
219 )
220 .ok()
221 .and_then(|s| s.parse().ok())
222 .unwrap_or(0);
223
224 PushStatus {
225 has_upstream: has_up,
226 needs_push: !has_up || commits_ahead > 0,
227 commits_ahead,
228 commits_ahead_of_default,
229 }
230}