use std::{path::PathBuf, process::Stdio, sync::Arc, time::Duration}; use serde::Serialize; use tokio::{process::Command, sync::Mutex}; const MAX_RESULTS: usize = 20; const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Clone)] pub struct GitRepository { remote_url: String, ssh_command: Option, cache_dir: PathBuf, lock: Arc>, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct BranchMatch { pub name: String, pub commit_hash: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CommitMatch { pub commit_hash: String, pub short_hash: String, pub subject: String, } impl GitRepository { pub fn new(remote_url: String, ssh_command: Option, cache_dir: PathBuf) -> Self { Self { remote_url, ssh_command, cache_dir, lock: Arc::new(Mutex::new(())), } } pub async fn search_branches(&self, query: &str) -> Result, String> { #[cfg(test)] if self.remote_url == "test://preview-repository" { let candidates = [ ( "feature/preview-ui", "0123456789abcdef0123456789abcdef01234567", ), ("feature/busy", "89abcdef0123456789abcdef0123456789abcdef"), ]; return Ok(candidates .into_iter() .filter(|(name, _)| name.contains(query)) .map(|(name, commit_hash)| BranchMatch { name: name.to_string(), commit_hash: commit_hash.to_string(), }) .collect()); } let output = self .run_remote(&["ls-remote", "--heads", &self.remote_url]) .await?; let query = query.to_ascii_lowercase(); let mut matches: Vec<_> = output .lines() .filter_map(|line| { let (commit_hash, reference) = line.split_once('\t')?; let name = reference.strip_prefix("refs/heads/")?; (name.to_ascii_lowercase().contains(&query) && super::validate_branch(name).is_ok() && super::validate_commit(commit_hash).is_ok()) .then(|| BranchMatch { name: name.to_string(), commit_hash: commit_hash.to_ascii_lowercase(), }) }) .collect(); matches.sort_by(|left, right| { branch_rank(&left.name, query.as_str()) .cmp(&branch_rank(&right.name, query.as_str())) .then_with(|| left.name.cmp(&right.name)) }); matches.truncate(MAX_RESULTS); Ok(matches) } pub async fn branch_exists(&self, branch: &str) -> Result { #[cfg(test)] if self.remote_url == "test://preview-repository" { return Ok(matches!(branch, "feature/preview-ui" | "feature/busy")); } let reference = format!("refs/heads/{branch}"); let output = self .run_remote(&["ls-remote", "--heads", &self.remote_url, &reference]) .await?; Ok(output.lines().any(|line| { line.split_once('\t') .is_some_and(|(_, returned)| returned == reference) })) } pub async fn search_commits( &self, branch: &str, query: &str, ) -> Result, String> { #[cfg(test)] if self.remote_url == "test://preview-repository" { if !self.branch_exists(branch).await? { return Err("test branch missing".to_string()); } return Ok(vec![CommitMatch { commit_hash: "0123456789abcdef0123456789abcdef01234567".to_string(), short_hash: "0123456".to_string(), subject: "test preview commit".to_string(), }]); } let _guard = self.lock.lock().await; self.fetch_branch(branch).await?; let branch_ref = format!("refs/remotes/origin/{branch}"); let output = self .run_cached(&[ "log", "--format=%H%x09%h%x09%s", "--max-count=500", &branch_ref, ]) .await?; let query = query.to_ascii_lowercase(); let mut matches: Vec<_> = output .lines() .filter_map(|line| { let mut parts = line.splitn(3, '\t'); let hash = parts.next()?; let short_hash = parts.next()?; let subject = parts.next().unwrap_or_default(); (query.is_empty() || hash.to_ascii_lowercase().starts_with(&query) || subject.to_ascii_lowercase().contains(&query)) .then(|| CommitMatch { commit_hash: hash.to_ascii_lowercase(), short_hash: short_hash.to_ascii_lowercase(), subject: subject.chars().take(200).collect(), }) }) .take(MAX_RESULTS) .collect(); matches.truncate(MAX_RESULTS); Ok(matches) } pub async fn commit_belongs_to_branch( &self, branch: &str, commit: &str, ) -> Result { #[cfg(test)] if self.remote_url == "test://preview-repository" { return Ok(branch == "feature/preview-ui" && commit == "abcdef1"); } let _guard = self.lock.lock().await; self.fetch_branch(branch).await?; let branch_ref = format!("refs/remotes/origin/{branch}"); let resolved = self .run_cached_status(&["rev-parse", "--verify", &format!("{commit}^{{commit}}")]) .await?; if !resolved { return Ok(false); } self.run_cached_status(&["merge-base", "--is-ancestor", commit, &branch_ref]) .await } async fn fetch_branch(&self, branch: &str) -> Result<(), String> { self.ensure_cache().await?; let refspec = format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"); let status = self .command() .arg("-C") .arg(&self.cache_dir) .args(["fetch", "--no-tags", "--prune", "origin", &refspec]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); let status = tokio::time::timeout(COMMAND_TIMEOUT, status) .await .map_err(|_| "Git 分支同步超时".to_string())? .map_err(|error| format!("无法执行 Git 分支同步: {error}"))?; if status.success() { Ok(()) } else { Err("无法从固定仓库同步目标分支".to_string()) } } async fn ensure_cache(&self) -> Result<(), String> { if !self.cache_dir.exists() { tokio::fs::create_dir_all(&self.cache_dir) .await .map_err(|error| format!("无法创建 Git 查询缓存: {error}"))?; self.run_cached(&["init", "--bare"]).await?; } let metadata = tokio::fs::symlink_metadata(&self.cache_dir) .await .map_err(|error| format!("无法读取 Git 查询缓存: {error}"))?; if !metadata.is_dir() || metadata.file_type().is_symlink() { return Err("Git 查询缓存必须是普通目录且不能是符号链接".to_string()); } if !self .run_cached_status(&["remote", "get-url", "origin"]) .await? { self.run_cached(&["remote", "add", "origin", &self.remote_url]) .await?; } else { let current = self.run_cached(&["remote", "get-url", "origin"]).await?; if current.trim() != self.remote_url { return Err("Git 查询缓存的固定远端不匹配".to_string()); } } Ok(()) } async fn run_remote(&self, args: &[&str]) -> Result { self.run(self.command().args(args)).await } async fn run_cached(&self, args: &[&str]) -> Result { self.run(self.command().arg("-C").arg(&self.cache_dir).args(args)) .await } async fn run_cached_status(&self, args: &[&str]) -> Result { let status = self .command() .arg("-C") .arg(&self.cache_dir) .args(args) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); Ok(tokio::time::timeout(COMMAND_TIMEOUT, status) .await .map_err(|_| "Git 查询超时".to_string())? .map_err(|error| format!("无法执行 Git 查询: {error}"))? .success()) } fn command(&self) -> Command { let mut command = Command::new("git"); command .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_CONFIG_NOSYSTEM", "1"); if let Some(value) = &self.ssh_command { command.env("GIT_SSH_COMMAND", value); } command } async fn run(&self, command: &mut Command) -> Result { let output = tokio::time::timeout(COMMAND_TIMEOUT, command.output()) .await .map_err(|_| "Git 查询超时".to_string())? .map_err(|error| format!("无法执行 Git 查询: {error}"))?; if !output.status.success() { return Err("固定 Git 仓库查询失败".to_string()); } String::from_utf8(output.stdout).map_err(|_| "Git 查询返回了无效文本".to_string()) } } fn branch_rank(name: &str, query: &str) -> u8 { let lower = name.to_ascii_lowercase(); if lower == query { 0 } else if lower.starts_with(query) { 1 } else { 2 } }