dev-manager-tauri/src-tauri/src/commands/git_ops.rs
lanrtop 3a560f149e feat(resource/broker): 实现受保护凭证代办与项目 readiness
Gitea、webhook 与 API Key 以受保护引用替代常态明文输出;旧值先备份并校验后可恢复迁移,MCP 仅提供项目只读状态。

Rules-Applied: R01,R05,R06,R07,R10

Feature-Confirmed: true
2026-09-23 18:45:30 +09:00

384 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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