use crate::db; use git2::{IndexAddOption, Repository, Signature}; use rusqlite::params; use serde::Serialize; use std::path::Path; // ── WSL 路径转换 ────────────────────────────────────────────────────────────── /// 从 settings 表读取 wsl_distro(默认 Ubuntu) fn get_wsl_distro() -> String { let conn = match db::pool().get() { Ok(c) => c, Err(_) => return "Ubuntu".to_string(), }; conn.query_row( "SELECT value FROM settings WHERE key = 'wsl_distro'", [], |row| row.get::<_, String>(0), ) .unwrap_or_else(|_| "Ubuntu".to_string()) } /// 将 Linux 路径(/home/...)转换为 Windows UNC 路径(\\wsl.localhost\Ubuntu\home\...) /// 非 Linux 路径原样返回 pub fn wsl_to_win(linux_path: &str) -> String { if linux_path.starts_with('/') { let distro = get_wsl_distro(); let inner = linux_path.trim_start_matches('/').replace('/', "\\"); format!("\\\\wsl.localhost\\{}\\{}", distro, inner) } else { linux_path.to_string() } } /// 暴露给前端:将 Linux 路径转换为 Windows 可访问的 UNC 路径 #[tauri::command] pub fn resolve_wsl_path(path: String) -> String { wsl_to_win(&path) } fn open_repo(path: &str) -> Result { Repository::open(path).map_err(|e| format!("无法打开仓库: {e}")) } /// 列出当前系统已安装的 WSL 发行版(wsl -l -q) #[tauri::command] pub fn get_wsl_distros() -> Result, String> { let output = crate::silent_cmd("wsl") .args(["-l", "-q"]) .output() .map_err(|_| "未检测到 WSL,请确认已安装 WSL 2".to_string())?; if !output.status.success() && output.stdout.is_empty() { return Err("WSL 未安装或未启用".to_string()); } // wsl -l -q 在 Windows 上输出 UTF-16 LE(带或不带 BOM) let bytes = &output.stdout; let text = if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE { // UTF-16 LE with BOM let words: Vec = bytes[2..] .chunks(2) .filter(|c| c.len() == 2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .collect(); String::from_utf16_lossy(&words).to_string() } else if bytes.windows(2).any(|w| w[1] == 0) { // UTF-16 LE without BOM(每个字符后跟 0x00) let words: Vec = bytes .chunks(2) .filter(|c| c.len() == 2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .collect(); String::from_utf16_lossy(&words).to_string() } else { String::from_utf8_lossy(bytes).to_string() }; let distros: Vec = text .lines() .map(|l| l.trim_matches(|c: char| c.is_whitespace() || c == '\0').to_string()) .filter(|l| !l.is_empty()) .collect(); if distros.is_empty() { return Err("未找到已安装的 WSL 发行版".to_string()); } Ok(distros) } // ── 数据结构 ───────────────────────────────────────────────────────────────── #[derive(Serialize, Clone)] pub struct ChangedFile { pub status: String, // "M" 修改 / "A" 新增 / "D" 删除 / "?" 未跟踪 pub path: String, } #[derive(Serialize, Clone)] pub struct GitRemote { pub name: String, pub url: String, } #[derive(Serialize)] pub struct GitScanResult { pub has_git: bool, pub has_commits: bool, pub branch: String, pub has_changes: bool, pub changed_files: Vec, pub remotes: Vec, pub has_gitignore: bool, pub suspicious_files: Vec, pub suggested_templates: Vec, // 推荐 .gitignore 模板 } // ── 内部工具 ───────────────────────────────────────────────────────────────── /// 检测疑似敏感文件(只扫一层 + 常见子目录,避免遍历 node_modules) fn scan_suspicious(root: &Path) -> Vec { let patterns = [ ".env", ".env.local", ".env.production", ".env.development", "secrets.json", "credentials.json", "credentials.yml", "config/database.yml", "config/secrets.yml", ]; let ext_patterns = ["key", "pem", "p12", "pfx", "cer", "crt"]; let mut found = Vec::new(); let check_file = |rel: &str, found: &mut Vec| { let lower = rel.to_lowercase(); // 固定名称 for p in &patterns { if lower == *p || lower.ends_with(&format!("/{}", p)) { found.push(rel.to_string()); return; } } // 扩展名 if let Some(ext) = rel.rsplit('.').next() { if ext_patterns.contains(&ext.to_lowercase().as_str()) { found.push(rel.to_string()); } } // .env.* 通配 let filename = rel.rsplit('/').next().unwrap_or(rel); if filename.starts_with(".env.") || filename.starts_with("*.env") { found.push(rel.to_string()); } }; // 只扫根目录一层 if let Ok(entries) = std::fs::read_dir(root) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); // 跳过 .git / node_modules / target 等大型目录 if matches!(name.as_str(), ".git" | "node_modules" | "target" | "dist" | ".next") { continue; } if entry.path().is_file() { check_file(&name, &mut found); } } } found } /// 根据项目文件推断 .gitignore 模板 fn detect_templates(root: &Path) -> Vec { let mut templates = Vec::new(); let markers: &[(&str, &str)] = &[ ("package.json", "Node"), ("Cargo.toml", "Rust"), ("requirements.txt", "Python"), ("pyproject.toml", "Python"), ("go.mod", "Go"), ("pom.xml", "Java"), ("build.gradle", "Java"), ("*.csproj", "VisualStudio"), ("*.sln", "VisualStudio"), ]; for (file, template) in markers { let path = root.join(file); if path.exists() && !templates.contains(&template.to_string()) { templates.push(template.to_string()); } } // 通配符检查 *.csproj / *.sln if !templates.contains(&"VisualStudio".to_string()) { if let Ok(entries) = std::fs::read_dir(root) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); if name.ends_with(".csproj") || name.ends_with(".sln") { templates.push("VisualStudio".to_string()); break; } } } } templates } // ── WSL git 辅助 ────────────────────────────────────────────────────────────── /// 从原始路径(可能是 Linux 路径或 UNC 路径)提取 Linux 绝对路径 /// - 原始路径以 `/` 开头 → 直接返回 /// - win_path 是 `\\wsl.localhost\Distro\...` → 提取 `/...` 部分 pub fn extract_linux_path(original: &str, win_path: &str) -> Option { if original.starts_with('/') { return Some(original.to_string()); } // \\wsl.localhost\Distro\home\user\... → /home/user/... if win_path.starts_with("\\\\wsl.localhost\\") { let rest = &win_path["\\\\wsl.localhost\\".len()..]; if let Some(slash_idx) = rest.find('\\') { let linux = "/".to_string() + &rest[slash_idx + 1..].replace('\\', "/"); return Some(linux); } } None } /// 在 WSL 中执行 git 命令,返回 stdout 字符串 fn run_wsl_git(linux_path: &str, git_args: &[&str]) -> Result { let mut args: Vec<&str> = vec!["git", "-C", linux_path]; args.extend_from_slice(git_args); let output = crate::silent_cmd("wsl") .args(&args) .output() .map_err(|e| format!("wsl 命令失败: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).to_string(); return Err(if stderr.is_empty() { format!("git 命令退出码: {}", output.status) } else { stderr }); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } /// WSL 项目专用扫描:通过 `wsl git` 获取准确状态 /// 避免 libgit2 在 Windows UNC 路径下 .gitignore 规则失效(路径分隔符不匹配) fn scan_local_git_via_wsl(linux_path: &str, win_path: &str) -> Result { let root = Path::new(win_path); let has_gitignore = root.join(".gitignore").exists(); let suspicious_files = scan_suspicious(root); let suggested_templates = detect_templates(root); if !root.join(".git").exists() { return Ok(GitScanResult { has_git: false, has_commits: false, branch: String::new(), has_changes: false, changed_files: vec![], remotes: vec![], has_gitignore, suspicious_files, suggested_templates, }); } // 分支名 let branch = run_wsl_git(linux_path, &["branch", "--show-current"]) .unwrap_or_default() .trim() .to_string(); let branch = if branch.is_empty() { // 可能是 detached HEAD,尝试 rev-parse --abbrev-ref run_wsl_git(linux_path, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_default() .trim() .to_string() } else { branch }; let branch = if branch.is_empty() || branch == "HEAD" { "main".to_string() } else { branch }; // 是否有提交 let has_commits = run_wsl_git(linux_path, &["rev-parse", "HEAD"]).is_ok(); // git status --porcelain(与 VSCode 使用相同的 git,gitignore 规则完全一致) let status_out = run_wsl_git(linux_path, &["status", "--porcelain"]).unwrap_or_default(); let changed_files: Vec = status_out .lines() .filter(|l| l.len() >= 3) .map(|line| { let xy = &line[0..2]; // 去掉 git 对含空格路径加的引号 let path = line[3..].trim().trim_matches('"').to_string(); let status = if xy.contains('?') { "?" } else if xy.contains('D') { "D" } else if xy.contains('R') { "R" } else { "M" }; ChangedFile { status: status.to_string(), path } }) .collect(); let has_changes = !changed_files.is_empty(); // git remote -v let remote_out = run_wsl_git(linux_path, &["remote", "-v"]).unwrap_or_default(); let mut remotes: Vec = Vec::new(); for line in remote_out.lines() { if !line.ends_with("(fetch)") { continue; } let mut parts = line.splitn(2, '\t'); let name = parts.next().unwrap_or("").to_string(); let url = parts .next() .and_then(|s| s.split_whitespace().next()) .unwrap_or("") .to_string(); if !name.is_empty() && !url.is_empty() { remotes.push(GitRemote { name, url }); } } Ok(GitScanResult { has_git: true, has_commits, branch, has_changes, changed_files, remotes, has_gitignore, suspicious_files, suggested_templates, }) } // ── Tauri 命令 ──────────────────────────────────────────────────────────────── /// 扫描本地目录的 git 状态、敏感文件、技术栈 #[tauri::command] pub fn scan_local_git(path: String) -> Result { // Linux 路径自动转换为 Windows UNC 路径 let win_path = wsl_to_win(&path); let root = Path::new(&win_path); if !root.exists() { return Err(format!( "路径不存在: {}\n(WSL 路径已转换为: {})\n请确认设置中的 WSL 发行版名称是否正确", path, win_path )); } // WSL 项目:通过 wsl git 命令扫描,保证 .gitignore 规则与 VSCode 完全一致 if let Some(linux_path) = extract_linux_path(&path, &win_path) { return scan_local_git_via_wsl(&linux_path, &win_path); } // Windows 项目:使用 libgit2 let has_gitignore = root.join(".gitignore").exists(); let suspicious_files = scan_suspicious(root); let suggested_templates = detect_templates(root); let git_dir = root.join(".git"); if !git_dir.exists() { return Ok(GitScanResult { has_git: false, has_commits: false, branch: String::new(), has_changes: false, changed_files: vec![], remotes: vec![], has_gitignore, suspicious_files, suggested_templates, }); } let repo = open_repo(&root.to_string_lossy())?; // 分支名 let branch = repo .head() .ok() .and_then(|h| h.shorthand().map(|s| s.to_string())) .unwrap_or_else(|| "main".to_string()); // 是否有提交 let has_commits = repo.head().is_ok(); // 改动文件 let mut changed_files: Vec = Vec::new(); let statuses = repo .statuses(Some( git2::StatusOptions::new() .include_untracked(true) .recurse_untracked_dirs(false), )) .map_err(|e| e.to_string())?; for entry in statuses.iter() { let s = entry.status(); let status_str = if s.contains(git2::Status::INDEX_NEW) || s.contains(git2::Status::WT_NEW) { "?" } else if s.contains(git2::Status::INDEX_MODIFIED) || s.contains(git2::Status::WT_MODIFIED) { "M" } else if s.contains(git2::Status::INDEX_DELETED) || s.contains(git2::Status::WT_DELETED) { "D" } else if s.contains(git2::Status::INDEX_RENAMED) || s.contains(git2::Status::WT_RENAMED) { "R" } else { "M" }; if let Some(path_str) = entry.path() { changed_files.push(ChangedFile { status: status_str.to_string(), path: path_str.to_string(), }); } } let has_changes = !changed_files.is_empty(); // Remote 列表 let remotes: Vec = repo .remotes() .map(|names| { names .iter() .flatten() .filter_map(|name| { repo.find_remote(name).ok().map(|r| GitRemote { name: name.to_string(), url: r.url().unwrap_or("").to_string(), }) }) .collect() }) .unwrap_or_default(); Ok(GitScanResult { has_git: true, has_commits, branch, has_changes, changed_files, remotes, has_gitignore, suspicious_files, suggested_templates, }) } /// git init(若还没有 .git)并提交所有改动 #[tauri::command] pub fn git_init_and_commit(path: String, message: String) -> Result<(), String> { let win_path = wsl_to_win(&path); let root = Path::new(&win_path); let repo = if root.join(".git").exists() { open_repo(&root.to_string_lossy())? } else { Repository::init(root).map_err(|e| format!("git init 失败: {e}"))? }; let mut index = repo.index().map_err(|e| e.to_string())?; index .add_all(["."].iter(), IndexAddOption::DEFAULT, None) .map_err(|e| format!("git add 失败: {e}"))?; index.write().map_err(|e| e.to_string())?; let tree_oid = index.write_tree().map_err(|e| e.to_string())?; let tree = repo.find_tree(tree_oid).map_err(|e| e.to_string())?; let sig = Signature::now("dev-manager", "dev@local") .map_err(|e| e.to_string())?; let parents: Vec = if let Ok(head) = repo.head() { if let Ok(commit) = head.peel_to_commit() { vec![commit] } else { vec![] } } else { vec![] }; let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parent_refs) .map_err(|e| format!("commit 失败: {e}"))?; Ok(()) } /// 添加或更新 origin remote #[tauri::command] pub fn git_remote_set(path: String, url: String) -> Result<(), String> { let win_path = wsl_to_win(&path); let repo = open_repo(&win_path)?; let has_origin = repo.find_remote("origin").is_ok(); if has_origin { repo.remote_set_url("origin", &url) .map_err(|e| format!("set-url 失败: {e}")) } else { repo.remote("origin", &url) .map(|_| ()) .map_err(|e| format!("remote add 失败: {e}")) } } /// 写入 .gitignore 文件到指定目录 #[tauri::command] pub fn write_gitignore(dir: String, content: String) -> Result<(), String> { let win_dir = wsl_to_win(&dir); let path = Path::new(&win_dir).join(".gitignore"); std::fs::write(&path, content).map_err(|e| format!("写入 .gitignore 失败: {e}")) } /// 注册到 git_repos 并更新 project_workspaces.repo_id #[tauri::command] pub fn register_and_link_repo( project_id: String, repo_url: String, name: String, description: Option, default_branch: Option, ) -> Result<(), String> { let conn = db::pool().get().map_err(|e| e.to_string())?; let repo_id = uuid::Uuid::new_v4().to_string(); let branch = default_branch.as_deref().unwrap_or("main"); // 插入 git_repos(同 URL 已存在则忽略) conn.execute( "INSERT OR IGNORE INTO git_repos (id, repo_url, name, description, default_branch) VALUES (?1, ?2, ?3, ?4, ?5)", params![repo_id, repo_url, name, description, branch], ) .map_err(|e| e.to_string())?; // 取实际 id(可能已存在) let actual_id: String = conn .query_row( "SELECT id FROM git_repos WHERE repo_url = ?1", params![repo_url], |r| r.get(0), ) .map_err(|e| format!("查询 git_repos 失败: {e}"))?; // 更新 project_workspaces.repo_id 及 profile 的 repo_url conn.execute( "UPDATE project_workspaces SET repo_id = ?1 WHERE id = ?2", params![actual_id, project_id], ) .map_err(|e| e.to_string())?; conn.execute( "UPDATE project_profiles SET repo_url = ?1 WHERE id = ( SELECT profile_id FROM project_workspaces WHERE id = ?2 )", params![repo_url, project_id], ) .map_err(|e| e.to_string())?; Ok(()) }