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::io_with_context(
68 format!("creating cache directory {}", config.cache_dir.display()),
69 e.to_string(),
70 )
71 })?;
72
73 let repo_name = Self::extract_repo_name(&config.repository_url)?;
75 let repo_path = config.cache_dir.join(repo_name);
76
77 Ok(Self {
78 config,
79 last_commit: None,
80 repo_path,
81 })
82 }
83
84 fn extract_repo_name(url: &str) -> Result<String> {
86 let name = if let Some(stripped) = url.strip_suffix(".git") {
91 stripped
92 } else {
93 url
94 };
95
96 let parts: Vec<&str> = name.split('/').collect();
98 if let Some(last) = parts.last() {
99 let clean = last.split('?').next().unwrap_or(last);
101 Ok(clean.to_string())
102 } else {
103 Err(Error::config(format!("Invalid repository URL: {}", url)))
104 }
105 }
106
107 pub async fn initialize(&mut self) -> Result<()> {
109 info!(
110 "Initializing Git watch for repository: {} (branch: {})",
111 self.config.repository_url, self.config.branch
112 );
113
114 if self.repo_path.exists() {
115 debug!("Repository exists, updating...");
116 self.update_repository().await?;
117 } else {
118 debug!("Repository does not exist, cloning...");
119 self.clone_repository().await?;
120 }
121
122 self.last_commit = Some(self.get_current_commit()?);
124
125 info!("Git watch initialized successfully");
126 Ok(())
127 }
128
129 async fn clone_repository(&self) -> Result<()> {
131 use std::process::Command;
132
133 let url = if let Some(ref token) = self.config.auth_token {
134 self.inject_auth_token(&self.config.repository_url, token)?
135 } else {
136 self.config.repository_url.clone()
137 };
138
139 let output = Command::new("git")
140 .args([
141 "clone",
142 "--branch",
143 &self.config.branch,
144 "--depth",
145 "1", &url,
147 self.repo_path.to_str().unwrap(),
148 ])
149 .output()
150 .map_err(|e| Error::io_with_context("executing git clone", e.to_string()))?;
151
152 if !output.status.success() {
153 let stderr = String::from_utf8_lossy(&output.stderr);
154 return Err(Error::io_with_context("git clone", stderr.to_string()));
155 }
156
157 info!("Repository cloned successfully");
158 Ok(())
159 }
160
161 async fn update_repository(&self) -> Result<()> {
163 use std::process::Command;
164
165 let repo_path_str = self.repo_path.to_str().unwrap();
166
167 let output = Command::new("git")
169 .args(["-C", repo_path_str, "fetch", "origin", &self.config.branch])
170 .output()
171 .map_err(|e| Error::io_with_context("executing git fetch", e.to_string()))?;
172
173 if !output.status.success() {
174 let stderr = String::from_utf8_lossy(&output.stderr);
175 warn!("Git fetch failed: {}", stderr);
176 }
178
179 let output = Command::new("git")
181 .args([
182 "-C",
183 repo_path_str,
184 "reset",
185 "--hard",
186 &format!("origin/{}", self.config.branch),
187 ])
188 .output()
189 .map_err(|e| Error::io_with_context("executing git reset", e.to_string()))?;
190
191 if !output.status.success() {
192 let stderr = String::from_utf8_lossy(&output.stderr);
193 return Err(Error::io_with_context("git reset", stderr.to_string()));
194 }
195
196 debug!("Repository updated successfully");
197 Ok(())
198 }
199
200 fn get_current_commit(&self) -> Result<String> {
202 use std::process::Command;
203
204 let output = Command::new("git")
205 .args(["-C", self.repo_path.to_str().unwrap(), "rev-parse", "HEAD"])
206 .output()
207 .map_err(|e| Error::io_with_context("executing git rev-parse", e.to_string()))?;
208
209 if !output.status.success() {
210 let stderr = String::from_utf8_lossy(&output.stderr);
211 return Err(Error::io_with_context("git rev-parse", stderr.to_string()));
212 }
213
214 let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
215 Ok(commit)
216 }
217
218 fn inject_auth_token(&self, url: &str, token: &str) -> Result<String> {
220 if url.starts_with("https://") {
222 if let Some(rest) = url.strip_prefix("https://") {
225 return Ok(format!("https://{}@{}", token, rest));
226 }
227 }
228 if url.contains('@') {
231 warn!("SSH URL detected. Token authentication may not work. Consider using HTTPS or SSH keys.");
232 }
233 Ok(url.to_string())
234 }
235
236 pub async fn check_for_changes(&mut self) -> Result<bool> {
238 self.update_repository().await?;
240
241 let current_commit = self.get_current_commit()?;
243
244 if let Some(ref last) = self.last_commit {
246 if last == ¤t_commit {
247 debug!("No changes detected (commit: {})", ¤t_commit[..8]);
248 return Ok(false);
249 }
250 }
251
252 info!(
253 "Changes detected! Previous: {}, Current: {}",
254 self.last_commit.as_ref().map(|c| &c[..8]).unwrap_or("none"),
255 ¤t_commit[..8]
256 );
257
258 self.last_commit = Some(current_commit);
260
261 Ok(true)
262 }
263
264 pub fn get_spec_files(&self) -> Result<Vec<PathBuf>> {
266 use globwalk::GlobWalkerBuilder;
267
268 let mut spec_files = Vec::new();
269
270 for pattern in &self.config.spec_paths {
271 let walker = GlobWalkerBuilder::from_patterns(&self.repo_path, &[pattern])
272 .build()
273 .map_err(|e| {
274 Error::io_with_context(
275 format!("building glob walker for {}", pattern),
276 e.to_string(),
277 )
278 })?;
279
280 for entry in walker {
281 match entry {
282 Ok(entry) => {
283 let path = entry.path();
284 if path.is_file() {
285 spec_files.push(path.to_path_buf());
286 }
287 }
288 Err(e) => {
289 warn!("Error walking path: {}", e);
290 }
291 }
292 }
293 }
294
295 spec_files.sort();
297 spec_files.dedup();
298
299 info!("Found {} OpenAPI spec file(s)", spec_files.len());
300 Ok(spec_files)
301 }
302
303 pub async fn watch<F>(&mut self, mut on_change: F) -> Result<()>
305 where
306 F: FnMut(Vec<PathBuf>) -> Result<()>,
307 {
308 info!(
309 "Starting Git watch mode (polling every {} seconds)",
310 self.config.poll_interval_seconds
311 );
312
313 let mut interval = interval(Duration::from_secs(self.config.poll_interval_seconds));
314
315 loop {
316 interval.tick().await;
317
318 match self.check_for_changes().await {
319 Ok(true) => {
320 match self.get_spec_files() {
322 Ok(spec_files) => {
323 if let Err(e) = on_change(spec_files) {
324 error!("Error handling spec changes: {}", e);
325 }
326 }
327 Err(e) => {
328 error!("Failed to get spec files: {}", e);
329 }
330 }
331 }
332 Ok(false) => {
333 }
335 Err(e) => {
336 error!("Error checking for changes: {}", e);
337 }
339 }
340 }
341 }
342
343 pub fn repo_path(&self) -> &Path {
345 &self.repo_path
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn test_extract_repo_name() {
355 let test_cases = vec![
356 ("https://github.com/user/repo.git", "repo"),
357 ("https://github.com/user/repo", "repo"),
358 ("git@github.com:user/repo.git", "repo"),
359 ("https://gitlab.com/group/project.git", "project"),
360 ];
361
362 for (url, expected) in test_cases {
363 let result = GitWatchService::extract_repo_name(url);
364 assert!(result.is_ok(), "Failed to extract repo name from: {}", url);
365 assert_eq!(result.unwrap(), expected);
366 }
367 }
368
369 #[test]
370 fn test_inject_auth_token() {
371 let config = GitWatchConfig {
372 repository_url: "https://github.com/user/repo.git".to_string(),
373 branch: "main".to_string(),
374 spec_paths: vec!["*.yaml".to_string()],
375 poll_interval_seconds: 60,
376 auth_token: None,
377 cache_dir: PathBuf::from("./test-cache"),
378 enabled: true,
379 };
380
381 let service = GitWatchService::new(config).unwrap();
382 let url = "https://github.com/user/repo.git";
383 let token = "ghp_token123";
384
385 let result = service.inject_auth_token(url, token).unwrap();
386 assert_eq!(result, "https://ghp_token123@github.com/user/repo.git");
387 }
388}