Gitea、webhook 与 API Key 以受保护引用替代常态明文输出;旧值先备份并校验后可恢复迁移,MCP 仅提供项目只读状态。 Rules-Applied: R01,R05,R06,R07,R10 Feature-Confirmed: true
384 lines
13 KiB
Rust
384 lines
13 KiB
Rust
use crate::db;
|
||
use git2::{build::RepoBuilder, FetchOptions, RemoteCallbacks, Repository};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
#[derive(Debug, Serialize, Deserialize)]
|
||
pub struct GitStatus {
|
||
pub branch: String,
|
||
pub ahead: usize,
|
||
pub behind: usize,
|
||
pub has_changes: bool,
|
||
pub last_commit_msg: Option<String>,
|
||
pub last_commit_time: Option<String>,
|
||
}
|
||
|
||
/// 按 URL 前缀匹配已注册的 Gitea 实例,返回其 access_token
|
||
fn gitea_token_for_url(url: &str) -> Option<String> {
|
||
let conn = db::pool().get().ok()?;
|
||
let instances: Vec<(String, String)> = {
|
||
let mut stmt = conn.prepare("SELECT id, url FROM gitea_instances").ok()?;
|
||
let rows = stmt
|
||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
||
.ok()?
|
||
.filter_map(Result::ok)
|
||
.collect();
|
||
rows
|
||
};
|
||
for (instance_id, base) in instances {
|
||
if gitea_url_matches(&base, url) {
|
||
return crate::commands::gitea::instance_auth(&conn, &instance_id)
|
||
.ok()
|
||
.map(|(_, token)| token);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn gitea_url_matches(base: &str, remote: &str) -> bool {
|
||
let (Ok(base), Ok(remote)) = (tauri::Url::parse(base), tauri::Url::parse(remote)) else {
|
||
return false;
|
||
};
|
||
if !matches!(base.scheme(), "http" | "https")
|
||
|| base.scheme() != remote.scheme()
|
||
|| base.host_str() != remote.host_str()
|
||
|| base.port_or_known_default() != remote.port_or_known_default()
|
||
{
|
||
return false;
|
||
}
|
||
let prefix = base.path().trim_end_matches('/');
|
||
prefix.is_empty() || remote.path() == prefix || remote.path().starts_with(&format!("{prefix}/"))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod credential_match_tests {
|
||
use super::gitea_url_matches;
|
||
|
||
#[test]
|
||
fn gitea_token_url_match_requires_same_origin_and_path_boundary() {
|
||
assert!(gitea_url_matches(
|
||
"https://gitea.example.com",
|
||
"https://gitea.example.com/o/r.git"
|
||
));
|
||
assert!(gitea_url_matches(
|
||
"https://gitea.example.com/git",
|
||
"https://gitea.example.com/git/o/r.git"
|
||
));
|
||
assert!(!gitea_url_matches(
|
||
"https://gitea.example.com",
|
||
"https://gitea.example.com.evil/o/r.git"
|
||
));
|
||
assert!(!gitea_url_matches(
|
||
"https://gitea.example.com/git",
|
||
"https://gitea.example.com/github/o/r.git"
|
||
));
|
||
assert!(!gitea_url_matches(
|
||
"https://gitea.example.com",
|
||
"http://gitea.example.com/o/r.git"
|
||
));
|
||
}
|
||
}
|
||
|
||
/// 凭据链:ssh agent → git credential helper(与 CLI 同一凭据源)→ Gitea 实例 token → default
|
||
/// 不再依赖 GitHub 登录,任何 remote(Gitea / GitHub / 其他)走系统已有凭据
|
||
fn make_callbacks() -> RemoteCallbacks<'static> {
|
||
let mut callbacks = RemoteCallbacks::new();
|
||
callbacks.credentials(|url, username_from_url, allowed| {
|
||
if allowed.contains(git2::CredentialType::SSH_KEY) {
|
||
if let Ok(cred) = git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) {
|
||
return Ok(cred);
|
||
}
|
||
}
|
||
if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
|
||
if let Ok(config) = git2::Config::open_default() {
|
||
if let Ok(cred) = git2::Cred::credential_helper(&config, url, username_from_url) {
|
||
return Ok(cred);
|
||
}
|
||
}
|
||
if let Some(token) = gitea_token_for_url(url) {
|
||
if let Ok(cred) = git2::Cred::userpass_plaintext(&token, "") {
|
||
return Ok(cred);
|
||
}
|
||
}
|
||
}
|
||
git2::Cred::default()
|
||
});
|
||
callbacks
|
||
}
|
||
|
||
fn make_fetch_options() -> FetchOptions<'static> {
|
||
let mut fetch_opts = FetchOptions::new();
|
||
fetch_opts.remote_callbacks(make_callbacks());
|
||
fetch_opts
|
||
}
|
||
|
||
/// clone 仓库到指定本地路径
|
||
#[tauri::command]
|
||
pub fn git_clone(
|
||
repo_url: String,
|
||
local_path: String,
|
||
upstream_url: Option<String>,
|
||
) -> Result<(), String> {
|
||
let fetch_opts = make_fetch_options();
|
||
|
||
let repo = RepoBuilder::new()
|
||
.fetch_options(fetch_opts)
|
||
.clone(&repo_url, std::path::Path::new(&local_path))
|
||
.map_err(|e| format!("clone 失败: {}", e))?;
|
||
|
||
if let Some(upstream) = upstream_url {
|
||
if !upstream.trim().is_empty() {
|
||
repo.remote("upstream", upstream.trim())
|
||
.map_err(|e| format!("设置 upstream remote 失败: {}", e))?;
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 获取仓库 git 状态(分支、领先/落后、是否有未提交变更、最近 commit)
|
||
#[tauri::command]
|
||
pub fn git_status(local_path: String) -> Result<GitStatus, String> {
|
||
let repo = Repository::open(&local_path).map_err(|e| format!("无法打开仓库: {}", e))?;
|
||
|
||
// 当前分支名
|
||
let head = repo.head().map_err(|e| e.to_string())?;
|
||
let branch = head.shorthand().unwrap_or("HEAD detached").to_string();
|
||
|
||
// 领先/落后远端
|
||
let (ahead, behind) = calc_ahead_behind(&repo, &branch);
|
||
|
||
// 是否有未提交变更
|
||
let statuses = repo.statuses(None).map_err(|e| e.to_string())?;
|
||
let has_changes = statuses
|
||
.iter()
|
||
.any(|s| s.status() != git2::Status::CURRENT && s.status() != git2::Status::IGNORED);
|
||
|
||
// 最近一次 commit
|
||
let (last_commit_msg, last_commit_time) = match head.peel_to_commit() {
|
||
Ok(commit) => {
|
||
let msg = commit.summary().map(|s| s.to_string());
|
||
let time = {
|
||
let t = commit.time().seconds();
|
||
let dt = chrono::DateTime::from_timestamp(t, 0)
|
||
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string());
|
||
dt
|
||
};
|
||
(msg, time)
|
||
}
|
||
Err(_) => (None, None),
|
||
};
|
||
|
||
Ok(GitStatus {
|
||
branch,
|
||
ahead,
|
||
behind,
|
||
has_changes,
|
||
last_commit_msg,
|
||
last_commit_time,
|
||
})
|
||
}
|
||
|
||
/// fetch 远端最新信息(不修改本地工作区)
|
||
#[tauri::command]
|
||
pub fn git_fetch(local_path: String) -> Result<(), String> {
|
||
let repo = Repository::open(&local_path).map_err(|e| format!("无法打开仓库: {}", e))?;
|
||
|
||
let mut fetch_opts = make_fetch_options();
|
||
repo.find_remote("origin")
|
||
.map_err(|e| e.to_string())?
|
||
.fetch(&[] as &[&str], Some(&mut fetch_opts), None)
|
||
.map_err(|e| format!("fetch 失败: {}", e))?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// pull(fetch + merge fast-forward)
|
||
#[tauri::command]
|
||
pub fn git_pull(local_path: String) -> Result<String, String> {
|
||
let repo = Repository::open(&local_path).map_err(|e| format!("无法打开仓库: {}", e))?;
|
||
|
||
// fetch
|
||
let mut fetch_opts = make_fetch_options();
|
||
let mut remote = repo.find_remote("origin").map_err(|e| e.to_string())?;
|
||
remote
|
||
.fetch(&[] as &[&str], Some(&mut fetch_opts), None)
|
||
.map_err(|e| format!("fetch 失败: {}", e))?;
|
||
|
||
// 当前分支
|
||
let head = repo.head().map_err(|e| e.to_string())?;
|
||
let branch_name = head.shorthand().ok_or("无法获取分支名")?.to_string();
|
||
|
||
// 找到 FETCH_HEAD
|
||
let fetch_head = repo
|
||
.find_reference("FETCH_HEAD")
|
||
.map_err(|_| "没有可以合并的远端提交".to_string())?;
|
||
let fetch_commit = repo
|
||
.reference_to_annotated_commit(&fetch_head)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
// 分析合并情况
|
||
let (analysis, _) = repo
|
||
.merge_analysis(&[&fetch_commit])
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
if analysis.is_up_to_date() {
|
||
return Ok(format!("已是最新(分支: {})", branch_name));
|
||
}
|
||
|
||
if analysis.is_fast_forward() {
|
||
let refname = format!("refs/heads/{}", branch_name);
|
||
let mut reference = repo.find_reference(&refname).map_err(|e| e.to_string())?;
|
||
reference
|
||
.set_target(fetch_commit.id(), "fast-forward pull")
|
||
.map_err(|e| e.to_string())?;
|
||
repo.set_head(&refname).map_err(|e| e.to_string())?;
|
||
repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
|
||
.map_err(|e| e.to_string())?;
|
||
return Ok(format!("Pull 成功(fast-forward,分支: {})", branch_name));
|
||
}
|
||
|
||
Err("存在冲突,需要手动合并".to_string())
|
||
}
|
||
|
||
/// 获取本地仓库当前 HEAD 的 commit hash(用于跨空间比对)
|
||
#[tauri::command]
|
||
pub fn git_head_commit(local_path: String) -> Result<Option<String>, String> {
|
||
let repo = match Repository::open(&local_path) {
|
||
Ok(r) => r,
|
||
Err(_) => return Ok(None),
|
||
};
|
||
let result = match repo.head().and_then(|h| h.peel_to_commit()) {
|
||
Ok(commit) => Ok(Some(commit.id().to_string())),
|
||
Err(_) => Ok(None),
|
||
};
|
||
result
|
||
}
|
||
|
||
/// 检查 upstream 远端落后情况(公司仓库是否有新提交)
|
||
#[tauri::command]
|
||
pub fn git_upstream_behind(local_path: String, default_branch: String) -> Result<usize, String> {
|
||
let repo = Repository::open(&local_path).map_err(|e| format!("无法打开仓库: {}", e))?;
|
||
|
||
// 检查是否有 upstream remote
|
||
if repo.find_remote("upstream").is_err() {
|
||
return Ok(0);
|
||
}
|
||
|
||
// fetch upstream
|
||
let mut fetch_opts = make_fetch_options();
|
||
|
||
if let Ok(mut remote) = repo.find_remote("upstream") {
|
||
let _ = remote.fetch(&[&default_branch], Some(&mut fetch_opts), None);
|
||
}
|
||
|
||
// 比较本地 HEAD 与 upstream/{default_branch}
|
||
let head_oid = match repo.head().ok().and_then(|h| h.target()) {
|
||
Some(o) => o,
|
||
None => return Ok(0),
|
||
};
|
||
let upstream_ref = format!("refs/remotes/upstream/{}", default_branch);
|
||
let upstream_oid = match repo
|
||
.find_reference(&upstream_ref)
|
||
.ok()
|
||
.and_then(|r| r.target())
|
||
{
|
||
Some(o) => o,
|
||
None => return Ok(0),
|
||
};
|
||
|
||
let (_, behind) = repo
|
||
.graph_ahead_behind(head_oid, upstream_oid)
|
||
.unwrap_or((0, 0));
|
||
Ok(behind)
|
||
}
|
||
|
||
/// push 当前分支到 origin
|
||
#[tauri::command]
|
||
pub fn git_push(local_path: String) -> Result<String, String> {
|
||
let repo = Repository::open(&local_path).map_err(|e| format!("无法打开仓库: {}", e))?;
|
||
|
||
let head = repo.head().map_err(|e| e.to_string())?;
|
||
let branch = head.shorthand().ok_or("无法获取分支名")?.to_string();
|
||
|
||
let refspec = format!("refs/heads/{}:refs/heads/{}", branch, branch);
|
||
|
||
let mut push_opts = git2::PushOptions::new();
|
||
push_opts.remote_callbacks(make_callbacks());
|
||
|
||
repo.find_remote("origin")
|
||
.map_err(|e| e.to_string())?
|
||
.push(&[refspec.as_str()], Some(&mut push_opts))
|
||
.map_err(|e| format!("push 失败: {}", e))?;
|
||
|
||
Ok(format!("Push 成功(分支: {})", branch))
|
||
}
|
||
|
||
// ── 内部辅助 ────────────────────────────────────────────────
|
||
|
||
fn calc_ahead_behind(repo: &Repository, branch: &str) -> (usize, usize) {
|
||
let local_ref = match repo.find_reference(&format!("refs/heads/{}", branch)) {
|
||
Ok(r) => r,
|
||
Err(_) => return (0, 0),
|
||
};
|
||
let remote_ref = match repo.find_reference(&format!("refs/remotes/origin/{}", branch)) {
|
||
Ok(r) => r,
|
||
Err(_) => return (0, 0),
|
||
};
|
||
let local_oid = match local_ref.target() {
|
||
Some(o) => o,
|
||
None => return (0, 0),
|
||
};
|
||
let remote_oid = match remote_ref.target() {
|
||
Some(o) => o,
|
||
None => return (0, 0),
|
||
};
|
||
repo.graph_ahead_behind(local_oid, remote_oid)
|
||
.unwrap_or((0, 0))
|
||
}
|
||
|
||
/// 创建并切换到新分支(从当前 HEAD 创建)
|
||
#[tauri::command]
|
||
pub fn git_create_branch(local_path: String, branch_name: String) -> Result<(), String> {
|
||
let repo = Repository::open(&local_path).map_err(|e| format!("无法打开仓库: {}", e))?;
|
||
let head = repo.head().map_err(|e| e.to_string())?;
|
||
let commit = head.peel_to_commit().map_err(|e| e.to_string())?;
|
||
let branch = repo
|
||
.branch(&branch_name, &commit, false)
|
||
.map_err(|e| format!("创建分支失败: {}", e))?;
|
||
let refname = branch.get().name().ok_or("无效分支名")?.to_string();
|
||
repo.set_head(&refname)
|
||
.map_err(|e| format!("切换分支失败: {}", e))?;
|
||
repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
|
||
.map_err(|e| format!("checkout 失败: {}", e))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 列出本地所有分支名
|
||
#[tauri::command]
|
||
pub fn git_list_branches(local_path: String) -> Result<Vec<String>, String> {
|
||
let repo = Repository::open(&local_path).map_err(|e| format!("无法打开仓库: {}", e))?;
|
||
let branches = repo
|
||
.branches(Some(git2::BranchType::Local))
|
||
.map_err(|e| e.to_string())?;
|
||
let mut names = Vec::new();
|
||
for b in branches {
|
||
let (branch, _) = b.map_err(|e| e.to_string())?;
|
||
if let Some(name) = branch.name().map_err(|e| e.to_string())? {
|
||
names.push(name.to_string());
|
||
}
|
||
}
|
||
Ok(names)
|
||
}
|
||
|
||
/// 切换到已有的本地分支
|
||
#[tauri::command]
|
||
pub fn git_checkout_branch(local_path: String, branch_name: String) -> Result<(), String> {
|
||
let repo = Repository::open(&local_path).map_err(|e| format!("无法打开仓库: {}", e))?;
|
||
let refname = format!("refs/heads/{}", branch_name);
|
||
repo.set_head(&refname)
|
||
.map_err(|e| format!("切换分支失败: {}", e))?;
|
||
repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
|
||
.map_err(|e| format!("checkout 失败: {}", e))?;
|
||
Ok(())
|
||
}
|