feat: add WSL git support and improve commit message handling in PublishModal

This commit is contained in:
lanrtop 2026-04-01 02:20:14 +09:00
parent 0baa304fbe
commit b067c1f348
2 changed files with 191 additions and 42 deletions

View File

@ -213,6 +213,147 @@ fn detect_templates(root: &Path) -> Vec<String> {
templates templates
} }
// ── WSL git 辅助 ──────────────────────────────────────────────────────────────
/// 从原始路径(可能是 Linux 路径或 UNC 路径)提取 Linux 绝对路径
/// - 原始路径以 `/` 开头 → 直接返回
/// - win_path 是 `\\wsl.localhost\Distro\...` → 提取 `/...` 部分
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 = std::process::Command::new("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 命令 ──────────────────────────────────────────────────────────────── // ── Tauri 命令 ────────────────────────────────────────────────────────────────
/// 扫描本地目录的 git 状态、敏感文件、技术栈 /// 扫描本地目录的 git 状态、敏感文件、技术栈
@ -229,6 +370,12 @@ pub fn scan_local_git(path: String) -> Result<GitScanResult, String> {
)); ));
} }
// 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 has_gitignore = root.join(".gitignore").exists();
let suspicious_files = scan_suspicious(root); let suspicious_files = scan_suspicious(root);
let suggested_templates = detect_templates(root); let suggested_templates = detect_templates(root);
@ -266,27 +413,19 @@ pub fn scan_local_git(path: String) -> Result<GitScanResult, String> {
.statuses(Some( .statuses(Some(
git2::StatusOptions::new() git2::StatusOptions::new()
.include_untracked(true) .include_untracked(true)
.recurse_untracked_dirs(true), .recurse_untracked_dirs(false),
)) ))
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
for entry in statuses.iter() { for entry in statuses.iter() {
let s = entry.status(); let s = entry.status();
let status_str = if s.contains(git2::Status::INDEX_NEW) let status_str = if s.contains(git2::Status::INDEX_NEW) || s.contains(git2::Status::WT_NEW) {
|| s.contains(git2::Status::WT_NEW)
{
"?" "?"
} else if s.contains(git2::Status::INDEX_MODIFIED) } else if s.contains(git2::Status::INDEX_MODIFIED) || s.contains(git2::Status::WT_MODIFIED) {
|| s.contains(git2::Status::WT_MODIFIED)
{
"M" "M"
} else if s.contains(git2::Status::INDEX_DELETED) } else if s.contains(git2::Status::INDEX_DELETED) || s.contains(git2::Status::WT_DELETED) {
|| s.contains(git2::Status::WT_DELETED)
{
"D" "D"
} else if s.contains(git2::Status::INDEX_RENAMED) } else if s.contains(git2::Status::INDEX_RENAMED) || s.contains(git2::Status::WT_RENAMED) {
|| s.contains(git2::Status::WT_RENAMED)
{
"R" "R"
} else { } else {
"M" "M"

View File

@ -96,9 +96,7 @@ export function PublishModal({ project, onClose }: Props) {
if (needInit || scan.has_changes) { if (needInit || scan.has_changes) {
steps.push({ label: `提交本地代码(${commitMsg}`, status: "pending" }); steps.push({ label: `提交本地代码(${commitMsg}`, status: "pending" });
} }
if (!hasRemoteConflict || scan.remotes.length === 0) { steps.push({ label: "配置 origin remote", status: "pending" });
steps.push({ label: "配置 origin remote", status: "pending" });
}
steps.push({ label: `推送到 GitHub (${branch})`, status: "pending" }); steps.push({ label: `推送到 GitHub (${branch})`, status: "pending" });
steps.push({ label: "注册到本地仓库", status: "pending" }); steps.push({ label: "注册到本地仓库", status: "pending" });
return steps; return steps;
@ -307,33 +305,45 @@ export function PublishModal({ project, onClose }: Props) {
)} )}
{/* 改动文件预览 */} {/* 改动文件预览 */}
{scan.has_changes && ( {(() => {
<div className="border border-gray-200 rounded-lg px-4 py-3 text-xs space-y-2"> const needCommit = !scan.has_git || !scan.has_commits || scan.has_changes;
<p className="font-semibold text-gray-600">{scan.changed_files.length} </p> if (!needCommit) return null;
<div className="max-h-28 overflow-y-auto space-y-0.5"> return (
{scan.changed_files.slice(0, 50).map((f, i) => ( <div className="border border-gray-200 rounded-lg px-4 py-3 text-xs space-y-2">
<div key={i} className="flex items-center gap-2 font-mono text-gray-500"> {scan.has_changes ? (
<span className={`w-4 font-bold ${ <>
f.status === "?" ? "text-green-500" : <p className="font-semibold text-gray-600">{scan.changed_files.length} </p>
f.status === "D" ? "text-red-500" : "text-yellow-500" <div className="max-h-28 overflow-y-auto space-y-0.5">
}`}>{f.status}</span> {scan.changed_files.slice(0, 50).map((f, i) => (
<span className="truncate">{f.path}</span> <div key={i} className="flex items-center gap-2 font-mono text-gray-500">
</div> <span className={`w-4 font-bold ${
))} f.status === "?" ? "text-green-500" :
{scan.changed_files.length > 50 && ( f.status === "D" ? "text-red-500" : "text-yellow-500"
<p className="text-gray-400"> {scan.changed_files.length - 50} </p> }`}>{f.status}</span>
<span className="truncate">{f.path}</span>
</div>
))}
{scan.changed_files.length > 50 && (
<p className="text-gray-400"> {scan.changed_files.length - 50} </p>
)}
</div>
</>
) : (
<p className="font-semibold text-gray-600">
{!scan.has_git ? "全新项目,将执行 git init 并提交所有文件" : "无历史提交,将提交当前所有文件"}
</p>
)} )}
<div>
<label className="block text-gray-500 mb-1">Commit </label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-blue-200"
value={commitMsg}
onChange={(e) => setCommitMsg(e.target.value)}
/>
</div>
</div> </div>
<div> );
<label className="block text-gray-500 mb-1">Commit </label> })()}
<input
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-blue-200"
value={commitMsg}
onChange={(e) => setCommitMsg(e.target.value)}
/>
</div>
</div>
)}
{/* GitHub 仓库配置 */} {/* GitHub 仓库配置 */}
<div className="border border-gray-200 rounded-lg px-4 py-3 space-y-3"> <div className="border border-gray-200 rounded-lg px-4 py-3 space-y-3">
@ -420,7 +430,7 @@ export function PublishModal({ project, onClose }: Props) {
onClick={handleExecute} onClick={handleExecute}
disabled={ disabled={
!repoName.trim() || !repoName.trim() ||
(scan?.suspicious_files.length! > 0 && !confirmedSensitive) (scan.suspicious_files.length > 0 && !confirmedSensitive)
} }
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50" className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
> >