283 lines
9.8 KiB
Rust
283 lines
9.8 KiB
Rust
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<String>,
|
|
cache_dir: PathBuf,
|
|
lock: Arc<Mutex<()>>,
|
|
}
|
|
|
|
#[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<String>, 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<Vec<BranchMatch>, 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<bool, String> {
|
|
#[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<Vec<CommitMatch>, 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<bool, String> {
|
|
#[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<String, String> {
|
|
self.run(self.command().args(args)).await
|
|
}
|
|
|
|
async fn run_cached(&self, args: &[&str]) -> Result<String, String> {
|
|
self.run(self.command().arg("-C").arg(&self.cache_dir).args(args))
|
|
.await
|
|
}
|
|
|
|
async fn run_cached_status(&self, args: &[&str]) -> Result<bool, String> {
|
|
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<String, String> {
|
|
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
|
|
}
|
|
}
|