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.referent_name().map(|full| full.shorten().to_string());
60        Ok(name)
61    }
62
63    /// Returns a high-level summary of the repository state: branch, dirty counts, ahead/behind.
64    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    /// Returns true if the working tree has any uncommitted changes or untracked files.
89    pub fn is_dirty(&self) -> Result<bool> {
90        let files = self.dirty_files()?;
91        Ok(!files.is_empty())
92    }
93
94    /// Returns (ahead, behind) counts relative to the upstream tracking branch.
95    /// Returns None if there's no tracking branch configured or HEAD is detached.
96    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            // No upstream configured, detached HEAD, etc.
109            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    /// Stage all changes and create a commit. Errors if there's nothing to commit.
125    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    /// Returns a list of dirty files with their statuses.
157    /// Uses `git status --porcelain` for reliable results across all repo states.
158    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    /// Configure a minimal git identity in the given repo so `git commit` works.
350    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}