dev-manager-tauri/src-tauri/src/commands/publish.rs
lanrtop 289b558278 refactor(github): GitHub 全面退役——免登录 + 发布/导入/同步链路移除 + 凭据去 GitHub 化
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
2026-07-02 13:15:56 +09:00

576 lines
19 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::{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 使用相同的 gitgitignore 规则完全一致)
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!(
"路径不存在: {}\nWSL 路径已转换为: {}\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(())
}