1use anyhow::Result;
2use std::path::Path;
3
4#[derive(Debug, Clone)]
5pub struct DirtyFile {
6 pub path: String,
7 pub status: DirtyStatus,
8}
9
10#[derive(Debug, Clone, PartialEq)]
11pub enum DirtyStatus {
12 Modified,
13 Added,
14 Deleted,
15 Untracked,
16}
17
18#[derive(Debug)]
19pub enum PushResult {
20 Success,
21 NoRemote,
22 Rejected(String),
23 Error(String),
24}
25
26#[derive(Debug)]
27pub enum PullResult {
28 Success,
29 NoRemote,
30 AlreadyUpToDate,
31 Conflicts(Vec<String>),
32 Error(String),
33}
34
35#[derive(Debug)]
36pub struct GitSummary {
37 pub branch: Option<String>,
38 pub dirty_count: usize,
39 pub untracked_count: usize,
40 pub modified_count: usize,
41 pub ahead_behind: Option<(usize, usize)>,
42}
43
44pub struct GitRepo {
45 repo: gix::Repository,
46}
47
48impl GitRepo {
49 pub fn open(path: &Path) -> Option<Self> {
52 let repo = gix::discover(path).ok()?;
53 Some(Self { repo })
54 }
55
56 pub fn branch_name(&self) -> Result<Option<String>> {
58 let head = self.repo.head()?;
59 let name = head
60 .referent_name()
61 .map(|full| full.shorten().to_string());
62 Ok(name)
63 }
64
65 pub fn summary(&self) -> Result<GitSummary> {
67 let branch = self.branch_name()?;
68 let dirty = self.dirty_files()?;
69
70 let untracked_count = dirty
71 .iter()
72 .filter(|f| matches!(f.status, DirtyStatus::Untracked))
73 .count();
74 let modified_count = dirty
75 .iter()
76 .filter(|f| !matches!(f.status, DirtyStatus::Untracked))
77 .count();
78
79 let ahead_behind = self.ahead_behind()?;
80
81 Ok(GitSummary {
82 branch,
83 dirty_count: dirty.len(),
84 untracked_count,
85 modified_count,
86 ahead_behind,
87 })
88 }
89
90 pub fn is_dirty(&self) -> Result<bool> {
92 let files = self.dirty_files()?;
93 Ok(!files.is_empty())
94 }
95
96 pub fn ahead_behind(&self) -> Result<Option<(usize, usize)>> {
99 let workdir = self
100 .repo
101 .workdir()
102 .ok_or_else(|| anyhow::anyhow!("bare repository has no working directory"))?;
103
104 let output = std::process::Command::new("git")
105 .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
106 .current_dir(workdir)
107 .output()?;
108
109 if !output.status.success() {
110 return Ok(None);
112 }
113
114 let stdout = String::from_utf8(output.stdout)?;
115 let parts: Vec<&str> = stdout.trim().split('\t').collect();
116 if parts.len() != 2 {
117 return Ok(None);
118 }
119
120 let ahead = parts[0].parse::<usize>().unwrap_or(0);
121 let behind = parts[1].parse::<usize>().unwrap_or(0);
122
123 Ok(Some((ahead, behind)))
124 }
125
126 pub fn commit_all(&self, message: &str) -> Result<()> {
128 if !self.is_dirty()? {
129 anyhow::bail!("nothing to commit — working tree is clean");
130 }
131
132 let workdir = self
133 .repo
134 .workdir()
135 .ok_or_else(|| anyhow::anyhow!("bare repository has no working directory"))?;
136
137 let status = std::process::Command::new("git")
138 .args(["add", "-A"])
139 .current_dir(workdir)
140 .status()?;
141
142 if !status.success() {
143 anyhow::bail!("git add failed with exit code {}", status);
144 }
145
146 let status = std::process::Command::new("git")
147 .args(["commit", "-m", message])
148 .current_dir(workdir)
149 .status()?;
150
151 if !status.success() {
152 anyhow::bail!("git commit failed with exit code {}", status);
153 }
154
155 Ok(())
156 }
157
158 pub fn dirty_files(&self) -> Result<Vec<DirtyFile>> {
161 let workdir = self
162 .repo
163 .workdir()
164 .ok_or_else(|| anyhow::anyhow!("bare repository has no working directory"))?;
165
166 let output = std::process::Command::new("git")
167 .args(["status", "--porcelain"])
168 .current_dir(workdir)
169 .output()?;
170
171 anyhow::ensure!(
172 output.status.success(),
173 "git status failed: {}",
174 String::from_utf8_lossy(&output.stderr)
175 );
176
177 let stdout = String::from_utf8(output.stdout)?;
178 let mut files = Vec::new();
179
180 for line in stdout.lines() {
181 if line.len() < 4 {
182 continue;
183 }
184 let index_status = line.as_bytes()[0];
185 let worktree_status = line.as_bytes()[1];
186 let path = line[3..].to_string();
187
188 let status = match (index_status, worktree_status) {
189 (b'?', b'?') => DirtyStatus::Untracked,
190 (b'A', _) | (_, b'A') => DirtyStatus::Added,
191 (b'D', _) | (_, b'D') => DirtyStatus::Deleted,
192 _ => DirtyStatus::Modified,
193 };
194
195 files.push(DirtyFile { path, status });
196 }
197
198 Ok(files)
199 }
200
201 fn has_remote(&self) -> bool {
202 self.repo.remote_names().first().is_some()
203 }
204
205 pub fn push(&self) -> Result<PushResult> {
206 if !self.has_remote() {
207 return Ok(PushResult::NoRemote);
208 }
209
210 let workdir = self
211 .repo
212 .workdir()
213 .ok_or_else(|| anyhow::anyhow!("bare repository has no working directory"))?;
214
215 let output = std::process::Command::new("git")
216 .args(["push"])
217 .current_dir(workdir)
218 .output()?;
219
220 if output.status.success() {
221 Ok(PushResult::Success)
222 } else {
223 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
224 if stderr.contains("rejected") {
225 Ok(PushResult::Rejected(stderr))
226 } else {
227 Ok(PushResult::Error(stderr))
228 }
229 }
230 }
231
232 pub fn pull(&self) -> Result<PullResult> {
233 if !self.has_remote() {
234 return Ok(PullResult::NoRemote);
235 }
236
237 let workdir = self
238 .repo
239 .workdir()
240 .ok_or_else(|| anyhow::anyhow!("bare repository has no working directory"))?;
241
242 let output = std::process::Command::new("git")
243 .args(["pull"])
244 .current_dir(workdir)
245 .output()?;
246
247 if output.status.success() {
248 let stdout = String::from_utf8_lossy(&output.stdout);
249 if stdout.contains("Already up to date") {
250 Ok(PullResult::AlreadyUpToDate)
251 } else {
252 Ok(PullResult::Success)
253 }
254 } else {
255 let stdout = String::from_utf8_lossy(&output.stdout);
256 let stderr = String::from_utf8_lossy(&output.stderr);
257 if stdout.contains("CONFLICT") || stderr.contains("CONFLICT") {
258 let conflicts = self.list_conflicted_files()?;
259 Ok(PullResult::Conflicts(conflicts))
260 } else {
261 Ok(PullResult::Error(stderr.to_string()))
262 }
263 }
264 }
265
266 fn list_conflicted_files(&self) -> Result<Vec<String>> {
267 let workdir = self
268 .repo
269 .workdir()
270 .ok_or_else(|| anyhow::anyhow!("bare repository has no working directory"))?;
271
272 let output = std::process::Command::new("git")
273 .args(["diff", "--name-only", "--diff-filter=U"])
274 .current_dir(workdir)
275 .output()?;
276
277 let files = String::from_utf8_lossy(&output.stdout)
278 .lines()
279 .map(|l| l.to_string())
280 .collect();
281
282 Ok(files)
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use tempfile::TempDir;
290
291 #[test]
292 fn open_returns_none_for_non_repo() {
293 let dir = TempDir::new().unwrap();
294 assert!(GitRepo::open(dir.path()).is_none());
295 }
296
297 #[test]
298 fn open_returns_some_for_git_repo() {
299 let dir = TempDir::new().unwrap();
300 gix::init(dir.path()).unwrap();
301 assert!(GitRepo::open(dir.path()).is_some());
302 }
303
304 #[test]
305 fn branch_name_on_fresh_repo() {
306 let dir = TempDir::new().unwrap();
307 gix::init(dir.path()).unwrap();
308 let repo = GitRepo::open(dir.path()).unwrap();
309 let name = repo.branch_name().unwrap();
310 assert_eq!(name, Some("main".to_string()));
311 }
312
313 #[test]
314 fn is_dirty_on_clean_repo() {
315 let dir = TempDir::new().unwrap();
316 gix::init(dir.path()).unwrap();
317 let repo = GitRepo::open(dir.path()).unwrap();
318 assert!(!repo.is_dirty().unwrap());
319 }
320
321 #[test]
322 fn is_dirty_with_untracked_file() {
323 let dir = TempDir::new().unwrap();
324 gix::init(dir.path()).unwrap();
325 std::fs::write(dir.path().join("hello.txt"), "hello").unwrap();
326 let repo = GitRepo::open(dir.path()).unwrap();
327 assert!(repo.is_dirty().unwrap());
328 }
329
330 #[test]
331 fn dirty_files_lists_changes() {
332 let dir = TempDir::new().unwrap();
333 gix::init(dir.path()).unwrap();
334 std::fs::write(dir.path().join("a.txt"), "aaa").unwrap();
335 std::fs::write(dir.path().join("b.txt"), "bbb").unwrap();
336 let repo = GitRepo::open(dir.path()).unwrap();
337 let files = repo.dirty_files().unwrap();
338 assert_eq!(files.len(), 2);
339 assert!(files.iter().all(|f| f.status == DirtyStatus::Untracked));
340 }
341
342 #[test]
343 fn ahead_behind_returns_none_without_remote() {
344 let dir = TempDir::new().unwrap();
345 gix::init(dir.path()).unwrap();
346 let repo = GitRepo::open(dir.path()).unwrap();
347 let result = repo.ahead_behind().unwrap();
348 assert_eq!(result, None);
349 }
350
351 fn configure_test_identity(dir: &Path) {
353 for (key, value) in [
354 ("user.name", "Test User"),
355 ("user.email", "test@test.com"),
356 ] {
357 std::process::Command::new("git")
358 .args(["config", key, value])
359 .current_dir(dir)
360 .status()
361 .unwrap();
362 }
363 }
364
365 #[test]
366 fn commit_all_creates_commit() {
367 let dir = TempDir::new().unwrap();
368 gix::init(dir.path()).unwrap();
369 configure_test_identity(dir.path());
370 std::fs::write(dir.path().join("file.txt"), "content").unwrap();
371
372 let repo = GitRepo::open(dir.path()).unwrap();
373 repo.commit_all("test commit").unwrap();
374
375 let gix_repo = gix::open(dir.path()).unwrap();
376 let head = gix_repo.head_commit().unwrap();
377 let msg = head.message_raw_sloppy();
378 assert!(
379 msg.starts_with(b"test commit"),
380 "commit message should match"
381 );
382 }
383
384 #[test]
385 fn commit_all_errors_when_nothing_to_commit() {
386 let dir = TempDir::new().unwrap();
387 gix::init(dir.path()).unwrap();
388 let repo = GitRepo::open(dir.path()).unwrap();
389 let result = repo.commit_all("empty commit");
390 assert!(result.is_err());
391 }
392
393 #[test]
394 fn push_returns_no_remote_without_remote() {
395 let dir = TempDir::new().unwrap();
396 gix::init(dir.path()).unwrap();
397 let repo = GitRepo::open(dir.path()).unwrap();
398 let result = repo.push().unwrap();
399 assert!(matches!(result, PushResult::NoRemote));
400 }
401
402 #[test]
403 fn pull_returns_no_remote_without_remote() {
404 let dir = TempDir::new().unwrap();
405 gix::init(dir.path()).unwrap();
406 let repo = GitRepo::open(dir.path()).unwrap();
407 let result = repo.pull().unwrap();
408 assert!(matches!(result, PullResult::NoRemote));
409 }
410
411 #[test]
412 fn summary_clean_repo() {
413 let dir = TempDir::new().unwrap();
414 gix::init(dir.path()).unwrap();
415 let repo = GitRepo::open(dir.path()).unwrap();
416 let summary = repo.summary().unwrap();
417 assert!(summary.branch.is_some());
418 assert_eq!(summary.dirty_count, 0);
419 assert!(summary.ahead_behind.is_none());
420 }
421
422 #[test]
423 fn summary_with_dirty_files() {
424 let dir = TempDir::new().unwrap();
425 gix::init(dir.path()).unwrap();
426 std::fs::write(dir.path().join("file.txt"), "content").unwrap();
427 let repo = GitRepo::open(dir.path()).unwrap();
428 let summary = repo.summary().unwrap();
429 assert!(summary.dirty_count > 0);
430 }
431}