D1 免登录改造:App 启动直接进主界面,删 LoginPage/github.rs/OAuth 全套命令、
Sidebar 账户区与 GitHub OAuth 设置弹窗、健康检查 GitHub Token 项
D2 移除发布/导入/PR/CI 入口:删 PublishModal/ImportRepoModal、ProjectCard 的
PR compare 链接与 Actions 状态灯、RepoRegistryModal 同步 tab、CicdModal runs tab、
publish.rs 的 github_create_repo/git_push_to_github
D3 spacesSync 单机化:删 spacesSync/useSpacesSync/SpacesPage/DiscoveryPanel,
拆除 App→Dashboard→ProjectCard 的 spacesJson 链
D4 git_ops 凭据链重写:ssh agent → git credential helper → gitea_instances token
前缀匹配 → default,不再依赖 github_token
保留:server_software GitHub 镜像(Releases 下载加速)、gitea_migrate 迁移入口、
cicd.rs(待 Gitea Actions 改造卡);DB 表不删(R05 migration 兼容)
顺带:修复 servers.rs 两个存量测试的 schema 漂移(测试建表缺 deploy_type 列);
包含会话前未提交的 agent-infra F3(get_agent_health 健康诊断,与 lib.rs/
commands.ts 物理耦合无法拆分提交)
验证:cargo test 53 passed / typecheck 全绿 / vitest passWithNoTests
576 lines
19 KiB
Rust
576 lines
19 KiB
Rust
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, String> {
|
||
Repository::open(path).map_err(|e| format!("无法打开仓库: {e}"))
|
||
}
|
||
|
||
/// 列出当前系统已安装的 WSL 发行版(wsl -l -q)
|
||
#[tauri::command]
|
||
pub fn get_wsl_distros() -> Result<Vec<String>, 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<u16> = 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<u16> = 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<String> = 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<ChangedFile>,
|
||
pub remotes: Vec<GitRemote>,
|
||
pub has_gitignore: bool,
|
||
pub suspicious_files: Vec<String>,
|
||
pub suggested_templates: Vec<String>, // 推荐 .gitignore 模板
|
||
}
|
||
|
||
// ── 内部工具 ─────────────────────────────────────────────────────────────────
|
||
|
||
/// 检测疑似敏感文件(只扫一层 + 常见子目录,避免遍历 node_modules)
|
||
fn scan_suspicious(root: &Path) -> Vec<String> {
|
||
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<String>| {
|
||
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<String> {
|
||
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<String> {
|
||
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<String, String> {
|
||
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<GitScanResult, String> {
|
||
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<ChangedFile> = 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<GitRemote> = 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<GitScanResult, String> {
|
||
// 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<ChangedFile> = 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<GitRemote> = 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<git2::Commit> = 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<String>,
|
||
default_branch: Option<String>,
|
||
) -> 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(())
|
||
}
|