vibe_graph_git/
lib.rs

1//! Git fossilization helpers and real-time change detection.
2
3use 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
10/// Abstraction describing how snapshots are persisted and retrieved.
11pub trait GitFossilStore {
12    /// Persist the provided snapshot into the fossil store.
13    fn commit_snapshot(&self, snapshot: &Snapshot) -> Result<()>;
14
15    /// Retrieve the latest snapshot if one exists.
16    fn get_latest_snapshot(&self) -> Result<Option<Snapshot>>;
17}
18
19/// Default filesystem-backed Git store.
20pub struct GitBackend {
21    /// Filesystem path to the repository managed by this backend.
22    pub repo_path: PathBuf,
23}
24
25impl GitBackend {
26    /// Construct a backend targeting the provided repository path.
27    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        // Placeholder for future git2/plumbing integration.
35        Ok(())
36    }
37
38    fn get_latest_snapshot(&self) -> Result<Option<Snapshot>> {
39        Ok(None)
40    }
41}
42
43// =============================================================================
44// Git Change Watcher
45// =============================================================================
46
47/// Configuration for the git watcher.
48#[derive(Debug, Clone)]
49pub struct GitWatcherConfig {
50    /// Minimum interval between polls (to avoid hammering the filesystem).
51    pub min_poll_interval: Duration,
52    /// Whether to include untracked files.
53    pub include_untracked: bool,
54    /// Whether to include ignored files.
55    pub include_ignored: bool,
56    /// Whether to recurse into submodules.
57    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
71/// Watches a git repository for changes.
72///
73/// Uses polling-based approach with git2 for efficient status checks.
74pub struct GitWatcher {
75    /// Path to the repository root.
76    repo_path: PathBuf,
77    /// Configuration.
78    config: GitWatcherConfig,
79    /// Last poll time.
80    last_poll: Option<Instant>,
81    /// Cached snapshot.
82    cached_snapshot: GitChangeSnapshot,
83}
84
85impl GitWatcher {
86    /// Create a new watcher for the given repository path.
87    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    /// Create with custom configuration.
97    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    /// Get the repository path.
107    pub fn repo_path(&self) -> &Path {
108        &self.repo_path
109    }
110
111    /// Check if it's time to poll again.
112    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    /// Get the cached snapshot (may be stale).
120    pub fn cached_snapshot(&self) -> &GitChangeSnapshot {
121        &self.cached_snapshot
122    }
123
124    /// Poll for changes, returning the current snapshot.
125    ///
126    /// This is rate-limited by `min_poll_interval`. If called too frequently,
127    /// returns the cached snapshot.
128    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    /// Force fetch changes regardless of rate limiting.
139    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    /// Fetch current git status and convert to GitChangeSnapshot.
146    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            // Map git2 status flags to our GitChangeKind
171            // Check staged changes first (index)
172            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            // Check working directory changes (not yet staged)
199            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
233/// Quick helper to get current changes for a path.
234pub 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), // Long interval
279            ..Default::default()
280        };
281        let mut watcher = GitWatcher::with_config(dir.path(), config);
282
283        // First poll should work
284        assert!(watcher.should_poll());
285        watcher.poll()?;
286
287        // Second poll should be rate-limited
288        assert!(!watcher.should_poll());
289        Ok(())
290    }
291}