1use std::path::{Path, PathBuf};
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result};
7use git2::{Repository, Status, StatusOptions};
8use vibe_graph_core::{GitChangeKind, GitChangeSnapshot, GitFileChange, Snapshot};
9
10pub trait GitFossilStore {
12 fn commit_snapshot(&self, snapshot: &Snapshot) -> Result<()>;
14
15 fn get_latest_snapshot(&self) -> Result<Option<Snapshot>>;
17}
18
19pub struct GitBackend {
21 pub repo_path: PathBuf,
23}
24
25impl GitBackend {
26 pub fn new(repo_path: PathBuf) -> Self {
28 Self { repo_path }
29 }
30}
31
32impl GitFossilStore for GitBackend {
33 fn commit_snapshot(&self, _snapshot: &Snapshot) -> Result<()> {
34 Ok(())
36 }
37
38 fn get_latest_snapshot(&self) -> Result<Option<Snapshot>> {
39 Ok(None)
40 }
41}
42
43#[derive(Debug, Clone)]
49pub struct GitWatcherConfig {
50 pub min_poll_interval: Duration,
52 pub include_untracked: bool,
54 pub include_ignored: bool,
56 pub recurse_submodules: bool,
58}
59
60impl Default for GitWatcherConfig {
61 fn default() -> Self {
62 Self {
63 min_poll_interval: Duration::from_millis(500),
64 include_untracked: true,
65 include_ignored: false,
66 recurse_submodules: false,
67 }
68 }
69}
70
71pub struct GitWatcher {
75 repo_path: PathBuf,
77 config: GitWatcherConfig,
79 last_poll: Option<Instant>,
81 cached_snapshot: GitChangeSnapshot,
83}
84
85impl GitWatcher {
86 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
88 Self {
89 repo_path: repo_path.into(),
90 config: GitWatcherConfig::default(),
91 last_poll: None,
92 cached_snapshot: GitChangeSnapshot::default(),
93 }
94 }
95
96 pub fn with_config(repo_path: impl Into<PathBuf>, config: GitWatcherConfig) -> Self {
98 Self {
99 repo_path: repo_path.into(),
100 config,
101 last_poll: None,
102 cached_snapshot: GitChangeSnapshot::default(),
103 }
104 }
105
106 pub fn repo_path(&self) -> &Path {
108 &self.repo_path
109 }
110
111 pub fn should_poll(&self) -> bool {
113 match self.last_poll {
114 Some(last) => last.elapsed() >= self.config.min_poll_interval,
115 None => true,
116 }
117 }
118
119 pub fn cached_snapshot(&self) -> &GitChangeSnapshot {
121 &self.cached_snapshot
122 }
123
124 pub fn poll(&mut self) -> Result<&GitChangeSnapshot> {
129 if !self.should_poll() {
130 return Ok(&self.cached_snapshot);
131 }
132
133 self.cached_snapshot = self.fetch_changes()?;
134 self.last_poll = Some(Instant::now());
135 Ok(&self.cached_snapshot)
136 }
137
138 pub fn force_poll(&mut self) -> Result<&GitChangeSnapshot> {
140 self.cached_snapshot = self.fetch_changes()?;
141 self.last_poll = Some(Instant::now());
142 Ok(&self.cached_snapshot)
143 }
144
145 fn fetch_changes(&self) -> Result<GitChangeSnapshot> {
147 let repo = Repository::open(&self.repo_path)
148 .with_context(|| format!("Failed to open repository at {:?}", self.repo_path))?;
149
150 let mut opts = StatusOptions::new();
151 opts.include_untracked(self.config.include_untracked)
152 .include_ignored(self.config.include_ignored)
153 .recurse_untracked_dirs(true)
154 .exclude_submodules(true);
155
156 let statuses = repo
157 .statuses(Some(&mut opts))
158 .context("Failed to get repository status")?;
159
160 let mut changes = Vec::new();
161
162 for entry in statuses.iter() {
163 let path = match entry.path() {
164 Some(p) => PathBuf::from(p),
165 None => continue,
166 };
167
168 let status = entry.status();
169
170 if status.contains(Status::INDEX_NEW) {
173 changes.push(GitFileChange {
174 path: path.clone(),
175 kind: GitChangeKind::Added,
176 staged: true,
177 });
178 } else if status.contains(Status::INDEX_MODIFIED) {
179 changes.push(GitFileChange {
180 path: path.clone(),
181 kind: GitChangeKind::Modified,
182 staged: true,
183 });
184 } else if status.contains(Status::INDEX_DELETED) {
185 changes.push(GitFileChange {
186 path: path.clone(),
187 kind: GitChangeKind::Deleted,
188 staged: true,
189 });
190 } else if status.contains(Status::INDEX_RENAMED) {
191 changes.push(GitFileChange {
192 path: path.clone(),
193 kind: GitChangeKind::RenamedTo,
194 staged: true,
195 });
196 }
197
198 if status.contains(Status::WT_NEW) {
200 changes.push(GitFileChange {
201 path: path.clone(),
202 kind: GitChangeKind::Added,
203 staged: false,
204 });
205 } else if status.contains(Status::WT_MODIFIED) {
206 changes.push(GitFileChange {
207 path: path.clone(),
208 kind: GitChangeKind::Modified,
209 staged: false,
210 });
211 } else if status.contains(Status::WT_DELETED) {
212 changes.push(GitFileChange {
213 path: path.clone(),
214 kind: GitChangeKind::Deleted,
215 staged: false,
216 });
217 } else if status.contains(Status::WT_RENAMED) {
218 changes.push(GitFileChange {
219 path: path.clone(),
220 kind: GitChangeKind::RenamedTo,
221 staged: false,
222 });
223 }
224 }
225
226 Ok(GitChangeSnapshot {
227 changes,
228 captured_at: Some(Instant::now()),
229 })
230 }
231}
232
233pub fn get_git_changes(repo_path: &Path) -> Result<GitChangeSnapshot> {
235 let mut watcher = GitWatcher::new(repo_path);
236 watcher.force_poll().cloned()
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use std::fs;
243 use tempfile::TempDir;
244
245 fn init_test_repo() -> Result<(TempDir, Repository)> {
246 let dir = TempDir::new()?;
247 let repo = Repository::init(dir.path())?;
248 Ok((dir, repo))
249 }
250
251 #[test]
252 fn test_watcher_empty_repo() -> Result<()> {
253 let (dir, _repo) = init_test_repo()?;
254 let mut watcher = GitWatcher::new(dir.path());
255 let snapshot = watcher.force_poll()?;
256 assert!(snapshot.changes.is_empty());
257 Ok(())
258 }
259
260 #[test]
261 fn test_watcher_detects_new_file() -> Result<()> {
262 let (dir, _repo) = init_test_repo()?;
263 fs::write(dir.path().join("new_file.txt"), "hello")?;
264
265 let mut watcher = GitWatcher::new(dir.path());
266 let snapshot = watcher.force_poll()?;
267
268 assert_eq!(snapshot.changes.len(), 1);
269 assert_eq!(snapshot.changes[0].kind, GitChangeKind::Added);
270 assert!(!snapshot.changes[0].staged);
271 Ok(())
272 }
273
274 #[test]
275 fn test_watcher_rate_limiting() -> Result<()> {
276 let (dir, _repo) = init_test_repo()?;
277 let config = GitWatcherConfig {
278 min_poll_interval: Duration::from_secs(60), ..Default::default()
280 };
281 let mut watcher = GitWatcher::with_config(dir.path(), config);
282
283 assert!(watcher.should_poll());
285 watcher.poll()?;
286
287 assert!(!watcher.should_poll());
289 Ok(())
290 }
291}