1use crate::error::{AcpError, Result};
7use git2::{Repository, Status, StatusOptions};
8use std::path::Path;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum FileStatus {
13 Clean,
15 Modified,
17 Staged,
19 Untracked,
21 Conflicted,
23 Deleted,
25 Ignored,
27 New,
29}
30
31impl FileStatus {
32 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
41pub struct GitRepository {
43 repo: Repository,
44}
45
46impl GitRepository {
47 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 pub(crate) fn inner(&self) -> &Repository {
56 &self.repo
57 }
58
59 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 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 pub fn head_commit_short(&self) -> Result<String> {
80 let full = self.head_commit()?;
81 Ok(full.chars().take(7).collect())
82 }
83
84 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) }
96 }
97
98 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 pub fn is_tracked(&self, path: &Path) -> bool {
108 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 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 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 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 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 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 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 }
250 }
251}