use crate::Error;
use crate::Result;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::time::interval;
use tracing::{debug, error, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitWatchConfig {
pub repository_url: String,
#[serde(default = "default_branch")]
pub branch: String,
pub spec_paths: Vec<String>,
#[serde(default = "default_poll_interval")]
pub poll_interval_seconds: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_token: Option<String>,
#[serde(default = "default_cache_dir")]
pub cache_dir: PathBuf,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_branch() -> String {
"main".to_string()
}
fn default_poll_interval() -> u64 {
60
}
fn default_cache_dir() -> PathBuf {
PathBuf::from("./.mockforge-git-cache")
}
fn default_true() -> bool {
true
}
pub struct GitWatchService {
config: GitWatchConfig,
last_commit: Option<String>,
repo_path: PathBuf,
}
impl GitWatchService {
pub fn new(config: GitWatchConfig) -> Result<Self> {
std::fs::create_dir_all(&config.cache_dir).map_err(|e| {
Error::io_with_context(
format!("creating cache directory {}", config.cache_dir.display()),
e.to_string(),
)
})?;
let repo_name = Self::extract_repo_name(&config.repository_url)?;
let repo_path = config.cache_dir.join(repo_name);
Ok(Self {
config,
last_commit: None,
repo_path,
})
}
fn extract_repo_name(url: &str) -> Result<String> {
let name = if let Some(stripped) = url.strip_suffix(".git") {
stripped
} else {
url
};
let parts: Vec<&str> = name.split('/').collect();
if let Some(last) = parts.last() {
let clean = last.split('?').next().unwrap_or(last);
Ok(clean.to_string())
} else {
Err(Error::config(format!("Invalid repository URL: {}", url)))
}
}
pub async fn initialize(&mut self) -> Result<()> {
info!(
"Initializing Git watch for repository: {} (branch: {})",
self.config.repository_url, self.config.branch
);
if self.repo_path.exists() {
debug!("Repository exists, updating...");
self.update_repository().await?;
} else {
debug!("Repository does not exist, cloning...");
self.clone_repository().await?;
}
self.last_commit = Some(self.get_current_commit()?);
info!("Git watch initialized successfully");
Ok(())
}
async fn clone_repository(&self) -> Result<()> {
use std::process::Command;
let url = if let Some(ref token) = self.config.auth_token {
self.inject_auth_token(&self.config.repository_url, token)?
} else {
self.config.repository_url.clone()
};
let output = Command::new("git")
.args([
"clone",
"--branch",
&self.config.branch,
"--depth",
"1", &url,
self.repo_path.to_str().unwrap(),
])
.output()
.map_err(|e| Error::io_with_context("executing git clone", e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::io_with_context("git clone", stderr.to_string()));
}
info!("Repository cloned successfully");
Ok(())
}
async fn update_repository(&self) -> Result<()> {
use std::process::Command;
let repo_path_str = self.repo_path.to_str().unwrap();
let output = Command::new("git")
.args(["-C", repo_path_str, "fetch", "origin", &self.config.branch])
.output()
.map_err(|e| Error::io_with_context("executing git fetch", e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Git fetch failed: {}", stderr);
}
let output = Command::new("git")
.args([
"-C",
repo_path_str,
"reset",
"--hard",
&format!("origin/{}", self.config.branch),
])
.output()
.map_err(|e| Error::io_with_context("executing git reset", e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::io_with_context("git reset", stderr.to_string()));
}
debug!("Repository updated successfully");
Ok(())
}
fn get_current_commit(&self) -> Result<String> {
use std::process::Command;
let output = Command::new("git")
.args(["-C", self.repo_path.to_str().unwrap(), "rev-parse", "HEAD"])
.output()
.map_err(|e| Error::io_with_context("executing git rev-parse", e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::io_with_context("git rev-parse", stderr.to_string()));
}
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(commit)
}
fn inject_auth_token(&self, url: &str, token: &str) -> Result<String> {
if url.starts_with("https://") {
if let Some(rest) = url.strip_prefix("https://") {
return Ok(format!("https://{}@{}", token, rest));
}
}
if url.contains('@') {
warn!("SSH URL detected. Token authentication may not work. Consider using HTTPS or SSH keys.");
}
Ok(url.to_string())
}
pub async fn check_for_changes(&mut self) -> Result<bool> {
self.update_repository().await?;
let current_commit = self.get_current_commit()?;
if let Some(ref last) = self.last_commit {
if last == ¤t_commit {
debug!("No changes detected (commit: {})", ¤t_commit[..8]);
return Ok(false);
}
}
info!(
"Changes detected! Previous: {}, Current: {}",
self.last_commit.as_ref().map(|c| &c[..8]).unwrap_or("none"),
¤t_commit[..8]
);
self.last_commit = Some(current_commit);
Ok(true)
}
pub fn get_spec_files(&self) -> Result<Vec<PathBuf>> {
use globwalk::GlobWalkerBuilder;
let mut spec_files = Vec::new();
for pattern in &self.config.spec_paths {
let walker = GlobWalkerBuilder::from_patterns(&self.repo_path, &[pattern])
.build()
.map_err(|e| {
Error::io_with_context(
format!("building glob walker for {}", pattern),
e.to_string(),
)
})?;
for entry in walker {
match entry {
Ok(entry) => {
let path = entry.path();
if path.is_file() {
spec_files.push(path.to_path_buf());
}
}
Err(e) => {
warn!("Error walking path: {}", e);
}
}
}
}
spec_files.sort();
spec_files.dedup();
info!("Found {} OpenAPI spec file(s)", spec_files.len());
Ok(spec_files)
}
pub async fn watch<F>(&mut self, mut on_change: F) -> Result<()>
where
F: FnMut(Vec<PathBuf>) -> Result<()>,
{
info!(
"Starting Git watch mode (polling every {} seconds)",
self.config.poll_interval_seconds
);
let mut interval = interval(Duration::from_secs(self.config.poll_interval_seconds));
loop {
interval.tick().await;
match self.check_for_changes().await {
Ok(true) => {
match self.get_spec_files() {
Ok(spec_files) => {
if let Err(e) = on_change(spec_files) {
error!("Error handling spec changes: {}", e);
}
}
Err(e) => {
error!("Failed to get spec files: {}", e);
}
}
}
Ok(false) => {
}
Err(e) => {
error!("Error checking for changes: {}", e);
}
}
}
}
pub fn repo_path(&self) -> &Path {
&self.repo_path
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_repo_name() {
let test_cases = vec![
("https://github.com/user/repo.git", "repo"),
("https://github.com/user/repo", "repo"),
("git@github.com:user/repo.git", "repo"),
("https://gitlab.com/group/project.git", "project"),
];
for (url, expected) in test_cases {
let result = GitWatchService::extract_repo_name(url);
assert!(result.is_ok(), "Failed to extract repo name from: {}", url);
assert_eq!(result.unwrap(), expected);
}
}
#[test]
fn test_inject_auth_token() {
let config = GitWatchConfig {
repository_url: "https://github.com/user/repo.git".to_string(),
branch: "main".to_string(),
spec_paths: vec!["*.yaml".to_string()],
poll_interval_seconds: 60,
auth_token: None,
cache_dir: PathBuf::from("./test-cache"),
enabled: true,
};
let service = GitWatchService::new(config).unwrap();
let url = "https://github.com/user/repo.git";
let token = "ghp_token123";
let result = service.inject_auth_token(url, token).unwrap();
assert_eq!(result, "https://ghp_token123@github.com/user/repo.git");
}
}