acp/git/
repository.rs

1//! @acp:module "Git Repository"
2//! @acp:summary "Repository operations via libgit2"
3//! @acp:domain cli
4//! @acp:layer integration
5
6use crate::error::{AcpError, Result};
7use git2::{Repository, Status, StatusOptions};
8use std::path::Path;
9
10/// File status in the git repository
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum FileStatus {
13    /// File is tracked and unchanged
14    Clean,
15    /// File has been modified
16    Modified,
17    /// File is staged for commit
18    Staged,
19    /// File is untracked
20    Untracked,
21    /// File has conflicts
22    Conflicted,
23    /// File has been deleted
24    Deleted,
25    /// File is ignored
26    Ignored,
27    /// File is newly added
28    New,
29}
30
31impl FileStatus {
32    /// Check if the file has uncommitted changes
33    pub fn is_dirty(&self) -> bool {
34        matches!(
35            self,
36            Self::Modified | Self::Staged | Self::New | Self::Deleted | Self::Conflicted
37        )
38    }
39}
40
41/// Git repository wrapper providing common operations
42pub struct GitRepository {
43    repo: Repository,
44}
45
46impl GitRepository {
47    /// Open a git repository from the given path (searches upward for .git)
48    pub fn open(path: &Path) -> Result<Self> {
49        let repo = Repository::discover(path)
50            .map_err(|e| AcpError::Other(format!("Failed to open git repository: {}", e)))?;
51        Ok(Self { repo })
52    }
53
54    /// Get the underlying git2 repository
55    pub(crate) fn inner(&self) -> &Repository {
56        &self.repo
57    }
58
59    /// Get the repository root path (workdir)
60    pub fn root(&self) -> Result<&Path> {
61        self.repo.workdir().ok_or_else(|| {
62            AcpError::Other("Repository has no working directory (bare repo)".into())
63        })
64    }
65
66    /// Get the current HEAD commit SHA (full 40-character hex)
67    pub fn head_commit(&self) -> Result<String> {
68        let head = self
69            .repo
70            .head()
71            .map_err(|e| AcpError::Other(format!("Failed to get HEAD: {}", e)))?;
72        let commit = head
73            .peel_to_commit()
74            .map_err(|e| AcpError::Other(format!("Failed to get HEAD commit: {}", e)))?;
75        Ok(commit.id().to_string())
76    }
77
78    /// Get the short HEAD commit SHA (7 characters)
79    pub fn head_commit_short(&self) -> Result<String> {
80        let full = self.head_commit()?;
81        Ok(full.chars().take(7).collect())
82    }
83
84    /// Get the current branch name (None if detached HEAD)
85    pub fn current_branch(&self) -> Result<Option<String>> {
86        let head = self
87            .repo
88            .head()
89            .map_err(|e| AcpError::Other(format!("Failed to get HEAD: {}", e)))?;
90
91        if head.is_branch() {
92            Ok(head.shorthand().map(String::from))
93        } else {
94            Ok(None) // Detached HEAD
95        }
96    }
97
98    /// Get the URL of a remote (e.g., "origin")
99    pub fn remote_url(&self, name: &str) -> Result<Option<String>> {
100        match self.repo.find_remote(name) {
101            Ok(remote) => Ok(remote.url().map(String::from)),
102            Err(_) => Ok(None),
103        }
104    }
105
106    /// Check if a file path is tracked by git
107    pub fn is_tracked(&self, path: &Path) -> bool {
108        // Make path relative to repo root
109        let relative_path = self.make_relative(path);
110
111        match self.repo.status_file(relative_path.as_ref()) {
112            Ok(status) => !status.contains(Status::WT_NEW) && !status.contains(Status::IGNORED),
113            Err(_) => false,
114        }
115    }
116
117    /// Get the status of a file
118    pub fn file_status(&self, path: &Path) -> Result<FileStatus> {
119        let relative_path = self.make_relative(path);
120
121        let status = self
122            .repo
123            .status_file(relative_path.as_ref())
124            .map_err(|e| AcpError::Other(format!("Failed to get file status: {}", e)))?;
125
126        Ok(Self::convert_status(status))
127    }
128
129    /// Get list of modified files in the repository
130    pub fn modified_files(&self) -> Result<Vec<String>> {
131        let mut opts = StatusOptions::new();
132        opts.include_untracked(false).include_ignored(false);
133
134        let statuses = self
135            .repo
136            .statuses(Some(&mut opts))
137            .map_err(|e| AcpError::Other(format!("Failed to get repository status: {}", e)))?;
138
139        let files: Vec<String> = statuses
140            .iter()
141            .filter_map(|entry| {
142                let status = entry.status();
143                if status.is_wt_modified() || status.is_index_modified() {
144                    entry.path().map(String::from)
145                } else {
146                    None
147                }
148            })
149            .collect();
150
151        Ok(files)
152    }
153
154    /// Check if the repository has uncommitted changes
155    pub fn is_dirty(&self) -> Result<bool> {
156        let mut opts = StatusOptions::new();
157        opts.include_untracked(false).include_ignored(false);
158
159        let statuses = self
160            .repo
161            .statuses(Some(&mut opts))
162            .map_err(|e| AcpError::Other(format!("Failed to get repository status: {}", e)))?;
163
164        Ok(statuses.iter().any(|entry| {
165            let status = entry.status();
166            status.is_wt_modified()
167                || status.is_index_modified()
168                || status.is_wt_deleted()
169                || status.is_index_deleted()
170                || status.is_wt_new()
171                || status.is_index_new()
172        }))
173    }
174
175    /// Make a path relative to the repository root
176    fn make_relative<'a>(&self, path: &'a Path) -> std::borrow::Cow<'a, Path> {
177        if let Ok(root) = self.root() {
178            if let Ok(relative) = path.strip_prefix(root) {
179                return std::borrow::Cow::Owned(relative.to_path_buf());
180            }
181        }
182        std::borrow::Cow::Borrowed(path)
183    }
184
185    /// Convert git2 Status to our FileStatus enum
186    fn convert_status(status: Status) -> FileStatus {
187        if status.is_conflicted() {
188            FileStatus::Conflicted
189        } else if status.is_ignored() {
190            FileStatus::Ignored
191        } else if status.is_wt_new() {
192            FileStatus::Untracked
193        } else if status.is_index_new() {
194            FileStatus::New
195        } else if status.is_wt_deleted() || status.is_index_deleted() {
196            FileStatus::Deleted
197        } else if status.is_index_modified() {
198            FileStatus::Staged
199        } else if status.is_wt_modified() {
200            FileStatus::Modified
201        } else {
202            FileStatus::Clean
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use std::env;
211
212    #[test]
213    fn test_open_current_repo() {
214        // This test runs from within the acp-spec repo
215        let cwd = env::current_dir().unwrap();
216        let repo = GitRepository::open(&cwd);
217        assert!(repo.is_ok(), "Should be able to open current repo");
218    }
219
220    #[test]
221    fn test_head_commit() {
222        let cwd = env::current_dir().unwrap();
223        if let Ok(repo) = GitRepository::open(&cwd) {
224            let commit = repo.head_commit();
225            assert!(commit.is_ok());
226            let sha = commit.unwrap();
227            assert_eq!(sha.len(), 40, "SHA should be 40 characters");
228        }
229    }
230
231    #[test]
232    fn test_head_commit_short() {
233        let cwd = env::current_dir().unwrap();
234        if let Ok(repo) = GitRepository::open(&cwd) {
235            let commit = repo.head_commit_short();
236            assert!(commit.is_ok());
237            let sha = commit.unwrap();
238            assert_eq!(sha.len(), 7, "Short SHA should be 7 characters");
239        }
240    }
241
242    #[test]
243    fn test_current_branch() {
244        let cwd = env::current_dir().unwrap();
245        if let Ok(repo) = GitRepository::open(&cwd) {
246            let branch = repo.current_branch();
247            assert!(branch.is_ok());
248            // Branch might be None if detached HEAD
249        }
250    }
251}