use std::path::Path;
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::vault::VaultError;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitStatus {
pub is_repo: bool,
pub branch: Option<String>,
pub upstream: Option<String>,
pub remote: Option<String>,
pub ahead: u32,
pub behind: u32,
pub changes: u32,
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitOutcome {
pub ok: bool,
pub action: String,
pub summary: String,
pub detail: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitSyncResult {
pub ok: bool,
pub steps: Vec<GitOutcome>,
pub status: GitStatus,
}
fn git(root: &Path, args: &[&str]) -> Result<std::process::Output, VaultError> {
let mut command = Command::new("git");
command.arg("-C").arg(root).args(args);
command.env("GIT_TERMINAL_PROMPT", "0");
command.env("GCM_INTERACTIVE", "never");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x0800_0000);
}
command.output().map_err(|problem| {
VaultError(format!("无法执行 git(请确认已安装并加入 PATH): {problem}"))
})
}
fn run(root: &Path, args: &[&str]) -> Result<(bool, String), VaultError> {
let output = git(root, args)?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let text = if stdout.is_empty() { stderr } else { stdout };
Ok((output.status.success(), text))
}
fn first_line(text: &str) -> String {
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("")
.to_string()
}
fn outcome(action: &str, ok: bool, fallback: &str, text: String) -> GitOutcome {
let summary = first_line(&text);
GitOutcome {
ok,
action: action.to_string(),
summary: if summary.is_empty() {
fallback.to_string()
} else {
summary
},
detail: text,
}
}
fn parse_counts(text: &str) -> (u32, u32) {
let mut parts = text.split_whitespace();
let behind = parts.next().and_then(|value| value.parse().ok()).unwrap_or(0);
let ahead = parts.next().and_then(|value| value.parse().ok()).unwrap_or(0);
(ahead, behind)
}
pub fn status(root: &Path) -> GitStatus {
let inside = match run(root, &["rev-parse", "--is-inside-work-tree"]) {
Ok((true, text)) => text == "true",
_ => false,
};
if !inside {
return GitStatus {
detail: Some("该知识库不是 Git 仓库".to_string()),
..GitStatus::default()
};
}
let branch = run(root, &["symbolic-ref", "--short", "HEAD"])
.ok()
.filter(|(ok, _)| *ok)
.map(|(_, text)| text)
.or_else(|| {
run(root, &["rev-parse", "--abbrev-ref", "HEAD"])
.ok()
.filter(|(ok, _)| *ok)
.map(|(_, text)| text)
})
.filter(|name| !name.is_empty() && name != "HEAD");
let changes = run(root, &["status", "--porcelain"])
.ok()
.map(|(_, text)| text.lines().filter(|line| !line.trim().is_empty()).count() as u32)
.unwrap_or(0);
let upstream = run(
root,
&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
)
.ok()
.filter(|(ok, _)| *ok)
.map(|(_, text)| text)
.filter(|name| !name.is_empty());
let (ahead, behind) = match &upstream {
Some(_) => run(
root,
&["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
)
.ok()
.filter(|(ok, _)| *ok)
.map(|(_, text)| parse_counts(&text))
.unwrap_or((0, 0)),
None => (0, 0),
};
let remote = run(root, &["remote", "get-url", "origin"])
.ok()
.filter(|(ok, _)| *ok)
.map(|(_, text)| text)
.filter(|url| !url.is_empty());
GitStatus {
is_repo: true,
branch,
upstream,
remote,
ahead,
behind,
changes,
detail: None,
}
}
pub fn init(root: &Path) -> Result<GitOutcome, VaultError> {
let (ok, text) = run(root, &["init"])?;
Ok(outcome("init", ok, "已初始化仓库", text))
}
pub fn validate_remote(url: &str) -> Result<String, VaultError> {
let trimmed = url.trim();
let allowed = ["https://", "http://", "ssh://", "git@", "file://"];
if trimmed.starts_with('-') || !allowed.iter().any(|prefix| trimmed.starts_with(prefix)) {
return Err(VaultError(
"远程地址需以 https:// / http:// / ssh:// / git@ / file:// 开头".to_string(),
));
}
Ok(trimmed.to_string())
}
pub fn set_remote(root: &Path, url: &str) -> Result<GitOutcome, VaultError> {
let url = validate_remote(url)?;
let has_origin = run(root, &["remote", "get-url", "origin"])
.map(|(ok, _)| ok)
.unwrap_or(false);
let args: Vec<&str> = if has_origin {
vec!["remote", "set-url", "origin", &url]
} else {
vec!["remote", "add", "origin", &url]
};
let (ok, text) = run(root, &args)?;
Ok(outcome("remote", ok, "已设置远程地址", text))
}
fn ensure_index_ignored(root: &Path) {
const ENTRY: &str = ".tasklens/";
let Ok((true, exclude_path)) = run(root, &["rev-parse", "--git-path", "info/exclude"]) else {
return;
};
let reported = Path::new(exclude_path.trim());
let exclude = if reported.is_absolute() {
reported.to_path_buf()
} else {
root.join(reported)
};
let Ok(existing) = std::fs::read_to_string(&exclude) else {
return;
};
if existing.lines().any(|line| line.trim() == ENTRY) {
return;
}
let mut content = existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str("\n# TaskLens 索引库(派生数据,可删除重建)\n.tasklens/\n");
let _ = std::fs::write(exclude, content);
}
pub fn commit(root: &Path, message: &str) -> Result<GitOutcome, VaultError> {
let message = message.trim();
if message.is_empty() {
return Err(VaultError("提交信息不能为空".to_string()));
}
ensure_index_ignored(root);
let (ok, text) = run(root, &["status", "--porcelain"])?;
if !ok {
return Ok(outcome("commit", false, "无法读取仓库状态", text));
}
if text.lines().all(|line| line.trim().is_empty()) {
return Ok(GitOutcome {
ok: true,
action: "commit".to_string(),
summary: "没有需要提交的改动".to_string(),
detail: String::new(),
});
}
let (ok, text) = run(root, &["add", "-A"])?;
if !ok {
return Ok(outcome("commit", false, "暂存改动失败", text));
}
let (ok, text) = run(root, &["commit", "-m", message])?;
Ok(outcome(
"commit",
ok,
if ok { "已提交" } else { "提交失败" },
text,
))
}
pub fn fetch(root: &Path) -> Result<GitOutcome, VaultError> {
if status(root).remote.is_none() {
return Ok(GitOutcome {
ok: false,
action: "fetch".to_string(),
summary: "尚未配置远程仓库".to_string(),
detail: String::new(),
});
}
let (ok, text) = run(root, &["fetch", "--prune"])?;
Ok(outcome(
"fetch",
ok,
if ok { "已获取远端状态" } else { "获取远端状态失败" },
text,
))
}
pub fn pull(root: &Path) -> Result<GitOutcome, VaultError> {
if status(root).upstream.is_none() {
return Ok(GitOutcome {
ok: false,
action: "pull".to_string(),
summary: "尚未设置上游分支:先推送一次即可建立".to_string(),
detail: String::new(),
});
}
let (ok, text) = run(root, &["pull", "--ff-only"])?;
Ok(outcome(
"pull",
ok,
if ok {
"已是最新"
} else {
"无法快进合并:本地与远端已分叉,请手动处理"
},
text,
))
}
pub fn push(root: &Path) -> Result<GitOutcome, VaultError> {
let current = status(root);
if current.upstream.is_none() {
let Some(branch) = current.branch.as_deref() else {
return Ok(outcome("push", false, "当前不在分支上,无法推送", String::new()));
};
let (ok, text) = run(root, &["push", "-u", "origin", branch])?;
return Ok(outcome(
"push",
ok,
if ok { "已推送并设置上游" } else { "推送失败" },
text,
));
}
let (ok, text) = run(root, &["push"])?;
Ok(outcome(
"push",
ok,
if ok { "已推送" } else { "推送失败" },
text,
))
}
pub fn sync(root: &Path, message: &str) -> Result<GitSyncResult, VaultError> {
let mut steps = Vec::new();
let commit_outcome = commit(root, message)?;
let commit_ok = commit_outcome.ok;
steps.push(commit_outcome);
if !commit_ok {
return Ok(GitSyncResult {
ok: false,
steps,
status: status(root),
});
}
if status(root).upstream.is_some() {
let pull_outcome = pull(root)?;
let pull_ok = pull_outcome.ok;
steps.push(pull_outcome);
if !pull_ok {
return Ok(GitSyncResult {
ok: false,
steps,
status: status(root),
});
}
}
let push_outcome = push(root)?;
let ok = push_outcome.ok;
steps.push(push_outcome);
Ok(GitSyncResult {
ok,
steps,
status: status(root),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
fn temp_dir() -> PathBuf {
tempfile::tempdir().expect("temp dir").keep()
}
fn configure(root: &Path) {
for (key, value) in [("user.email", "tasklens@example.com"), ("user.name", "TaskLens")] {
let _ = Command::new("git")
.arg("-C")
.arg(root)
.args(["config", key, value])
.output();
}
}
fn file_url(path: &Path) -> String {
format!("file:///{}", path.to_string_lossy().replace('\\', "/"))
}
#[test]
fn reports_a_directory_that_is_not_a_repository() {
let root = temp_dir();
let current = status(&root);
assert!(!current.is_repo);
assert_eq!(current.changes, 0);
assert!(current.detail.is_some());
}
#[test]
fn commits_and_reports_remaining_changes() {
let root = temp_dir();
git(&root, &["init"]).expect("git init");
configure(&root);
fs::write(root.join("note.md"), "# 一\n").expect("write");
let current = status(&root);
assert!(current.is_repo);
assert_eq!(current.changes, 1);
assert!(current.branch.is_some());
let committed = commit(&root, "初始提交").expect("commit");
assert!(committed.ok, "{committed:?}");
assert_eq!(status(&root).changes, 0);
let again = commit(&root, "初始提交").expect("commit");
assert!(again.ok);
assert_eq!(again.summary, "没有需要提交的改动");
}
#[test]
fn rejects_unsafe_remote_urls() {
assert!(validate_remote("https://example.com/vault.git").is_ok());
assert!(validate_remote("file:///tmp/bare.git").is_ok());
assert!(validate_remote("--upload-pack=touch evil").is_err());
assert!(validate_remote("C:/somewhere").is_err());
assert!(validate_remote("").is_err());
}
#[test]
fn keeps_the_index_database_out_of_commits() {
let root = temp_dir();
git(&root, &["init"]).expect("git init");
configure(&root);
std::fs::create_dir_all(root.join(".tasklens")).expect("mkdir");
std::fs::write(root.join(".tasklens/tasklens.db"), "binary").expect("write db");
std::fs::write(root.join("note.md"), "# 一\n").expect("write note");
let committed = commit(&root, "first").expect("commit");
assert!(committed.ok, "{committed:?}");
let (ok, tracked) = run(&root, &["ls-files"]).expect("ls-files");
assert!(ok);
assert!(tracked.contains("note.md"), "{tracked}");
assert!(!tracked.contains(".tasklens"), "{tracked}");
// The vault's own .gitignore is never rewritten.
assert!(!root.join(".gitignore").exists());
}
#[test]
fn pushes_pulls_against_a_local_remote() {
let bare = temp_dir();
assert!(run(&bare, &["init", "--bare"]).expect("init bare").0);
let origin = temp_dir();
assert!(run(&origin, &["init"]).expect("init origin").0);
configure(&origin);
fs::write(origin.join("a.md"), "一\n").expect("write a");
let remote_url = file_url(&bare);
assert!(set_remote(&origin, &remote_url).expect("set remote").ok);
assert!(commit(&origin, "first").expect("commit").ok);
let pushed = push(&origin).expect("push");
assert!(pushed.ok, "{pushed:?}");
let after = status(&origin);
assert!(after.upstream.is_some());
assert_eq!(after.ahead, 0);
assert_eq!(after.behind, 0);
let clone = temp_dir();
let clone_target = clone.join("work");
let output = Command::new("git")
.args(["clone", &remote_url, &clone_target.to_string_lossy()])
.output()
.expect("clone");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
configure(&clone_target);
fs::write(clone_target.join("b.md"), "二\n").expect("write b");
assert!(commit(&clone_target, "second").expect("commit").ok);
assert!(push(&clone_target).expect("push").ok);
assert_eq!(status(&origin).behind, 0);
let fetched = fetch(&origin).expect("fetch");
assert!(fetched.ok, "{fetched:?}");
assert_eq!(status(&origin).behind, 1);
let pulled = pull(&origin).expect("pull");
assert!(pulled.ok, "{pulled:?}");
assert!(origin.join("b.md").exists());
assert_eq!(status(&origin).behind, 0);
}
#[test]
fn sync_runs_commit_pull_push_in_order() {
let bare = temp_dir();
assert!(run(&bare, &["init", "--bare"]).expect("init bare").0);
let root = temp_dir();
assert!(run(&root, &["init"]).expect("init").0);
configure(&root);
fs::write(root.join("note.md"), "# 一\n").expect("write");
assert!(set_remote(&root, &file_url(&bare)).expect("remote").ok);
let result = sync(&root, "自动同步").expect("sync");
assert!(result.ok, "{result:?}");
let actions: Vec<&str> = result.steps.iter().map(|step| step.action.as_str()).collect();
assert_eq!(actions, vec!["commit", "push"]);
assert_eq!(result.status.changes, 0);
assert_eq!(result.status.ahead, 0);
fs::write(root.join("note.md"), "# 二\n").expect("write");
let result = sync(&root, "自动同步").expect("sync");
assert!(result.ok, "{result:?}");
let actions: Vec<&str> = result.steps.iter().map(|step| step.action.as_str()).collect();
assert_eq!(actions, vec!["commit", "pull", "push"]);
assert_eq!(result.status.ahead, 0);
}
}