mockforge_core/
git_watch.rs

1//! Git Watch Mode
2//!
3//! Monitors a Git repository for OpenAPI spec changes and auto-syncs mocks.
4//! This enables contract-driven mocking where mocks stay in sync with API specifications.
5
6use crate::Error;
7use crate::Result;
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11use tokio::time::interval;
12use tracing::{debug, error, info, warn};
13
14/// Git watch configuration
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct GitWatchConfig {
17    /// Repository URL (HTTPS or SSH)
18    pub repository_url: String,
19    /// Branch to watch (default: "main")
20    #[serde(default = "default_branch")]
21    pub branch: String,
22    /// Path to OpenAPI spec file(s) in the repository
23    /// Supports glob patterns (e.g., "**/*.yaml", "specs/*.json")
24    pub spec_paths: Vec<String>,
25    /// Polling interval in seconds (default: 60)
26    #[serde(default = "default_poll_interval")]
27    pub poll_interval_seconds: u64,
28    /// Authentication token for private repositories (optional)
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub auth_token: Option<String>,
31    /// Local cache directory for cloned repository
32    #[serde(default = "default_cache_dir")]
33    pub cache_dir: PathBuf,
34    /// Whether to enable watch mode (default: true)
35    #[serde(default = "default_true")]
36    pub enabled: bool,
37}
38
39fn default_branch() -> String {
40    "main".to_string()
41}
42
43fn default_poll_interval() -> u64 {
44    60
45}
46
47fn default_cache_dir() -> PathBuf {
48    PathBuf::from("./.mockforge-git-cache")
49}
50
51fn default_true() -> bool {
52    true
53}
54
55/// Git watch service that monitors a repository for changes
56pub struct GitWatchService {
57    config: GitWatchConfig,
58    last_commit: Option<String>,
59    repo_path: PathBuf,
60}
61
62impl GitWatchService {
63    /// Create a new Git watch service
64    pub fn new(config: GitWatchConfig) -> Result<Self> {
65        // Create cache directory if it doesn't exist
66        std::fs::create_dir_all(&config.cache_dir).map_err(|e| {
67            Error::generic(format!(
68                "Failed to create cache directory {}: {}",
69                config.cache_dir.display(),
70                e
71            ))
72        })?;
73
74        // Generate repository path from URL
75        let repo_name = Self::extract_repo_name(&config.repository_url)?;
76        let repo_path = config.cache_dir.join(repo_name);
77
78        Ok(Self {
79            config,
80            last_commit: None,
81            repo_path,
82        })
83    }
84
85    /// Extract repository name from URL
86    fn extract_repo_name(url: &str) -> Result<String> {
87        // Handle various URL formats:
88        // - https://github.com/user/repo.git
89        // - git@github.com:user/repo.git
90        // - https://github.com/user/repo
91        let name = if url.ends_with(".git") {
92            &url[..url.len() - 4]
93        } else {
94            url
95        };
96
97        // Extract the last component
98        let parts: Vec<&str> = name.split('/').collect();
99        if let Some(last) = parts.last() {
100            // Remove any query parameters or fragments
101            let clean = last.split('?').next().unwrap_or(last);
102            Ok(clean.to_string())
103        } else {
104            Err(Error::generic(format!("Invalid repository URL: {}", url)))
105        }
106    }
107
108    /// Initialize the repository (clone if needed, update if exists)
109    pub async fn initialize(&mut self) -> Result<()> {
110        info!(
111            "Initializing Git watch for repository: {} (branch: {})",
112            self.config.repository_url, self.config.branch
113        );
114
115        if self.repo_path.exists() {
116            debug!("Repository exists, updating...");
117            self.update_repository().await?;
118        } else {
119            debug!("Repository does not exist, cloning...");
120            self.clone_repository().await?;
121        }
122
123        // Get initial commit hash
124        self.last_commit = Some(self.get_current_commit()?);
125
126        info!("Git watch initialized successfully");
127        Ok(())
128    }
129
130    /// Clone the repository
131    async fn clone_repository(&self) -> Result<()> {
132        use std::process::Command;
133
134        let url = if let Some(ref token) = self.config.auth_token {
135            self.inject_auth_token(&self.config.repository_url, token)?
136        } else {
137            self.config.repository_url.clone()
138        };
139
140        let output = Command::new("git")
141            .args([
142                "clone",
143                "--branch",
144                &self.config.branch,
145                "--depth",
146                "1", // Shallow clone for performance
147                &url,
148                self.repo_path.to_str().unwrap(),
149            ])
150            .output()
151            .map_err(|e| Error::generic(format!("Failed to execute git clone: {}", e)))?;
152
153        if !output.status.success() {
154            let stderr = String::from_utf8_lossy(&output.stderr);
155            return Err(Error::generic(format!("Git clone failed: {}", stderr)));
156        }
157
158        info!("Repository cloned successfully");
159        Ok(())
160    }
161
162    /// Update the repository (fetch and checkout)
163    async fn update_repository(&self) -> Result<()> {
164        use std::process::Command;
165
166        let repo_path_str = self.repo_path.to_str().unwrap();
167
168        // Fetch latest changes
169        let output = Command::new("git")
170            .args(["-C", repo_path_str, "fetch", "origin", &self.config.branch])
171            .output()
172            .map_err(|e| Error::generic(format!("Failed to execute git fetch: {}", e)))?;
173
174        if !output.status.success() {
175            let stderr = String::from_utf8_lossy(&output.stderr);
176            warn!("Git fetch failed: {}", stderr);
177            // Continue anyway, might be network issue
178        }
179
180        // Reset to remote branch
181        let output = Command::new("git")
182            .args([
183                "-C",
184                repo_path_str,
185                "reset",
186                "--hard",
187                &format!("origin/{}", self.config.branch),
188            ])
189            .output()
190            .map_err(|e| Error::generic(format!("Failed to execute git reset: {}", e)))?;
191
192        if !output.status.success() {
193            let stderr = String::from_utf8_lossy(&output.stderr);
194            return Err(Error::generic(format!("Git reset failed: {}", stderr)));
195        }
196
197        debug!("Repository updated successfully");
198        Ok(())
199    }
200
201    /// Get current commit hash
202    fn get_current_commit(&self) -> Result<String> {
203        use std::process::Command;
204
205        let output = Command::new("git")
206            .args(["-C", self.repo_path.to_str().unwrap(), "rev-parse", "HEAD"])
207            .output()
208            .map_err(|e| Error::generic(format!("Failed to execute git rev-parse: {}", e)))?;
209
210        if !output.status.success() {
211            let stderr = String::from_utf8_lossy(&output.stderr);
212            return Err(Error::generic(format!("Git rev-parse failed: {}", stderr)));
213        }
214
215        let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
216        Ok(commit)
217    }
218
219    /// Inject authentication token into repository URL
220    fn inject_auth_token(&self, url: &str, token: &str) -> Result<String> {
221        // Handle HTTPS URLs
222        if url.starts_with("https://") {
223            // Insert token before the hostname
224            // https://github.com/user/repo -> https://token@github.com/user/repo
225            if let Some(rest) = url.strip_prefix("https://") {
226                return Ok(format!("https://{}@{}", token, rest));
227            }
228        }
229        // For SSH URLs, token injection is more complex and typically uses SSH keys
230        // For now, return the original URL and log a warning
231        if url.contains('@') {
232            warn!("SSH URL detected. Token authentication may not work. Consider using HTTPS or SSH keys.");
233        }
234        Ok(url.to_string())
235    }
236
237    /// Check for changes in the repository
238    pub async fn check_for_changes(&mut self) -> Result<bool> {
239        // Update repository
240        self.update_repository().await?;
241
242        // Get current commit
243        let current_commit = self.get_current_commit()?;
244
245        // Compare with last known commit
246        if let Some(ref last) = self.last_commit {
247            if last == &current_commit {
248                debug!("No changes detected (commit: {})", &current_commit[..8]);
249                return Ok(false);
250            }
251        }
252
253        info!(
254            "Changes detected! Previous: {}, Current: {}",
255            self.last_commit.as_ref().map(|c| &c[..8]).unwrap_or("none"),
256            &current_commit[..8]
257        );
258
259        // Update last commit
260        self.last_commit = Some(current_commit);
261
262        Ok(true)
263    }
264
265    /// Get paths to OpenAPI spec files
266    pub fn get_spec_files(&self) -> Result<Vec<PathBuf>> {
267        use globwalk::GlobWalkerBuilder;
268
269        let mut spec_files = Vec::new();
270
271        for pattern in &self.config.spec_paths {
272            let walker = GlobWalkerBuilder::from_patterns(&self.repo_path, &[pattern])
273                .build()
274                .map_err(|e| {
275                    Error::generic(format!("Failed to build glob walker for {}: {}", pattern, e))
276                })?;
277
278            for entry in walker {
279                match entry {
280                    Ok(entry) => {
281                        let path = entry.path();
282                        if path.is_file() {
283                            spec_files.push(path.to_path_buf());
284                        }
285                    }
286                    Err(e) => {
287                        warn!("Error walking path: {}", e);
288                    }
289                }
290            }
291        }
292
293        // Remove duplicates and sort
294        spec_files.sort();
295        spec_files.dedup();
296
297        info!("Found {} OpenAPI spec file(s)", spec_files.len());
298        Ok(spec_files)
299    }
300
301    /// Start watching the repository
302    pub async fn watch<F>(&mut self, mut on_change: F) -> Result<()>
303    where
304        F: FnMut(Vec<PathBuf>) -> Result<()>,
305    {
306        info!(
307            "Starting Git watch mode (polling every {} seconds)",
308            self.config.poll_interval_seconds
309        );
310
311        let mut interval = interval(Duration::from_secs(self.config.poll_interval_seconds));
312
313        loop {
314            interval.tick().await;
315
316            match self.check_for_changes().await {
317                Ok(true) => {
318                    // Changes detected, get spec files and notify
319                    match self.get_spec_files() {
320                        Ok(spec_files) => {
321                            if let Err(e) = on_change(spec_files) {
322                                error!("Error handling spec changes: {}", e);
323                            }
324                        }
325                        Err(e) => {
326                            error!("Failed to get spec files: {}", e);
327                        }
328                    }
329                }
330                Ok(false) => {
331                    // No changes, continue
332                }
333                Err(e) => {
334                    error!("Error checking for changes: {}", e);
335                    // Continue watching despite errors
336                }
337            }
338        }
339    }
340
341    /// Get the repository path
342    pub fn repo_path(&self) -> &Path {
343        &self.repo_path
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn test_extract_repo_name() {
353        let test_cases = vec![
354            ("https://github.com/user/repo.git", "repo"),
355            ("https://github.com/user/repo", "repo"),
356            ("git@github.com:user/repo.git", "repo"),
357            ("https://gitlab.com/group/project.git", "project"),
358        ];
359
360        for (url, expected) in test_cases {
361            let result = GitWatchService::extract_repo_name(url);
362            assert!(result.is_ok(), "Failed to extract repo name from: {}", url);
363            assert_eq!(result.unwrap(), expected);
364        }
365    }
366
367    #[test]
368    fn test_inject_auth_token() {
369        let config = GitWatchConfig {
370            repository_url: "https://github.com/user/repo.git".to_string(),
371            branch: "main".to_string(),
372            spec_paths: vec!["*.yaml".to_string()],
373            poll_interval_seconds: 60,
374            auth_token: None,
375            cache_dir: PathBuf::from("./test-cache"),
376            enabled: true,
377        };
378
379        let service = GitWatchService::new(config).unwrap();
380        let url = "https://github.com/user/repo.git";
381        let token = "ghp_token123";
382
383        let result = service.inject_auth_token(url, token).unwrap();
384        assert_eq!(result, "https://ghp_token123@github.com/user/repo.git");
385    }
386}