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, pub last_commit_time: Option, } /// 按 URL 前缀匹配已注册的 Gitea 实例,返回其 access_token fn gitea_token_for_url(url: &str) -> Option { 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, ) -> 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 { 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 { 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, 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 { 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 { 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, 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(()) }