Skip to main content

dotm/
git.rs

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    /// Attempt to open (discover) a git repository at or above `path`.
50    /// Returns `None` if `path` is not inside a git repository.
51    pub fn open(path: &Path) -> Option<Self> {
52        let repo = gix::discover(path).ok()?;
53        Some(Self { repo })
54    }
55
56    /// Returns the current branch name, or `None` if HEAD is detached.
57    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    /// Returns a high-level summary of the repository state: branch, dirty counts, ahead/behind.
66    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    /// Returns true if the working tree has any uncommitted changes or untracked files.
91    pub fn is_dirty(&self) -> Result<bool> {
92        let files = self.dirty_files()?;
93        Ok(!files.is_empty())
94    }
95
96    /// Returns (ahead, behind) counts relative to the upstream tracking branch.
97    /// Returns None if there's no tracking branch configured or HEAD is detached.
98    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            // No upstream configured, detached HEAD, etc.
111            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    /// Stage all changes and create a commit. Errors if there's nothing to commit.
127    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    /// Returns a list of dirty files with their statuses.
159    /// Uses `git status --porcelain` for reliable results across all repo states.
160    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    /// Configure a minimal git identity in the given repo so `git commit` works.
352    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}