Files
kdletters abb3912530
Project CI / Frontend tests (push) Successful in 2m54s
Project CI / Native shell tests (push) Successful in 14m0s
Project CI / Repository checks (push) Successful in 1m6s
Project CI / Backend tests (push) Successful in 3m54s
增加分支与提交搜索校验
增加分支和 commit 的输入搜索下拉构建前复核固定仓库中的分支与 commit 归属补充预览控制面接口合同、配置和测试
2026-08-17 12:04:31 +08:00

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
}
}