feat(git): 工作区 Git 同步——侧栏同步条、远程设置、定时同步(提交→变基拉取→推送,冲突停下不覆盖)(docs/27)

This commit is contained in:
Muse
2026-11-14 17:35:41 +08:00
parent 13271f11b6
commit fadee6aacf
11 changed files with 792 additions and 2 deletions

View File

@@ -625,3 +625,216 @@ pub async fn workspace_search(
.await
.unwrap_or_default()
}
// ---------- Git 同步(工作区文件夹) ----------
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GitStatusInfo {
pub is_repo: bool,
pub branch: Option<String>,
pub remote_url: Option<String>,
pub dirty_count: u32,
pub ahead: u32,
pub behind: u32,
pub has_upstream: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GitSyncOutcome {
pub committed: bool,
pub pushed: bool,
pub pulled: bool,
pub summary: String,
}
fn run_git(dir: &Path, args: &[&str]) -> Result<String, String> {
let mut cmd = std::process::Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_TERMINAL_PROMPT", "0");
// GUI 进程调控制台程序会闪黑窗,Windows 下隐藏
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000);
}
let out = cmd
.output()
.map_err(|e| format!("无法运行 git(请确认已安装 Git):{e}"))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if !stderr.is_empty() {
return Err(stderr);
}
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
return Err(stdout);
}
Err("git 命令执行失败".to_string())
}
}
fn git_status_inner(root: &str) -> GitStatusInfo {
let empty = GitStatusInfo {
is_repo: false,
branch: None,
remote_url: None,
dirty_count: 0,
ahead: 0,
behind: 0,
has_upstream: false,
};
let dir = Path::new(root);
if run_git(dir, &["rev-parse", "--is-inside-work-tree"]).ok().as_deref() != Some("true") {
return empty;
}
// 工作区必须是仓库根:在父仓库的子目录里不接管,避免误提交别的仓库
if let Ok(top) = run_git(dir, &["rev-parse", "--show-toplevel"]) {
let norm = |p: &str| p.replace('\\', "/").trim_end_matches('/').to_lowercase();
if norm(&top) != norm(root) {
return empty;
}
} else {
return empty;
}
let branch = run_git(dir, &["branch", "--show-current"]).ok().filter(|b| !b.is_empty());
let remote_url = run_git(dir, &["remote", "get-url", "origin"]).ok().filter(|u| !u.is_empty());
let dirty_count = run_git(dir, &["status", "--porcelain"])
.map(|s| s.lines().filter(|l| !l.trim().is_empty()).count() as u32)
.unwrap_or(0);
let mut ahead = 0u32;
let mut behind = 0u32;
let has_upstream = run_git(dir, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]).is_ok();
if has_upstream {
if let Ok(counts) = run_git(dir, &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"]) {
let mut it = counts.split_whitespace();
behind = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
ahead = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
}
}
GitStatusInfo { is_repo: true, branch, remote_url, dirty_count, ahead, behind, has_upstream }
}
#[tauri::command]
pub async fn git_status(root: String) -> GitStatusInfo {
tokio::task::spawn_blocking(move || git_status_inner(&root))
.await
.unwrap_or(GitStatusInfo {
is_repo: false,
branch: None,
remote_url: None,
dirty_count: 0,
ahead: 0,
behind: 0,
has_upstream: false,
})
}
#[tauri::command]
pub async fn git_init_repo(root: String, remote_url: String, branch: String) -> Result<GitStatusInfo, String> {
tokio::task::spawn_blocking(move || {
let dir = Path::new(&root);
let b = if branch.trim().is_empty() { "main" } else { branch.trim() };
run_git(dir, &["init", "-b", b])?;
if !remote_url.trim().is_empty() {
run_git(dir, &["remote", "add", "origin", remote_url.trim()])?;
}
Ok(git_status_inner(&root))
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn git_set_remote(root: String, remote_url: String) -> Result<GitStatusInfo, String> {
tokio::task::spawn_blocking(move || {
let dir = Path::new(&root);
let url = remote_url.trim();
let has_origin = run_git(dir, &["remote"]).map(|r| r.lines().any(|l| l.trim() == "origin")).unwrap_or(false);
if url.is_empty() {
if has_origin {
run_git(dir, &["remote", "remove", "origin"])?;
}
} else if has_origin {
run_git(dir, &["remote", "set-url", "origin", url])?;
} else {
run_git(dir, &["remote", "add", "origin", url])?;
}
Ok(git_status_inner(&root))
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn git_sync_now(root: String, message: String) -> Result<GitSyncOutcome, String> {
tokio::task::spawn_blocking(move || {
let dir = Path::new(&root);
let st = git_status_inner(&root);
if !st.is_repo {
return Err("该文件夹不是 Git 仓库".to_string());
}
let has_remote = st.remote_url.is_some();
if has_remote {
run_git(dir, &["fetch", "origin"]).map_err(|e| format!("拉取远程信息失败:{e}"))?;
}
// 先提交本地更改,再变基到远程之上,最后推送——避免脏工作区挡住 pull
let mut committed = false;
if st.dirty_count > 0 {
run_git(dir, &["add", "-A"])?;
let mut args: Vec<&str> = Vec::new();
let email_missing = run_git(dir, &["config", "user.email"]).map(|v| v.is_empty()).unwrap_or(true);
if email_missing {
args.extend(["-c", "user.name=new-markdown", "-c", "user.email=new-markdown@localhost"]);
}
args.extend(["commit", "-m", message.as_str()]);
run_git(dir, &args).map_err(|e| format!("提交失败:{e}"))?;
committed = true;
}
let branch = st.branch.clone().unwrap_or_else(|| "main".to_string());
let mut pulled = false;
if has_remote {
let remote_ref = format!("origin/{branch}");
let remote_exists = run_git(dir, &["rev-parse", "--verify", &remote_ref]).is_ok();
if remote_exists {
let before = run_git(dir, &["rev-parse", "HEAD"]).unwrap_or_default();
if let Err(e) = run_git(dir, &["rebase", &remote_ref]) {
let _ = run_git(dir, &["rebase", "--abort"]);
if e.contains("CONFLICT") || e.contains("could not apply") || e.contains("Cannot rebase") {
return Err("同步冲突:本地与远程修改了相同内容,已取消本次同步,请在该文件夹手动处理冲突后重试。".to_string());
}
return Err(format!("合并远程更新失败:{e}"));
}
let after = run_git(dir, &["rev-parse", "HEAD"]).unwrap_or_default();
pulled = before != after || st.behind > 0;
}
if st.has_upstream {
run_git(dir, &["push"]).map_err(|e| format!("推送失败:{e}"))?;
} else {
run_git(dir, &["push", "-u", "origin", &branch]).map_err(|e| format!("推送失败:{e}"))?;
}
}
let mut parts: Vec<&str> = Vec::new();
if pulled {
parts.push("已拉取远程更新");
}
if committed {
parts.push("已提交本地更改");
}
if has_remote {
parts.push("已推送到远程");
}
let summary = if parts.is_empty() {
"已是最新,没有需要同步的更改".to_string()
} else {
parts.join(",")
};
Ok(GitSyncOutcome { committed, pushed: has_remote, pulled, summary })
})
.await
.map_err(|e| e.to_string())?
}

View File

@@ -32,6 +32,10 @@ pub fn run() {
commands::create_dir,
commands::rename_path,
commands::reveal_in_explorer,
commands::git_status,
commands::git_init_repo,
commands::git_set_remote,
commands::git_sync_now,
commands::delete_to_trash,
commands::db_recent_list,
commands::db_recent_add,