1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct GitWatchConfig {
17 pub repository_url: String,
19 #[serde(default = "default_branch")]
21 pub branch: String,
22 pub spec_paths: Vec<String>,
25 #[serde(default = "default_poll_interval")]
27 pub poll_interval_seconds: u64,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub auth_token: Option<String>,
31 #[serde(default = "default_cache_dir")]
33 pub cache_dir: PathBuf,
34 #[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
55pub struct GitWatchService {
57 config: GitWatchConfig,
58 last_commit: Option<String>,
59 repo_path: PathBuf,
60}
61
62impl GitWatchService {
63 pub fn new(config: GitWatchConfig) -> Result<Self> {
65 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 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 fn extract_repo_name(url: &str) -> Result<String> {
87 let name = if url.ends_with(".git") {
92 &url[..url.len() - 4]
93 } else {
94 url
95 };
96
97 let parts: Vec<&str> = name.split('/').collect();
99 if let Some(last) = parts.last() {
100 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 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 self.last_commit = Some(self.get_current_commit()?);
125
126 info!("Git watch initialized successfully");
127 Ok(())
128 }
129
130 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", &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 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 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 }
179
180 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 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 fn inject_auth_token(&self, url: &str, token: &str) -> Result<String> {
221 if url.starts_with("https://") {
223 if let Some(rest) = url.strip_prefix("https://") {
226 return Ok(format!("https://{}@{}", token, rest));
227 }
228 }
229 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 pub async fn check_for_changes(&mut self) -> Result<bool> {
239 self.update_repository().await?;
241
242 let current_commit = self.get_current_commit()?;
244
245 if let Some(ref last) = self.last_commit {
247 if last == ¤t_commit {
248 debug!("No changes detected (commit: {})", ¤t_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 ¤t_commit[..8]
257 );
258
259 self.last_commit = Some(current_commit);
261
262 Ok(true)
263 }
264
265 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 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 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 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 }
333 Err(e) => {
334 error!("Error checking for changes: {}", e);
335 }
337 }
338 }
339 }
340
341 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}