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