feat: publish HoloLake model-native living system source

This commit is contained in:
冰朔 2026-08-03 10:04:41 +08:00
commit c395dd3a99
2467 changed files with 615073 additions and 0 deletions

View file

@ -0,0 +1,294 @@
use serde::Serialize;
use std::path::Path;
use super::command::git_output_result;
use super::run_git;
pub(crate) const FALLBACK_AUTHOR_NAME: &str = "Tolaria";
pub(crate) const FALLBACK_AUTHOR_EMAIL: &str = "vault@tolaria.default";
pub(crate) const LEGACY_FALLBACK_EMAIL: &str = "vault@tolaria.md";
const SOURCE_FALLBACK: &str = "fallback";
const SOURCE_GLOBAL: &str = "global";
const SOURCE_REPOSITORY: &str = "repository";
const SOURCE_SYSTEM: &str = "system";
const SOURCE_UNKNOWN: &str = "unknown";
const SOURCE_ENVIRONMENT: &str = "environment";
const WARNING_LOCAL_OVERRIDES_GLOBAL: &str = "local_overrides_global";
#[derive(Clone, Copy)]
pub(crate) enum AuthorConfigKey {
Name,
Email,
}
#[derive(Clone, Copy)]
enum ConfigScope {
Local,
Global,
}
#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
pub struct GitAuthorIdentity {
pub name: String,
pub email: String,
pub source: String,
pub warning: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AuthorIdentity {
name: String,
email: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ScopedConfigValue {
scope: String,
value: String,
}
pub fn git_author_identity(vault_path: &str) -> Result<GitAuthorIdentity, String> {
let dir = Path::new(vault_path);
ensure_author_config(dir)?;
let identity = resolved_git_author_identity(dir)?;
let source = author_identity_source(dir, &identity.email)?;
let warning = local_global_identity_warning(dir)?;
Ok(GitAuthorIdentity {
name: identity.name,
email: identity.email,
source,
warning,
})
}
pub(crate) fn ensure_author_config(dir: &Path) -> Result<(), String> {
heal_legacy_local_identity(dir)?;
for (key, fallback, skip_legacy) in [
(AuthorConfigKey::Name, FALLBACK_AUTHOR_NAME, false),
(AuthorConfigKey::Email, FALLBACK_AUTHOR_EMAIL, true),
] {
let key_name = match key {
AuthorConfigKey::Name => "user.name",
AuthorConfigKey::Email => "user.email",
};
let resolved = git_output_result(dir, &["config", key_name])
.map_err(|e| format!("Failed to check git config {key_name}: {e}"))?;
let value = String::from_utf8_lossy(&resolved.stdout);
let value = value.trim();
if resolved.status.success() && resolved_author_value_is_usable(value, skip_legacy) {
continue;
}
run_git(dir, &["config", "--local", key_name, fallback])?;
}
Ok(())
}
fn resolved_author_value_is_usable(value: &str, skip_legacy: bool) -> bool {
if value.is_empty() {
return false;
}
!skip_legacy || value != LEGACY_FALLBACK_EMAIL
}
fn heal_legacy_local_identity(dir: &Path) -> Result<(), String> {
let local_email = local_config_value(dir, AuthorConfigKey::Email)?;
if local_email.as_deref() != Some(LEGACY_FALLBACK_EMAIL) {
return Ok(());
}
run_git(dir, &["config", "--local", "--unset-all", "user.email"])?;
if local_config_value(dir, AuthorConfigKey::Name)?.as_deref() == Some(FALLBACK_AUTHOR_NAME) {
run_git(dir, &["config", "--local", "--unset-all", "user.name"])?;
}
Ok(())
}
pub(crate) fn local_config_value(
dir: &Path,
key: AuthorConfigKey,
) -> Result<Option<String>, String> {
config_value(dir, ConfigScope::Local, key)
}
fn global_config_value(dir: &Path, key: AuthorConfigKey) -> Result<Option<String>, String> {
config_value(dir, ConfigScope::Global, key)
}
fn config_value(
dir: &Path,
scope: ConfigScope,
key: AuthorConfigKey,
) -> Result<Option<String>, String> {
let scope_flag = match scope {
ConfigScope::Local => "--local",
ConfigScope::Global => "--global",
};
let key_name = match key {
AuthorConfigKey::Name => "user.name",
AuthorConfigKey::Email => "user.email",
};
let output = git_output_result(dir, &["config", scope_flag, key_name])
.map_err(|e| format!("Failed to check git config {key_name}: {e}"))?;
let value = String::from_utf8_lossy(&output.stdout);
let value = value.trim();
Ok((output.status.success() && !value.is_empty()).then(|| value.to_string()))
}
fn resolved_git_author_identity(dir: &Path) -> Result<AuthorIdentity, String> {
let output = git_output_result(dir, &["var", "GIT_AUTHOR_IDENT"])
.map_err(|e| format!("Failed to resolve git author identity: {e}"))?;
if !output.status.success() {
return Err(author_identity_error(&output));
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_author_ident(&stdout).ok_or_else(|| "Failed to parse git author identity".to_string())
}
fn author_identity_error(output: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let detail = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
format!("Failed to resolve git author identity: {detail}")
}
fn parse_author_ident(value: &str) -> Option<AuthorIdentity> {
let value = value.trim();
let close = value.rfind('>')?;
let before_email_close = &value[..close];
let open = before_email_close.rfind('<')?;
let name = before_email_close[..open].trim();
let email = before_email_close[open + 1..].trim();
if name.is_empty() || email.is_empty() {
return None;
}
Some(AuthorIdentity {
name: name.to_string(),
email: email.to_string(),
})
}
fn author_identity_source(dir: &Path, email: &str) -> Result<String, String> {
if email == FALLBACK_AUTHOR_EMAIL {
return Ok(SOURCE_FALLBACK.to_string());
}
let Some(config) = scoped_config_value(dir, AuthorConfigKey::Email)? else {
return Ok(SOURCE_UNKNOWN.to_string());
};
if config.value != email {
return Ok(SOURCE_ENVIRONMENT.to_string());
}
Ok(scope_source(&config.scope).to_string())
}
fn scoped_config_value(
dir: &Path,
key: AuthorConfigKey,
) -> Result<Option<ScopedConfigValue>, String> {
let key_name = match key {
AuthorConfigKey::Name => "user.name",
AuthorConfigKey::Email => "user.email",
};
let output = git_output_result(dir, &["config", "--show-scope", "--get", key_name])
.map_err(|e| format!("Failed to check git config {key_name}: {e}"))?;
if !output.status.success() {
return Ok(None);
}
Ok(parse_scoped_config_value(&String::from_utf8_lossy(
&output.stdout,
)))
}
fn parse_scoped_config_value(stdout: &str) -> Option<ScopedConfigValue> {
let line = stdout.lines().next()?.trim();
let (scope, value) = line.split_once('\t')?;
let value = value.trim();
(!scope.is_empty() && !value.is_empty()).then(|| ScopedConfigValue {
scope: scope.to_string(),
value: value.to_string(),
})
}
fn scope_source(scope: &str) -> &str {
match scope {
"local" | "worktree" => SOURCE_REPOSITORY,
"global" => SOURCE_GLOBAL,
"system" => SOURCE_SYSTEM,
"command" => SOURCE_ENVIRONMENT,
_ => SOURCE_UNKNOWN,
}
}
fn local_global_identity_warning(dir: &Path) -> Result<Option<String>, String> {
let Some(local) = config_identity(dir, local_config_value)? else {
return Ok(None);
};
let Some(global) = config_identity(dir, global_config_value)? else {
return Ok(None);
};
Ok((local != global).then(|| WARNING_LOCAL_OVERRIDES_GLOBAL.to_string()))
}
fn config_identity(
dir: &Path,
reader: fn(&Path, AuthorConfigKey) -> Result<Option<String>, String>,
) -> Result<Option<AuthorIdentity>, String> {
let name = reader(dir, AuthorConfigKey::Name)?;
let email = reader(dir, AuthorConfigKey::Email)?;
Ok(match (name, email) {
(Some(name), Some(email)) => Some(AuthorIdentity { name, email }),
_ => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_author_ident_with_spaces_in_name() {
let identity = parse_author_ident("Vault Owner <owner@example.com> 1781170560 +0200")
.expect("author identity should parse");
assert_eq!(
identity,
AuthorIdentity {
name: "Vault Owner".to_string(),
email: "owner@example.com".to_string(),
}
);
}
#[test]
fn parses_scoped_config_value() {
assert_eq!(
parse_scoped_config_value("local\towner@example.com\n"),
Some(ScopedConfigValue {
scope: "local".to_string(),
value: "owner@example.com".to_string(),
})
);
}
}

View file

@ -0,0 +1,266 @@
use std::path::Path;
use std::process::{Command, Output, Stdio};
use super::git_command;
struct CloneRequest<'a> {
url: &'a str,
dest: &'a Path,
}
/// Clone a git repository to a local path using the system git configuration.
pub fn clone_repo(url: &str, local_path: &str) -> Result<String, String> {
let dest = Path::new(local_path);
let request = CloneRequest { url, dest };
prepare_clone_destination(dest)?;
if let Err(err) = run_clone(&request) {
cleanup_failed_clone(dest);
return Err(err);
}
Ok(format!("Cloned to {}", dest.display()))
}
fn prepare_clone_destination(dest: &Path) -> Result<(), String> {
if !dest.exists() {
return ensure_parent_directory(dest);
}
ensure_empty_directory(dest)
}
fn ensure_empty_directory(dest: &Path) -> Result<(), String> {
if !dest.is_dir() {
return Err(format!(
"Destination '{}' already exists and is not a directory",
dest.display()
));
}
if directory_has_entries(dest)? {
return Err(format!(
"Destination '{}' already exists and is not empty",
dest.display()
));
}
Ok(())
}
fn ensure_parent_directory(dest: &Path) -> Result<(), String> {
let Some(parent) = dest.parent() else {
return Ok(());
};
if parent.as_os_str().is_empty() {
return Ok(());
}
std::fs::create_dir_all(parent).map_err(|e| {
format!(
"Failed to create parent directory for '{}': {}",
dest.display(),
e
)
})
}
fn directory_has_entries(dest: &Path) -> Result<bool, String> {
dest.read_dir()
.map_err(|e| format!("Failed to inspect destination '{}': {}", dest.display(), e))
.map(|mut entries| entries.next().is_some())
}
fn run_clone(request: &CloneRequest<'_>) -> Result<(), String> {
let destination = request.dest.to_str().ok_or_else(|| {
format!(
"Destination '{}' is not valid UTF-8",
request.dest.display()
)
})?;
let git_destination = super::git_path_argument(destination)?;
let output = build_clone_command(request, &git_destination)
.output()
.map_err(|e| format!("Failed to run git clone: {}", e))?;
if output.status.success() {
return Ok(());
}
Err(format!(
"git clone failed: {}",
clone_failure_message(&output)
))
}
fn build_clone_command(request: &CloneRequest<'_>, destination: &str) -> Command {
let mut command = git_command();
command
.args(["clone", "--quiet", "--", request.url, destination])
.env("GIT_TERMINAL_PROMPT", "0")
.env("SSH_ASKPASS_REQUIRE", "never")
.stdin(Stdio::null());
command
}
fn clone_failure_message(output: &Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr.trim();
if !stderr.is_empty() {
return stderr.to_string();
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = stdout.trim();
if !stdout.is_empty() {
return stdout.to_string();
}
format!("git clone exited with status {}", output.status)
}
fn cleanup_failed_clone(dest: &Path) {
if dest.exists() && dest.is_dir() {
let _ = std::fs::remove_dir_all(dest);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::os::unix::process::ExitStatusExt;
use std::path::Path;
use std::process::Command as StdCommand;
fn init_source_repo(path: &Path) {
fs::create_dir_all(path).unwrap();
fs::write(path.join("welcome.md"), "# Welcome\n").unwrap();
StdCommand::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.email", "tolaria@app.local"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.name", "Tolaria App"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["add", "."])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(path)
.output()
.unwrap();
}
#[test]
fn test_clone_repo_clones_local_repository() {
let dir = tempfile::TempDir::new().unwrap();
let source = dir.path().join("source");
let dest = dir.path().join("dest");
init_source_repo(&source);
let result = clone_repo(source.to_str().unwrap(), dest.to_str().unwrap()).unwrap();
assert_eq!(result, format!("Cloned to {}", dest.to_string_lossy()));
assert!(dest.join(".git").exists());
assert!(dest.join("welcome.md").exists());
}
#[test]
fn test_clone_repo_nonempty_dest() {
let dir = tempfile::TempDir::new().unwrap();
fs::write(dir.path().join("existing.txt"), "data").unwrap();
let result = clone_repo("https://example.com/repo.git", dir.path().to_str().unwrap());
assert!(result.unwrap_err().contains("not empty"));
}
#[test]
fn test_clone_repo_empty_dest_allowed() {
let dir = tempfile::TempDir::new().unwrap();
let dest = dir.path().join("empty-dir");
fs::create_dir(&dest).unwrap();
let result = clone_repo(
"https://example.com/nonexistent/repo.git",
dest.to_str().unwrap(),
);
assert!(result.unwrap_err().contains("git clone failed"));
}
#[test]
fn test_clone_failure_message_falls_back_to_stdout() {
let output = Output {
status: std::process::ExitStatus::from_raw(128),
stdout: b"fatal: stdout only".to_vec(),
stderr: Vec::new(),
};
assert_eq!(clone_failure_message(&output), "fatal: stdout only");
}
#[test]
fn test_build_clone_command_disables_interactive_prompts() {
let dest = Path::new("/tmp/repo");
let request = CloneRequest {
url: "https://example.com/repo.git",
dest,
};
let command = build_clone_command(&request, "/tmp/repo");
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().to_string())
.collect::<Vec<_>>();
let envs = command
.get_envs()
.map(|(key, value)| {
(
key.to_string_lossy().to_string(),
value.map(|entry| entry.to_string_lossy().to_string()),
)
})
.collect::<std::collections::HashMap<_, _>>();
assert_eq!(
envs.get("GIT_TERMINAL_PROMPT"),
Some(&Some("0".to_string()))
);
assert_eq!(
envs.get("SSH_ASKPASS_REQUIRE"),
Some(&Some("never".to_string()))
);
assert_eq!(
args,
vec![
"-c".to_string(),
"core.quotePath=false".to_string(),
"-c".to_string(),
"protocol.ext.allow=never".to_string(),
"-c".to_string(),
"protocol.file.allow=user".to_string(),
"-c".to_string(),
"core.fsmonitor=false".to_string(),
"-c".to_string(),
"core.sshCommand=ssh".to_string(),
"clone".to_string(),
"--quiet".to_string(),
"--".to_string(),
"https://example.com/repo.git".to_string(),
"/tmp/repo".to_string(),
]
);
}
}

View file

@ -0,0 +1,57 @@
use std::io;
use std::path::Path;
use std::process::Output;
use super::git_command_at;
pub(super) fn git_output(dir: &Path, args: &[&str]) -> io::Result<Output> {
git_command_at(dir)?.args(args).output()
}
pub(super) fn git_output_result(dir: &Path, args: &[&str]) -> Result<Output, String> {
git_output(dir, args).map_err(|e| format!("Failed to run git {}: {e}", git_command_label(args)))
}
pub(super) fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
let output = git_output_result(dir, args)?;
if output.status.success() {
return Ok(());
}
Err(stderr_text(&output))
}
pub(super) fn stdout_text(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
pub(super) fn stdout_lines(output: &Output) -> Vec<String> {
stdout_text(output)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect()
}
pub(super) fn stderr_text(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).trim().to_string()
}
pub(super) fn stderr_or_failure(command: &str, output: &Output) -> String {
let stderr = stderr_text(output);
if stderr.is_empty() {
format!("{command} failed")
} else {
stderr
}
}
pub(super) fn git_command_label<'a>(args: &'a [&'a str]) -> &'a str {
if args.first() == Some(&"-c") {
return args.get(2).copied().unwrap_or(args[0]);
}
args[0]
}

View file

@ -0,0 +1,274 @@
use super::command::git_output_result;
use super::{ensure_author_config, git_command_at};
use std::path::Path;
struct CommitFailure {
stdout: String,
stderr: String,
}
/// Commit all changes with a message.
pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
// Stage all changes
let add = git_output_result(vault, &["add", "-A"])
.map_err(|e| format!("Failed to run git add: {}", e))?;
if !add.status.success() {
let stderr = String::from_utf8_lossy(&add.stderr);
return Err(format!("git add failed: {}", stderr));
}
ensure_author_config(vault)?;
match run_commit(vault, message, false) {
Ok(stdout) => Ok(stdout),
Err(failure) if is_commit_signing_failure(&failure.detail()) => {
run_commit(vault, message, true).map_err(|retry_failure| {
format!(
"git commit signing failed; retried without signing but git commit still failed: {}",
retry_failure.detail()
)
})
}
Err(failure) => Err(format!("git commit failed: {}", failure.detail())),
}
}
fn run_commit(vault: &Path, message: &str, disable_signing: bool) -> Result<String, CommitFailure> {
let mut command = git_command_at(vault).map_err(|e| CommitFailure {
stdout: String::new(),
stderr: format!("Failed to run git commit: {}", e),
})?;
if disable_signing {
command.args(["-c", "commit.gpgsign=false"]);
}
let commit = command
.args(["commit", "-m", message])
.output()
.map_err(|e| CommitFailure {
stdout: String::new(),
stderr: format!("Failed to run git commit: {}", e),
})?;
if commit.status.success() {
return Ok(String::from_utf8_lossy(&commit.stdout).to_string());
}
Err(CommitFailure {
stdout: String::from_utf8_lossy(&commit.stdout).to_string(),
stderr: String::from_utf8_lossy(&commit.stderr).to_string(),
})
}
impl CommitFailure {
fn detail(&self) -> String {
// git writes "nothing to commit" to stdout, not stderr.
let detail = if self.stderr.trim().is_empty() {
&self.stdout
} else {
&self.stderr
};
detail.trim().to_string()
}
}
fn is_commit_signing_failure(detail: &str) -> bool {
let lower = detail.to_ascii_lowercase();
lower.contains("cannot run gpg")
|| lower.contains("gpg failed to sign")
|| lower.contains("failed to sign the data")
|| lower.contains("gpg.ssh")
|| (lower.contains("failed to write commit object")
&& (lower.contains("sign") || lower.contains("gpg")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_command;
use crate::git::tests::{setup_git_repo, GitConfigEnvGuard};
use std::fs;
use std::path::Path;
fn unset_local_author_config(vault: &Path) {
for key in ["user.name", "user.email"] {
let status = git_command()
.args(["config", "--local", "--unset-all", key])
.current_dir(vault)
.status()
.unwrap();
assert!(status.success(), "failed to unset {key}");
}
}
fn local_config_value(vault: &Path, key: &str) -> Option<String> {
let output = git_command()
.args(["config", "--local", key])
.current_dir(vault)
.output()
.unwrap();
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[test]
fn test_git_commit() {
let dir = setup_git_repo();
let vault = dir.path();
fs::write(vault.join("commit-test.md"), "# Test\n").unwrap();
let result = git_commit(vault.to_str().unwrap(), "Test commit");
assert!(result.is_ok());
// Verify the commit exists
let log = git_command()
.args(["log", "--oneline", "-1"])
.current_dir(vault)
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Test commit"));
}
#[test]
fn test_git_commit_sets_missing_local_author_identity() {
let _env = GitConfigEnvGuard::isolated();
let dir = setup_git_repo();
let vault = dir.path();
unset_local_author_config(vault);
fs::write(vault.join("identity-fallback.md"), "# Identity fallback\n").unwrap();
let result = git_commit(vault.to_str().unwrap(), "Commit without local identity");
assert!(
result.is_ok(),
"commit should set local fallback identity: {result:?}"
);
assert_eq!(
local_config_value(vault, "user.name").as_deref(),
Some("Tolaria")
);
assert_eq!(
local_config_value(vault, "user.email").as_deref(),
Some("vault@tolaria.default")
);
let author = git_command()
.args(["log", "-1", "--format=%an <%ae>"])
.current_dir(vault)
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&author.stdout).trim(),
"Tolaria <vault@tolaria.default>"
);
}
#[test]
fn test_git_commit_respects_global_author_identity() {
let _env =
GitConfigEnvGuard::with_global_identity(Some(("Global User", "global@test.com")));
let dir = setup_git_repo();
let vault = dir.path();
unset_local_author_config(vault);
fs::write(vault.join("global-identity.md"), "# Global identity\n").unwrap();
let result = git_commit(vault.to_str().unwrap(), "Commit with global identity");
assert!(
result.is_ok(),
"commit should use the global identity: {result:?}"
);
// The global identity resolves, so no local override is written.
assert_eq!(local_config_value(vault, "user.name"), None);
assert_eq!(local_config_value(vault, "user.email"), None);
let author = git_command()
.args(["log", "-1", "--format=%an <%ae>"])
.current_dir(vault)
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&author.stdout).trim(),
"Global User <global@test.com>"
);
}
#[test]
fn test_commit_nothing_to_commit_returns_error() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
// Create and commit, so working tree is clean
fs::write(vault.join("clean.md"), "# Clean\n").unwrap();
git_commit(vp, "initial").unwrap();
// Committing again with no changes should fail
let result = git_commit(vp, "nothing here");
assert!(result.is_err(), "Commit should fail when nothing to commit");
assert!(
result.unwrap_err().contains("nothing to commit"),
"Error should mention 'nothing to commit'"
);
}
#[test]
fn test_git_commit_retries_without_signing_when_gpg_is_missing() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
git_command()
.args(["config", "commit.gpgsign", "true"])
.current_dir(vault)
.output()
.unwrap();
git_command()
.args(["config", "gpg.program", "/missing/tolaria-test-gpg"])
.current_dir(vault)
.output()
.unwrap();
fs::write(vault.join("signed-config.md"), "# Signed config\n").unwrap();
let result = git_commit(vp, "Commit with broken signing config");
assert!(
result.is_ok(),
"commit should retry unsigned when signing helper is missing: {result:?}"
);
let log = git_command()
.args(["log", "--oneline", "-1"])
.current_dir(vault)
.output()
.unwrap();
assert!(String::from_utf8_lossy(&log.stdout).contains("Commit with broken signing config"));
let config = git_command()
.args(["config", "commit.gpgsign"])
.current_dir(vault)
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&config.stdout).trim(), "true");
}
#[test]
fn test_commit_signing_failure_detection_is_specific() {
assert!(is_commit_signing_failure(
"error: cannot run gpg: No such file or directory\nfatal: failed to write commit object"
));
assert!(!is_commit_signing_failure(
"On branch main\nnothing to commit, working tree clean"
));
}
}

View file

@ -0,0 +1,414 @@
use std::path::Path;
use super::command::git_output_result;
use super::{ensure_author_config, git_command_at, run_git};
/// List files with merge conflicts (unmerged paths).
///
/// Uses `git ls-files --unmerged` instead of `git diff --diff-filter=U` because
/// ls-files reliably detects unmerged index entries even when the merge state is
/// stale (e.g. after a reboot or when MERGE_HEAD is missing).
pub fn get_conflict_files(vault_path: &str) -> Result<Vec<String>, String> {
let vault = Path::new(vault_path);
let output = git_output_result(vault, &["ls-files", "--unmerged"])
.map_err(|e| format!("Failed to check conflicts: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout);
// Each unmerged file appears multiple times (once per stage: base/ours/theirs).
// Format: "<mode> <hash> <stage>\t<path>"
let mut files: Vec<String> = stdout
.lines()
.filter_map(|line| line.split('\t').nth(1).map(|s| s.to_string()))
.collect();
files.sort();
files.dedup();
Ok(files)
}
/// Resolve a single conflict file by choosing "ours" or "theirs" strategy,
/// then stage the result.
pub fn git_resolve_conflict(vault_path: &str, file: &str, strategy: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
let checkout_flag = match strategy {
"ours" => "--ours",
"theirs" => "--theirs",
_ => {
return Err(format!(
"Invalid strategy '{}': must be 'ours' or 'theirs'",
strategy
))
}
};
run_git(vault, &["checkout", checkout_flag, "--", file])?;
run_git(vault, &["add", "--", file])?;
Ok(())
}
/// Check whether a rebase is currently in progress.
pub fn is_rebase_in_progress(vault_path: &str) -> bool {
let vault = Path::new(vault_path);
let git_dir = vault.join(".git");
git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists()
}
/// Check whether a merge is currently in progress.
pub fn is_merge_in_progress(vault_path: &str) -> bool {
Path::new(vault_path)
.join(".git")
.join("MERGE_HEAD")
.exists()
}
/// Returns the current conflict mode: "rebase", "merge", or "none".
pub fn get_conflict_mode(vault_path: &str) -> String {
if is_rebase_in_progress(vault_path) {
"rebase".to_string()
} else if is_merge_in_progress(vault_path) {
"merge".to_string()
} else {
"none".to_string()
}
}
/// Commit after all conflicts have been resolved.
/// Detects whether the repo is in a merge or rebase state and uses the
/// appropriate command (`git commit` vs `git rebase --continue`).
pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
// Verify no remaining conflicts
let remaining = get_conflict_files(vault_path)?;
if !remaining.is_empty() {
return Err(format!(
"Cannot commit: {} file(s) still have unresolved conflicts",
remaining.len()
));
}
ensure_author_config(vault)?;
let mode = get_conflict_mode(vault_path);
let output = match mode.as_str() {
"rebase" => git_command_at(vault)
.and_then(|mut command| {
command
.args(["rebase", "--continue"])
.env("GIT_EDITOR", "true")
.output()
})
.map_err(|e| format!("Failed to run git rebase --continue: {}", e))?,
_ => git_command_at(vault)
.and_then(|mut command| {
command
.args(["commit", "-m", "Resolve merge conflicts"])
.output()
})
.map_err(|e| format!("Failed to run git commit: {}", e))?,
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let detail = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
let cmd_name = if mode == "rebase" {
"git rebase --continue"
} else {
"git commit"
};
return Err(format!("{} failed: {}", cmd_name, detail.trim()));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_command;
use crate::git::tests::{setup_git_repo, setup_remote_pair, GitConfigEnvGuard};
use crate::git::{git_commit, git_pull, git_push};
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn unset_local_author_config(vault: &Path) {
for key in ["user.name", "user.email"] {
let status = git_command()
.args(["config", "--local", "--unset-all", key])
.current_dir(vault)
.status()
.unwrap();
assert!(status.success(), "failed to unset {key}");
}
}
fn local_config_value(vault: &Path, key: &str) -> Option<String> {
let output = git_command()
.args(["config", "--local", key])
.current_dir(vault)
.output()
.unwrap();
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[test]
fn test_get_conflict_files_empty_when_clean() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
let conflicts = get_conflict_files(vp).unwrap();
assert!(conflicts.is_empty());
}
#[test]
fn test_resolve_conflict_invalid_strategy() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let result = git_resolve_conflict(vp_b, "conflict.md", "invalid");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid strategy"));
}
#[test]
fn test_conflict_mode_none_for_clean_repo() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
assert_eq!(get_conflict_mode(vp), "none");
assert!(!is_rebase_in_progress(vp));
assert!(!is_merge_in_progress(vp));
}
/// Set up a pair of clones that have a merge conflict on the same file.
/// Returns (bare, clone_a, clone_b) where clone_b has an unresolved conflict.
fn setup_conflict_pair() -> (TempDir, TempDir, TempDir) {
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
let vp_a = clone_a_dir.path().to_str().unwrap();
let vp_b = clone_b_dir.path().to_str().unwrap();
// A creates the file and pushes
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
git_commit(vp_a, "create conflict.md").unwrap();
git_push(vp_a).unwrap();
// B pulls to get the file
git_pull(vp_b).unwrap();
// A modifies and pushes
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
git_commit(vp_a, "A's change").unwrap();
git_push(vp_a).unwrap();
// B modifies the same file locally and commits
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
git_commit(vp_b, "B's change").unwrap();
// B pulls — this causes a merge conflict
let result = git_pull(vp_b).unwrap();
assert_eq!(result.status, "conflict");
(bare_dir, clone_a_dir, clone_b_dir)
}
fn assert_resolve_conflict_strategy(strategy: &str, expected_content: &str) {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let conflicts = get_conflict_files(vp_b).unwrap();
assert!(conflicts.contains(&"conflict.md".to_string()));
git_resolve_conflict(vp_b, "conflict.md", strategy).unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
assert_eq!(content, expected_content);
}
#[test]
fn test_resolve_conflict_ours() {
assert_resolve_conflict_strategy("ours", "# Version B\n");
}
#[test]
fn test_resolve_conflict_theirs() {
assert_resolve_conflict_strategy("theirs", "# Version A\n");
}
#[test]
fn test_commit_conflict_resolution() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok());
let log = git_command()
.args(["log", "--oneline", "-1"])
.current_dir(clone_b.path())
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Resolve merge conflicts"));
}
#[test]
fn test_commit_conflict_resolution_fails_with_unresolved() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("still have unresolved conflicts"));
}
#[test]
fn test_conflict_mode_merge_during_merge_conflict() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "merge");
assert!(is_merge_in_progress(vp_b));
assert!(!is_rebase_in_progress(vp_b));
}
#[test]
fn test_commit_conflict_resolution_merge_mode() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "merge");
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok());
assert_eq!(get_conflict_mode(vp_b), "none");
}
#[test]
fn test_commit_conflict_resolution_sets_missing_local_author_identity() {
let _env = GitConfigEnvGuard::isolated();
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vault = clone_b.path();
let vp_b = vault.to_str().unwrap();
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
unset_local_author_config(vault);
let result = git_commit_conflict_resolution(vp_b);
assert!(
result.is_ok(),
"conflict commit should set local fallback identity: {result:?}"
);
assert_eq!(
local_config_value(vault, "user.name").as_deref(),
Some("Tolaria")
);
assert_eq!(
local_config_value(vault, "user.email").as_deref(),
Some("vault@tolaria.default")
);
}
/// Set up a rebase conflict: clone_b has diverged from origin and
/// `git pull --rebase` causes a conflict.
fn setup_rebase_conflict_pair() -> (TempDir, TempDir, TempDir) {
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
let vp_a = clone_a_dir.path().to_str().unwrap();
let vp_b = clone_b_dir.path().to_str().unwrap();
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
git_commit(vp_a, "create conflict.md").unwrap();
git_push(vp_a).unwrap();
git_pull(vp_b).unwrap();
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
git_commit(vp_a, "A's change").unwrap();
git_push(vp_a).unwrap();
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
git_commit(vp_b, "B's change").unwrap();
let output = git_command()
.args(["pull", "--rebase"])
.current_dir(clone_b_dir.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"Expected rebase conflict, but pull succeeded"
);
(bare_dir, clone_a_dir, clone_b_dir)
}
#[test]
fn test_conflict_mode_rebase_during_rebase_conflict() {
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "rebase");
assert!(is_rebase_in_progress(vp_b));
assert!(!is_merge_in_progress(vp_b));
}
#[test]
fn test_get_conflict_files_during_rebase() {
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let conflicts = get_conflict_files(vp_b).unwrap();
assert!(
conflicts.contains(&"conflict.md".to_string()),
"Should detect conflict.md during rebase, got: {:?}",
conflicts
);
}
#[test]
fn test_resolve_and_continue_rebase() {
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "rebase");
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok(), "rebase --continue failed: {:?}", result);
assert_eq!(get_conflict_mode(vp_b), "none");
}
}

View file

@ -0,0 +1,591 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Output;
use super::command::{
git_output, git_output_result, run_git, stderr_text, stdout_lines, stdout_text,
};
use super::credentials::request_remote_credentials;
use super::ensure_author_config;
use super::remote_config::{configure_origin_remote, list_configured_remotes};
const DEFAULT_REMOTE_NAME: &str = "origin";
#[derive(Clone, Copy)]
enum ConnectStatus {
Connected,
AlreadyConfigured,
IncompatibleHistory,
AuthError,
NetworkError,
Error,
}
impl ConnectStatus {
fn as_str(self) -> &'static str {
match self {
Self::Connected => "connected",
Self::AlreadyConfigured => "already_configured",
Self::IncompatibleHistory => "incompatible_history",
Self::AuthError => "auth_error",
Self::NetworkError => "network_error",
Self::Error => "error",
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GitAddRemoteResult {
pub status: String, // "connected" | "already_configured" | "incompatible_history" | "auth_error" | "network_error" | "error"
pub message: String,
}
struct RemoteConnection {
branch: String,
remote_branch: String,
}
impl RemoteConnection {
fn new(branch: String) -> Self {
let remote_branch = format!("{DEFAULT_REMOTE_NAME}/{branch}");
Self {
branch,
remote_branch,
}
}
fn pushed_history_message(&self) -> String {
format!(
"Remote connected. Tolaria pushed your local commits and is now tracking {}.",
self.remote_branch
)
}
fn tracking_message(&self) -> String {
format!(
"Remote connected. This vault now tracks {}.",
self.remote_branch
)
}
}
pub fn disconnect_all_remotes(vault_path: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
for remote in list_remotes(vault)? {
run_git(vault, &["remote", "remove", &remote])?;
}
unset_upstream(vault);
Ok(())
}
pub fn git_add_remote(vault_path: &str, remote_url: &str) -> Result<GitAddRemoteResult, String> {
let vault = Path::new(vault_path);
if remote_url.trim().is_empty() {
return Ok(connect_result(
ConnectStatus::Error,
"Enter a repository URL before connecting a remote.",
));
}
if !list_remotes(vault)?.is_empty() {
return Ok(connect_result(
ConnectStatus::AlreadyConfigured,
"This vault already has a remote configured.",
));
}
ensure_author_config(vault)?;
let branch = current_branch(vault)?;
if branch.is_empty() {
return Ok(connect_result(
ConnectStatus::Error,
"Tolaria could not determine the current branch for this vault.",
));
}
let connection = RemoteConnection::new(branch);
let trimmed_url = remote_url.trim();
configure_origin_remote(vault, trimmed_url)?;
request_remote_credentials(vault, trimmed_url);
let result = finish_remote_connection(vault, &connection);
if result.status != "connected" {
let _ = disconnect_all_remotes(vault_path);
}
Ok(result)
}
fn finish_remote_connection(vault: &Path, connection: &RemoteConnection) -> GitAddRemoteResult {
if let Err(stderr) = fetch_remote(vault) {
return classify_connect_error(&stderr);
}
let remote_branches = match list_remote_branches(vault) {
Ok(branches) => branches,
Err(err) => return connect_result(ConnectStatus::Error, err),
};
if remote_branches.is_empty() {
return push_with_tracking(vault, connection, connection.pushed_history_message());
}
if !remote_branches
.iter()
.any(|candidate| candidate == &connection.remote_branch)
{
return connect_result(
ConnectStatus::IncompatibleHistory,
format!(
"This repository already has git branches, but not '{}'. Use an empty repository or one created from this vault.",
connection.branch
),
);
}
if !histories_share_base(vault, connection) {
return connect_result(
ConnectStatus::IncompatibleHistory,
"This repository has unrelated history. Use an empty repository or one created from this vault.",
);
}
let (_, behind) = match ahead_behind_counts(vault, connection) {
Ok(counts) => counts,
Err(err) => return connect_result(ConnectStatus::Error, err),
};
if behind > 0 {
return connect_result(
ConnectStatus::IncompatibleHistory,
format!(
"This repository already has commits on '{}' that are not in this vault. Tolaria will not connect it automatically.",
connection.branch
),
);
}
push_with_tracking(vault, connection, connection.tracking_message())
}
fn connect_result(status: ConnectStatus, message: impl Into<String>) -> GitAddRemoteResult {
GitAddRemoteResult {
status: status.as_str().to_string(),
message: message.into(),
}
}
fn current_branch(vault: &Path) -> Result<String, String> {
let output = git_output_result(vault, &["branch", "--show-current"])?;
if output.status.success() {
return Ok(stdout_text(&output));
}
Err(command_error("git branch --show-current", &output))
}
fn list_remotes(vault: &Path) -> Result<Vec<String>, String> {
list_configured_remotes(vault)
}
fn unset_upstream(vault: &Path) {
let _ = git_output(vault, &["branch", "--unset-upstream"]);
}
fn fetch_remote(vault: &Path) -> Result<(), String> {
run_git(vault, &["fetch", DEFAULT_REMOTE_NAME, "--prune"])
}
fn list_remote_branches(vault: &Path) -> Result<Vec<String>, String> {
let output = git_output_result(
vault,
&[
"for-each-ref",
"--format=%(refname:short)",
"refs/remotes/origin",
],
)?;
if !output.status.success() {
return Err(command_error("git for-each-ref", &output));
}
Ok(stdout_lines(&output)
.into_iter()
.filter(|line| line != "origin/HEAD")
.collect())
}
fn histories_share_base(vault: &Path, connection: &RemoteConnection) -> bool {
git_output(
vault,
&["merge-base", "HEAD", connection.remote_branch.as_str()],
)
.map(|output| output.status.success())
.unwrap_or(false)
}
fn ahead_behind_counts(vault: &Path, connection: &RemoteConnection) -> Result<(u32, u32), String> {
let revision_range = format!("HEAD...{}", connection.remote_branch);
let output = git_output_result(
vault,
&["rev-list", "--left-right", "--count", &revision_range],
)?;
if !output.status.success() {
return Err(command_error("git rev-list", &output));
}
let counts = stdout_text(&output);
let parts: Vec<&str> = counts.trim().split('\t').collect();
let ahead = parts
.first()
.and_then(|value| value.parse().ok())
.unwrap_or(0);
let behind = parts
.get(1)
.and_then(|value| value.parse().ok())
.unwrap_or(0);
Ok((ahead, behind))
}
fn push_with_tracking(
vault: &Path,
connection: &RemoteConnection,
success_message: String,
) -> GitAddRemoteResult {
match run_git(
vault,
&[
"push",
"-u",
DEFAULT_REMOTE_NAME,
connection.branch.as_str(),
],
) {
Ok(()) => connect_result(ConnectStatus::Connected, success_message),
Err(stderr) => classify_connect_error(&stderr),
}
}
fn classify_connect_error(stderr: &str) -> GitAddRemoteResult {
let lower = stderr.to_lowercase();
if is_auth_error(&lower) {
return connect_result(
ConnectStatus::AuthError,
"Could not connect to that remote because git reported an authentication error. Check your credentials and try again.",
);
}
if is_network_error(&lower) {
return connect_result(
ConnectStatus::NetworkError,
"Could not reach that remote. Check your connection and repository URL, then try again.",
);
}
connect_result(
ConnectStatus::Error,
format!(
"Could not connect that remote: {}",
concise_git_detail(stderr)
),
)
}
fn command_error(command: &str, output: &Output) -> String {
format!("{command} failed: {}", stderr_text(output))
}
fn is_auth_error(lower: &str) -> bool {
[
"authentication failed",
"could not read username",
"permission denied",
"the requested url returned error: 403",
"invalid credentials",
"repository not found",
]
.iter()
.any(|needle| lower.contains(needle))
}
fn is_network_error(lower: &str) -> bool {
[
"could not resolve host",
"unable to access",
"connection refused",
"network is unreachable",
"timed out",
"couldn't connect",
]
.iter()
.any(|needle| lower.contains(needle))
}
fn concise_git_detail(stderr: &str) -> String {
stderr
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("git reported an unknown error")
.trim_start_matches("fatal:")
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::tests::{setup_git_repo, GitConfigEnvGuard};
use crate::git::{git_commit, git_remote_status};
use std::fs;
use std::process::Command as StdCommand;
use tempfile::TempDir;
fn init_bare_remote(path: &Path) {
StdCommand::new("git")
.args(["init", "--bare", "--initial-branch=main"])
.current_dir(path)
.output()
.unwrap();
}
fn configure_author(path: &Path, email: &str, name: &str) {
StdCommand::new("git")
.args(["config", "user.email", email])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.name", name])
.current_dir(path)
.output()
.unwrap();
}
fn seed_remote_history(bare_path: &Path) {
let working = TempDir::new().unwrap();
StdCommand::new("git")
.args(["clone", bare_path.to_str().unwrap(), "."])
.current_dir(working.path())
.output()
.unwrap();
configure_author(working.path(), "remote@test.com", "Remote User");
fs::write(working.path().join("remote.md"), "# Remote\n").unwrap();
StdCommand::new("git")
.args(["add", "."])
.current_dir(working.path())
.output()
.unwrap();
StdCommand::new("git")
.args(["commit", "-m", "Seed remote"])
.current_dir(working.path())
.output()
.unwrap();
StdCommand::new("git")
.args(["push", "origin", "main"])
.current_dir(working.path())
.output()
.unwrap();
}
fn create_local_commit(path: &Path, filename: &str, title: &str, message: &str) {
fs::write(path.join(filename), format!("# {title}\n")).unwrap();
git_commit(path.to_str().unwrap(), message).unwrap();
}
fn clear_local_author(path: &Path) {
for key in ["user.name", "user.email"] {
StdCommand::new("git")
.args(["config", "--local", "--unset-all", key])
.current_dir(path)
.output()
.unwrap();
}
}
fn local_author_is_configured(path: &Path) -> bool {
["user.name", "user.email"].into_iter().all(|key| {
let output = StdCommand::new("git")
.args(["config", "--local", key])
.current_dir(path)
.output()
.unwrap();
output.status.success() && !String::from_utf8_lossy(&output.stdout).trim().is_empty()
})
}
#[test]
fn disconnect_all_remotes_removes_every_remote() {
let dir = setup_git_repo();
let vault = dir.path();
let vault_path = vault.to_str().unwrap();
StdCommand::new("git")
.args(["remote", "add", "origin", "https://example.com/one.git"])
.current_dir(vault)
.output()
.unwrap();
StdCommand::new("git")
.args(["remote", "add", "backup", "https://example.com/two.git"])
.current_dir(vault)
.output()
.unwrap();
disconnect_all_remotes(vault_path).unwrap();
assert!(list_remotes(vault).unwrap().is_empty());
}
#[test]
fn git_add_remote_connects_an_empty_remote_and_pushes_local_history() {
let local = setup_git_repo();
configure_author(local.path(), "local@test.com", "Local User");
create_local_commit(local.path(), "note.md", "Local", "Initial local commit");
let bare = TempDir::new().unwrap();
init_bare_remote(bare.path());
let result = git_add_remote(
local.path().to_str().unwrap(),
bare.path().to_str().unwrap(),
)
.unwrap();
assert_eq!(result.status, "connected");
assert!(result.message.contains("tracking"));
let status = git_remote_status(local.path().to_str().unwrap()).unwrap();
assert!(status.has_remote);
assert_eq!((status.ahead, status.behind), (0, 0));
}
#[test]
fn git_add_remote_sets_local_identity_when_existing_repo_has_none() {
let _env = GitConfigEnvGuard::isolated();
let local = setup_git_repo();
create_local_commit(local.path(), "note.md", "Local", "Initial local commit");
clear_local_author(local.path());
assert!(!local_author_is_configured(local.path()));
let bare = TempDir::new().unwrap();
init_bare_remote(bare.path());
let result = git_add_remote(
local.path().to_str().unwrap(),
bare.path().to_str().unwrap(),
)
.unwrap();
assert_eq!(result.status, "connected");
assert!(local_author_is_configured(local.path()));
let email = StdCommand::new("git")
.args(["config", "--local", "user.email"])
.current_dir(local.path())
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&email.stdout).trim(),
"vault@tolaria.default"
);
}
#[test]
fn git_add_remote_pushes_when_remote_is_the_local_branch_ancestor() {
let local = setup_git_repo();
configure_author(local.path(), "local@test.com", "Local User");
create_local_commit(local.path(), "note.md", "Base", "Base commit");
let bare = TempDir::new().unwrap();
StdCommand::new("git")
.args([
"clone",
"--bare",
local.path().to_str().unwrap(),
bare.path().to_str().unwrap(),
])
.output()
.unwrap();
create_local_commit(local.path(), "next.md", "Next", "Local follow-up");
let result = git_add_remote(
local.path().to_str().unwrap(),
bare.path().to_str().unwrap(),
)
.unwrap();
assert_eq!(result.status, "connected");
let status = git_remote_status(local.path().to_str().unwrap()).unwrap();
assert!(status.has_remote);
assert_eq!((status.ahead, status.behind), (0, 0));
}
#[test]
fn git_add_remote_rejects_unrelated_remote_history_and_cleans_up() {
let local = setup_git_repo();
configure_author(local.path(), "local@test.com", "Local User");
create_local_commit(local.path(), "note.md", "Local", "Local commit");
let bare = TempDir::new().unwrap();
init_bare_remote(bare.path());
seed_remote_history(bare.path());
let result = git_add_remote(
local.path().to_str().unwrap(),
bare.path().to_str().unwrap(),
)
.unwrap();
assert_eq!(result.status, "incompatible_history");
assert!(result.message.contains("unrelated history"));
assert!(list_remotes(local.path()).unwrap().is_empty());
}
#[test]
fn git_add_remote_reports_when_the_vault_is_already_remote_backed() {
let local = setup_git_repo();
let vault = local.path();
StdCommand::new("git")
.args(["remote", "add", "origin", "https://example.com/repo.git"])
.current_dir(vault)
.output()
.unwrap();
let result =
git_add_remote(vault.to_str().unwrap(), "https://example.com/other.git").unwrap();
assert_eq!(result.status, "already_configured");
}
#[test]
fn classify_connect_error_maps_auth_failures() {
let result = classify_connect_error(
"fatal: unable to access 'https://github.com/org/repo.git/': The requested URL returned error: 403",
);
assert_eq!(result.status, "auth_error");
}
#[test]
fn classify_connect_error_maps_network_failures() {
let result = classify_connect_error(
"fatal: unable to access 'https://github.com/org/repo.git/': Could not resolve host: github.com",
);
assert_eq!(result.status, "network_error");
}
}

View file

@ -0,0 +1,122 @@
use std::path::Path;
#[cfg(target_os = "macos")]
use std::io::Write;
#[cfg(target_os = "macos")]
use std::process::Stdio;
#[cfg(target_os = "macos")]
use super::git_command_at;
#[cfg(target_os = "macos")]
pub(super) fn request_remote_credentials(vault: &Path, remote_url: &str) {
let Some(input) = credential_fill_input(remote_url) else {
return;
};
let mut child = match git_command_at(vault).and_then(|mut command| {
command
.args(["credential", "fill"])
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
}) {
Ok(child) => child,
Err(_) => return,
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(input.as_bytes());
}
let _ = child.wait();
}
#[cfg(not(target_os = "macos"))]
pub(super) fn request_remote_credentials(_vault: &Path, _remote_url: &str) {}
#[cfg(any(test, target_os = "macos"))]
struct CredentialTarget<'a> {
protocol: &'a str,
host: &'a str,
username: Option<&'a str>,
path: Option<&'a str>,
}
#[cfg(any(test, target_os = "macos"))]
fn credential_fill_input(remote_url: &str) -> Option<String> {
let target = credential_target(remote_url)?;
let mut lines = vec![
format!("protocol={}", target.protocol),
format!("host={}", target.host),
];
if let Some(username) = target.username {
lines.push(format!("username={username}"));
}
if let Some(path) = target.path {
lines.push(format!("path={path}"));
}
Some(format!("{}\n\n", lines.join("\n")))
}
#[cfg(any(test, target_os = "macos"))]
fn credential_target(remote_url: &str) -> Option<CredentialTarget<'_>> {
let (protocol, rest) = remote_url.trim().split_once("://")?;
if !matches!(protocol, "https" | "http") {
return None;
}
let rest = rest.split_once('#').map_or(rest, |(value, _)| value);
let rest = rest.split_once('?').map_or(rest, |(value, _)| value);
let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
if authority.is_empty() {
return None;
}
let (username, host) = match authority.rsplit_once('@') {
Some((userinfo, host)) => {
let username = userinfo
.split_once(':')
.map_or(userinfo, |(name, _)| name)
.trim();
let username = (!username.is_empty()).then_some(username);
(username, host)
}
None => (None, authority),
};
if host.is_empty() {
return None;
}
Some(CredentialTarget {
protocol,
host,
username,
path: (!path.is_empty()).then_some(path),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn credential_fill_input_extracts_https_remote_parts() {
let input = credential_fill_input("https://github.com/refactoringhq/tolaria.git").unwrap();
assert!(input.contains("protocol=https\n"));
assert!(input.contains("host=github.com\n"));
assert!(input.contains("path=refactoringhq/tolaria.git\n"));
assert!(input.ends_with("\n\n"));
}
#[test]
fn credential_fill_input_ignores_ssh_remotes() {
assert!(credential_fill_input("git@github.com:refactoringhq/tolaria.git").is_none());
}
}

View file

@ -0,0 +1,230 @@
use super::git_command_at;
use chrono::DateTime;
use std::collections::HashMap;
use std::path::Path;
/// Git-derived creation and modification timestamps for a file.
#[derive(Debug, Clone)]
pub struct GitDates {
pub created_at: u64,
pub modified_at: u64,
}
/// Run a single `git log` to collect creation and modification dates for all
/// tracked files in the repository. Returns a map from relative path to dates.
///
/// - **modified_at** = author date of the most recent commit touching the file
/// - **created_at** = author date of the oldest commit touching the file
///
/// Files not yet committed (untracked / only staged) will not appear in the map;
/// callers should fall back to filesystem metadata for those.
pub fn get_all_file_dates(vault_path: &Path) -> HashMap<String, GitDates> {
let output = match git_command_at(vault_path).and_then(|mut command| {
command
.args(["log", "--format=COMMIT %aI", "--name-only"])
.output()
}) {
Ok(o) if o.status.success() => o,
_ => return HashMap::new(),
};
let stdout = String::from_utf8_lossy(&output.stdout);
parse_git_log_output(&stdout)
}
/// Parse the output of `git log --format="COMMIT %aI" --name-only`.
///
/// Output looks like:
/// ```text
/// COMMIT 2026-03-15T10:00:00+02:00
///
/// file-a.md
/// file-b.md
///
/// COMMIT 2026-03-10T08:00:00+02:00
///
/// file-a.md
/// ```
///
/// Commits are ordered newest-first. For each file:
/// - First occurrence → sets `modified_at`
/// - Every subsequent occurrence overwrites `created_at` (last one = oldest commit wins)
fn parse_git_log_output(stdout: &str) -> HashMap<String, GitDates> {
let mut map: HashMap<String, GitDates> = HashMap::new();
let mut current_ts: Option<u64> = None;
for line in stdout.lines() {
if let Some(date_str) = line.strip_prefix("COMMIT ") {
current_ts = parse_author_date(date_str);
continue;
}
let path = line.trim();
if path.is_empty() || current_ts.is_none() {
continue;
}
// Only process .md files
if !path.ends_with(".md") {
continue;
}
let ts = current_ts.unwrap();
map.entry(path.to_string())
.and_modify(|d| d.created_at = ts)
.or_insert(GitDates {
created_at: ts,
modified_at: ts,
});
}
map
}
fn parse_author_date(s: &str) -> Option<u64> {
DateTime::parse_from_rfc3339(s.trim())
.ok()
.map(|dt| dt.timestamp() as u64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_git_log_single_commit() {
let output = "\
COMMIT 2026-03-15T10:00:00+00:00
file-a.md
file-b.md
";
let map = parse_git_log_output(output);
assert_eq!(map.len(), 2);
assert_eq!(map["file-a.md"].created_at, 1773568800);
assert_eq!(map["file-a.md"].modified_at, 1773568800);
}
#[test]
fn test_parse_git_log_multiple_commits() {
let output = "\
COMMIT 2026-03-15T10:00:00+00:00
file-a.md
COMMIT 2026-03-10T08:00:00+00:00
file-a.md
file-b.md
";
let map = parse_git_log_output(output);
assert_eq!(map.len(), 2);
// file-a: modified = newest (2026-03-15), created = oldest (2026-03-10)
assert_eq!(map["file-a.md"].modified_at, 1773568800);
assert_eq!(map["file-a.md"].created_at, 1773129600);
// file-b: only in second commit
assert_eq!(map["file-b.md"].modified_at, 1773129600);
assert_eq!(map["file-b.md"].created_at, 1773129600);
}
#[test]
fn test_non_md_files_filtered_out() {
let output = "\
COMMIT 2026-03-15T10:00:00+00:00
README.txt
note.md
image.png
";
let map = parse_git_log_output(output);
assert_eq!(map.len(), 1);
assert!(map.contains_key("note.md"));
}
#[test]
fn test_empty_output() {
let map = parse_git_log_output("");
assert!(map.is_empty());
}
#[test]
fn test_subdirectory_paths() {
let output = "\
COMMIT 2026-03-15T10:00:00+00:00
docs/adr/0001-stack.md
notes/daily.md
";
let map = parse_git_log_output(output);
assert_eq!(map.len(), 2);
assert!(map.contains_key("docs/adr/0001-stack.md"));
assert!(map.contains_key("notes/daily.md"));
}
#[test]
fn test_get_all_file_dates_in_real_repo() {
let dir = tempfile::TempDir::new().unwrap();
let vault = dir.path();
// Init repo
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
// First commit with one file
std::fs::write(vault.join("first.md"), "# First\n").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "first"])
.current_dir(vault)
.output()
.unwrap();
// Second commit with another file + modify first
std::fs::write(vault.join("first.md"), "# First\nUpdated.\n").unwrap();
std::fs::write(vault.join("second.md"), "# Second\n").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "second"])
.current_dir(vault)
.output()
.unwrap();
let map = get_all_file_dates(vault);
assert_eq!(map.len(), 2);
assert!(map.contains_key("first.md"));
assert!(map.contains_key("second.md"));
// first.md: created in commit 1, modified in commit 2
// So modified_at > created_at (or equal if commits are same second)
assert!(map["first.md"].modified_at >= map["first.md"].created_at);
// second.md: only in commit 2
assert_eq!(map["second.md"].modified_at, map["second.md"].created_at);
}
#[test]
fn test_get_all_file_dates_no_git_repo() {
let dir = tempfile::TempDir::new().unwrap();
let map = get_all_file_dates(dir.path());
assert!(map.is_empty());
}
}

View file

@ -0,0 +1,403 @@
use std::path::Path;
use crate::vault::path_identity::vault_relative_path_string;
use super::command::{git_output, stderr_or_failure, stdout_text};
use super::remote_config::primary_remote_url;
enum RemoteWebKind {
Bitbucket,
Gitea,
GitLab,
Generic,
}
struct RemoteWebBase {
base_url: String,
kind: RemoteWebKind,
}
struct GitFileLocation {
branch: BranchName,
relative_path: RelativeGitPath,
}
struct BranchName(String);
struct RelativeGitPath(String);
struct RemoteUrl(String);
struct RemoteParts {
host: RemoteHost,
repo_path: RepoPath,
}
struct RemoteHost(String);
struct RepoPath(String);
pub fn git_file_url(vault_path: &str, file_path: &str) -> Result<Option<String>, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let Some(relative_path) = RelativeGitPath::from_paths(vault, file)? else {
return Ok(None);
};
let Some(remote_url) = primary_remote_url(vault)?.map(RemoteUrl::new) else {
return Ok(None);
};
let location = GitFileLocation::new(current_ref_name(vault)?, relative_path);
let url = match remote_web_base(&remote_url) {
Some(remote) => remote.file_url(&location),
None => remote_url.git_fragment_url(&location),
};
Ok(Some(url))
}
fn current_ref_name(vault: &Path) -> Result<BranchName, String> {
let output = git_output(vault, &["branch", "--show-current"])
.map_err(|e| format!("Failed to get branch: {e}"))?;
if !output.status.success() {
return Err(stderr_or_failure("git branch", &output));
}
let branch = stdout_text(&output);
Ok(BranchName::new(branch))
}
impl GitFileLocation {
fn new(branch: BranchName, relative_path: RelativeGitPath) -> Self {
Self {
branch,
relative_path,
}
}
}
impl BranchName {
fn new(value: String) -> Self {
if value.is_empty() {
return Self("HEAD".to_string());
}
Self(value)
}
fn encoded_fragment(&self) -> String {
encode_fragment_part(&self.0)
}
fn encoded_path(&self) -> String {
encode_path(&self.0)
}
}
impl RelativeGitPath {
fn from_paths(vault: &Path, file: &Path) -> Result<Option<Self>, String> {
let value = vault_relative_path_string(vault, file)?;
if value.is_empty() {
return Ok(None);
}
Ok(Some(Self(value)))
}
fn encoded_fragment(&self) -> String {
encode_fragment_part(&self.0)
}
fn encoded_path(&self) -> String {
encode_path(&self.0)
}
}
impl RemoteWebBase {
fn file_url(&self, location: &GitFileLocation) -> String {
let branch = location.branch.encoded_path();
let path = location.relative_path.encoded_path();
match self.kind {
RemoteWebKind::Bitbucket => format!("{}/src/{}/{}", self.base_url, branch, path),
RemoteWebKind::Gitea => format!("{}/src/branch/{}/{}", self.base_url, branch, path),
RemoteWebKind::GitLab => format!("{}/-/blob/{}/{}", self.base_url, branch, path),
RemoteWebKind::Generic => format!("{}/blob/{}/{}", self.base_url, branch, path),
}
}
}
impl RemoteUrl {
fn new(value: String) -> Self {
Self(value)
}
fn trimmed(&self) -> &str {
self.0.trim()
}
fn git_fragment_url(&self, location: &GitFileLocation) -> String {
format!(
"{}#{}:{}",
self.trimmed(),
location.branch.encoded_fragment(),
location.relative_path.encoded_fragment(),
)
}
fn host_and_path(&self) -> Option<RemoteParts> {
self.http_parts()
.or_else(|| self.scheme_parts())
.or_else(|| self.scp_parts())
}
fn http_parts(&self) -> Option<RemoteParts> {
let rest = self
.trimmed()
.strip_prefix("https://")
.or_else(|| self.trimmed().strip_prefix("http://"))?;
RemoteParts::from_authority_path(rest)
}
fn scheme_parts(&self) -> Option<RemoteParts> {
let rest = self
.trimmed()
.strip_prefix("ssh://")
.or_else(|| self.trimmed().strip_prefix("git://"))?;
RemoteParts::from_authority_path(rest)
}
fn scp_parts(&self) -> Option<RemoteParts> {
let (_, target) = self.trimmed().split_once('@')?;
let (host, path) = target.split_once(':')?;
Some(RemoteParts::new(RemoteHost::new(host), RepoPath::new(path)))
}
}
impl RemoteParts {
fn new(host: RemoteHost, repo_path: RepoPath) -> Self {
Self { host, repo_path }
}
fn from_authority_path(value: &str) -> Option<Self> {
let (authority, path) = value.split_once('/')?;
Some(Self::new(
RemoteHost::from_authority(authority),
RepoPath::new(path),
))
}
fn into_web_base(self) -> Option<RemoteWebBase> {
let clean_path = self.repo_path.clean();
if self.host.is_empty() {
return None;
}
if clean_path.is_empty() {
return None;
}
let base_url = format!("https://{}/{clean_path}", self.host.as_str());
Some(RemoteWebBase {
kind: remote_web_kind(&self.host),
base_url,
})
}
}
impl RemoteHost {
fn new(value: &str) -> Self {
Self(value.to_string())
}
fn from_authority(authority: &str) -> Self {
Self::new(authority.rsplit('@').next().unwrap_or_default())
}
fn as_str(&self) -> &str {
&self.0
}
fn is_empty(&self) -> bool {
self.0.is_empty()
}
fn lower(&self) -> String {
self.0.to_ascii_lowercase()
}
}
impl RepoPath {
fn new(value: &str) -> Self {
Self(value.to_string())
}
fn clean(&self) -> &str {
let trimmed = self.0.trim_matches('/');
trimmed.strip_suffix(".git").unwrap_or(trimmed)
}
}
fn remote_web_base(remote_url: &RemoteUrl) -> Option<RemoteWebBase> {
remote_url.host_and_path()?.into_web_base()
}
fn remote_web_kind(host: &RemoteHost) -> RemoteWebKind {
let lower_host = host.lower();
if lower_host.contains("gitlab") {
return RemoteWebKind::GitLab;
}
if lower_host.contains("bitbucket") {
return RemoteWebKind::Bitbucket;
}
if lower_host.contains("gitea") {
return RemoteWebKind::Gitea;
}
if lower_host.contains("forgejo") {
return RemoteWebKind::Gitea;
}
if lower_host == "codeberg.org" {
return RemoteWebKind::Gitea;
}
RemoteWebKind::Generic
}
fn encode_path(path: &str) -> String {
path.split('/')
.map(encode_segment)
.collect::<Vec<_>>()
.join("/")
}
fn encode_fragment_part(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
match byte {
b' ' => encoded.push_str("%20"),
b'#' => encoded.push_str("%23"),
b'%' => encoded.push_str("%25"),
_ => encoded.push(char::from(byte)),
}
}
encoded
}
fn encode_segment(segment: &str) -> String {
segment
.bytes()
.flat_map(|byte| {
if is_unreserved_url_byte(byte) {
vec![byte]
} else {
format!("%{byte:02X}").into_bytes()
}
})
.map(char::from)
.collect()
}
fn is_unreserved_url_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_command;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::path::Path;
struct RemoteFixture {
name: &'static str,
url: &'static str,
}
struct GitUrlCase {
remote: RemoteFixture,
note_path: &'static str,
expected_url: &'static str,
}
fn add_remote(vault: &Path, remote: RemoteFixture) {
git_command()
.args(["remote", "add", remote.name, remote.url])
.current_dir(vault)
.output()
.unwrap();
}
fn write_note(vault: &Path, relative_path: &str) -> String {
let file = vault.join(relative_path);
fs::create_dir_all(file.parent().unwrap()).unwrap();
fs::write(&file, "# Note\n").unwrap();
file.to_string_lossy().to_string()
}
fn assert_git_file_url(test_case: GitUrlCase) {
let dir = setup_git_repo();
let note = write_note(dir.path(), test_case.note_path);
add_remote(dir.path(), test_case.remote);
let url = git_file_url(dir.path().to_str().unwrap(), &note).unwrap();
assert_eq!(url.as_deref(), Some(test_case.expected_url));
}
#[test]
fn returns_none_without_remote() {
let dir = setup_git_repo();
let note = write_note(dir.path(), "note.md");
let url = git_file_url(dir.path().to_str().unwrap(), &note).unwrap();
assert_eq!(url, None);
}
#[test]
fn returns_none_outside_git_repository() {
let dir = tempfile::tempdir().unwrap();
let note = write_note(dir.path(), "note.md");
let url = git_file_url(dir.path().to_str().unwrap(), &note).unwrap();
assert_eq!(url, None);
}
#[test]
fn builds_remote_note_urls() {
[
GitUrlCase {
remote: RemoteFixture {
name: "origin",
url: "git@github.com:owner/repo.git",
},
note_path: "Notes/Project Plan.md",
expected_url: "https://github.com/owner/repo/blob/main/Notes/Project%20Plan.md",
},
GitUrlCase {
remote: RemoteFixture {
name: "origin",
url: "https://gho_secret@github.com/owner/repo.git",
},
note_path: "private.md",
expected_url: "https://github.com/owner/repo/blob/main/private.md",
},
GitUrlCase {
remote: RemoteFixture {
name: "origin",
url: "https://gitlab.com/group/repo.git",
},
note_path: "notes/topic.md",
expected_url: "https://gitlab.com/group/repo/-/blob/main/notes/topic.md",
},
GitUrlCase {
remote: RemoteFixture {
name: "upstream",
url: "https://github.com/team/vault.git",
},
note_path: "shared.md",
expected_url: "https://github.com/team/vault/blob/main/shared.md",
},
]
.into_iter()
.for_each(assert_git_file_url);
}
}

View file

@ -0,0 +1,349 @@
use super::git_command_at;
use crate::vault::path_identity::vault_relative_path_string;
use std::path::Path;
use super::GitCommit;
/// Get git log history for a specific file in the vault.
pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitCommit>, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative_str = vault_relative_path_string(vault, file)?;
let output = git_command_at(vault)
.and_then(|mut command| {
command
.args([
"log",
"--format=%H|%h|%an|%aI|%s",
"-n",
"20",
"--",
&relative_str,
])
.output()
})
.map_err(|e| format!("Failed to run git log: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// No commits yet is not an error - just return empty history
if stderr.contains("does not have any commits yet") {
return Ok(Vec::new());
}
return Err(format!("git log failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let commits = stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
// Format: hash|short_hash|author|date|message
// Use splitn(5) so message (last) can contain '|'
let parts: Vec<&str> = line.splitn(5, '|').collect();
if parts.len() != 5 {
return None;
}
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
.map(|dt| dt.timestamp())
.unwrap_or(0);
Some(GitCommit {
hash: parts[0].to_string(),
short_hash: parts[1].to_string(),
author: parts[2].to_string(),
date,
message: parts[4].to_string(),
})
})
.collect();
Ok(commits)
}
/// Get git diff for a specific file.
pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative_str = vault_relative_path_string(vault, file)?;
// First try tracked file diff
let output = git_command_at(vault)
.and_then(|mut command| command.args(["diff", "--", &relative_str]).output())
.map_err(|e| format!("Failed to run git diff: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
// If no diff (maybe staged or untracked), try diff --cached
if stdout.is_empty() {
let cached = git_command_at(vault)
.and_then(|mut command| {
command
.args(["diff", "--cached", "--", &relative_str])
.output()
})
.map_err(|e| format!("Failed to run git diff --cached: {}", e))?;
let cached_stdout = String::from_utf8_lossy(&cached.stdout).to_string();
if !cached_stdout.is_empty() {
return Ok(cached_stdout);
}
// Try showing untracked file as all-new
let status = git_command_at(vault)
.and_then(|mut command| {
command
.args(["status", "--porcelain", "--", &relative_str])
.output()
})
.map_err(|e| format!("Failed to run git status: {}", e))?;
let status_out = String::from_utf8_lossy(&status.stdout);
if status_out.starts_with("??") {
// Untracked file: show entire content as added
let content =
std::fs::read_to_string(file).map_err(|e| format!("Failed to read file: {}", e))?;
let lines: Vec<String> = content.lines().map(|l| format!("+{}", l)).collect();
return Ok(format!(
"diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}",
relative_str,
lines.len(),
lines.join("\n")
));
}
}
Ok(stdout)
}
/// Get git diff for a specific file at a given commit (compared to its parent).
pub fn get_file_diff_at_commit(
vault_path: &str,
file_path: &str,
commit_hash: &str,
) -> Result<String, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative_str = vault_relative_path_string(vault, file)?;
// Show diff between commit^ and commit for this file
let output = git_command_at(vault)
.and_then(|mut command| {
command
.args([
"diff",
&format!("{}^", commit_hash),
commit_hash,
"--",
&relative_str,
])
.output()
})
.map_err(|e| format!("Failed to run git diff: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
// If diff is empty, it might be the initial commit (no parent).
// Fall back to showing the full file content as added.
if stdout.is_empty() {
let show = git_command_at(vault)
.and_then(|mut command| {
command
.args(["show", &format!("{}:{}", commit_hash, relative_str)])
.output()
})
.map_err(|e| format!("Failed to run git show: {}", e))?;
if show.status.success() {
let content = String::from_utf8_lossy(&show.stdout);
let lines: Vec<String> = content.lines().map(|l| format!("+{}", l)).collect();
return Ok(format!(
"diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}",
relative_str,
lines.len(),
lines.join("\n")
));
}
}
Ok(stdout)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_command;
use crate::git::tests::setup_git_repo;
use std::{fs, path::PathBuf};
fn force_quoted_git_paths(vault: &Path) {
git_command()
.args(["config", "core.quotePath", "true"])
.current_dir(vault)
.output()
.unwrap();
}
fn write_and_commit_file(
vault: &Path,
relative_path: &str,
content: &str,
message: &str,
) -> PathBuf {
let file = vault.join(relative_path);
fs::write(&file, content).unwrap();
git_command()
.args(["add", relative_path])
.current_dir(vault)
.output()
.unwrap();
git_command()
.args(["commit", "-m", message])
.current_dir(vault)
.output()
.unwrap();
file
}
fn head_hash(vault: &Path) -> String {
let log = git_command()
.args(["log", "--format=%H", "-1"])
.current_dir(vault)
.output()
.unwrap();
String::from_utf8_lossy(&log.stdout).trim().to_string()
}
#[test]
fn test_get_file_history_with_commits() {
let dir = setup_git_repo();
let vault = dir.path();
let file = write_and_commit_file(vault, "test.md", "# Initial\n", "Initial commit");
write_and_commit_file(vault, "test.md", "# Updated\n\nNew content.", "Update test");
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[0].message, "Update test");
assert_eq!(history[1].message, "Initial commit");
assert_eq!(history[0].author, "Test User");
assert!(!history[0].hash.is_empty());
assert!(!history[0].short_hash.is_empty());
}
#[test]
fn test_get_file_history_no_commits() {
let dir = setup_git_repo();
let vault = dir.path();
let file = vault.join("new.md");
fs::write(&file, "# New\n").unwrap();
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
assert!(history.is_empty());
}
#[test]
fn test_get_file_diff() {
let dir = setup_git_repo();
let vault = dir.path();
let file = write_and_commit_file(
vault,
"diff-test.md",
"# Test\n\nOriginal content.",
"Add diff-test",
);
fs::write(&file, "# Test\n\nModified content.").unwrap();
let diff = get_file_diff(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
assert!(!diff.is_empty());
assert!(diff.contains("-Original content."));
assert!(diff.contains("+Modified content."));
}
#[test]
fn test_get_file_diff_at_commit() {
let dir = setup_git_repo();
let vault = dir.path();
let file = write_and_commit_file(
vault,
"diff-at-commit.md",
"# First\n\nOriginal content.",
"First commit",
);
write_and_commit_file(
vault,
"diff-at-commit.md",
"# First\n\nModified content.",
"Second commit",
);
let hash = head_hash(vault);
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
.unwrap();
assert!(!diff.is_empty());
assert!(diff.contains("-Original content."));
assert!(diff.contains("+Modified content."));
}
#[test]
fn test_get_file_diff_at_initial_commit() {
let dir = setup_git_repo();
let vault = dir.path();
let file = write_and_commit_file(
vault,
"initial.md",
"# Initial\n\nHello world.",
"Initial commit",
);
let hash = head_hash(vault);
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
.unwrap();
assert!(!diff.is_empty());
assert!(diff.contains("+# Initial"));
assert!(diff.contains("+Hello world."));
}
#[test]
fn test_get_file_diff_at_commit_preserves_chinese_filename_and_content() {
let dir = setup_git_repo();
let vault = dir.path();
let relative_path = "中文笔记.md";
let file = vault.join(relative_path);
force_quoted_git_paths(vault);
write_and_commit_file(
vault,
relative_path,
"# 初始\n\n第一行\n",
"Add Chinese note",
);
write_and_commit_file(
vault,
relative_path,
"# 初始\n\n第二行\n",
"Update Chinese note",
);
let hash = head_hash(vault);
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
.unwrap();
assert!(diff.contains("diff --git a/中文笔记.md b/中文笔记.md"));
assert!(diff.contains("-第一行"));
assert!(diff.contains("+第二行"));
assert!(!diff.contains("\\344"));
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,443 @@
use serde::Serialize;
use std::ffi::OsString;
use std::process::{Command, Output};
use crate::settings::{normalize_git_provider, Settings};
pub(super) const NATIVE_PROVIDER: &str = "native";
pub(super) const WSL_PROVIDER: &str = "wsl";
#[derive(Debug, Clone, Copy)]
struct ProbeIdentity {
provider: &'static str,
label: &'static str,
}
const NATIVE_GIT_IDENTITY: ProbeIdentity = ProbeIdentity {
provider: NATIVE_PROVIDER,
label: "Native Git",
};
const WSL_GIT_IDENTITY: ProbeIdentity = ProbeIdentity {
provider: WSL_PROVIDER,
label: "WSL2 Git",
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum GitProviderSelection {
Native,
Wsl { distro: Option<String> },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitProviderProbe {
pub provider: String,
pub label: String,
pub available: bool,
pub version: Option<String>,
pub distro: Option<String>,
pub path: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitProviderStatus {
pub selected_provider: String,
pub selected_wsl_distro: Option<String>,
pub native: GitProviderProbe,
pub wsl_distributions: Vec<GitProviderProbe>,
}
impl GitProviderSelection {
pub(super) fn from_settings(settings: Option<&Settings>) -> Self {
let provider =
settings.and_then(|settings| normalize_git_provider(settings.git_provider.as_deref()));
if provider.as_deref() == Some(WSL_PROVIDER) && wsl_supported_on_this_platform() {
return Self::Wsl {
distro: settings.and_then(|settings| settings.git_wsl_distro.clone()),
};
}
Self::Native
}
pub(super) fn provider_id(&self) -> &'static str {
match self {
Self::Native => NATIVE_PROVIDER,
Self::Wsl { .. } => WSL_PROVIDER,
}
}
}
pub(super) fn wsl_git_prefix_args(distro: Option<&str>) -> Vec<OsString> {
let mut args = Vec::new();
if let Some(distro) = distro.map(str::trim).filter(|distro| !distro.is_empty()) {
args.push(OsString::from("--distribution"));
args.push(OsString::from(distro));
}
args.push(OsString::from("--exec"));
args.push(OsString::from("git"));
args
}
pub(super) fn selected_git_path_argument(
path: &str,
settings: Option<&Settings>,
) -> Result<String, String> {
match GitProviderSelection::from_settings(settings) {
GitProviderSelection::Wsl { .. } => windows_path_to_wsl_path(path).ok_or_else(|| {
format!("The selected WSL Git provider cannot translate '{path}' to a WSL path.")
}),
GitProviderSelection::Native => Ok(path.to_string()),
}
}
pub fn git_provider_status() -> GitProviderStatus {
let settings = crate::settings::get_settings().ok();
let selection = GitProviderSelection::from_settings(settings.as_ref());
GitProviderStatus {
selected_provider: selection.provider_id().to_string(),
selected_wsl_distro: settings.and_then(|settings| settings.git_wsl_distro),
native: native_git_probe(),
wsl_distributions: wsl_git_probes(),
}
}
pub fn test_git_provider(
provider: &str,
distro: Option<&str>,
vault_path: Option<&str>,
) -> GitProviderProbe {
match normalize_git_provider(Some(provider)).as_deref() {
Some(WSL_PROVIDER) => wsl_git_probe(distro, vault_path),
_ => native_git_probe(),
}
}
fn native_git_probe() -> GitProviderProbe {
let output = Command::new("git").arg("--version").output();
match output {
Ok(output) if output.status.success() => available_probe(
NATIVE_GIT_IDENTITY,
None,
None,
version_from_output(&output),
),
Ok(output) => unavailable_probe(NATIVE_GIT_IDENTITY, None, native_failure_message(&output)),
Err(err) => unavailable_probe(
NATIVE_GIT_IDENTITY,
None,
format!("Native Git is unavailable: {err}"),
),
}
}
fn wsl_git_probes() -> Vec<GitProviderProbe> {
match wsl_distribution_names() {
Ok(distributions) if !distributions.is_empty() => distributions
.into_iter()
.map(|distro| wsl_git_probe(Some(&distro), None))
.collect(),
Ok(_) => vec![unavailable_probe(
WSL_GIT_IDENTITY,
None,
"WSL is installed, but no distributions are configured.".to_string(),
)],
Err(message) => vec![unavailable_probe(WSL_GIT_IDENTITY, None, message)],
}
}
fn wsl_git_probe(distro: Option<&str>, vault_path: Option<&str>) -> GitProviderProbe {
if !wsl_supported_on_this_platform() {
return unavailable_probe(
WSL_GIT_IDENTITY,
distro.map(ToOwned::to_owned),
"WSL2 Git is only available on Windows.".to_string(),
);
}
let translated_vault_path = match vault_path
.and_then(|path| (!path.trim().is_empty()).then(|| windows_path_to_wsl_path(path)))
{
Some(Some(path)) => Some(path),
Some(None) => {
return unavailable_probe(
WSL_GIT_IDENTITY,
distro.map(ToOwned::to_owned),
"The selected vault path cannot be translated to WSL.".to_string(),
);
}
None => None,
};
let output = wsl_git_version_command(distro, translated_vault_path.as_deref()).output();
match output {
Ok(output) if output.status.success() => available_probe(
WSL_GIT_IDENTITY,
distro.map(ToOwned::to_owned),
translated_vault_path,
version_from_output(&output),
),
Ok(output) => unavailable_probe(
WSL_GIT_IDENTITY,
distro.map(ToOwned::to_owned),
native_failure_message(&output),
),
Err(err) => unavailable_probe(
WSL_GIT_IDENTITY,
distro.map(ToOwned::to_owned),
format!("WSL2 Git is unavailable: {err}"),
),
}
}
fn available_probe(
identity: ProbeIdentity,
distro: Option<String>,
path: Option<String>,
version: Option<String>,
) -> GitProviderProbe {
let message = version
.as_deref()
.map(|version| format!("{} is available: {version}", identity.label))
.unwrap_or_else(|| format!("{} is available.", identity.label));
GitProviderProbe {
provider: identity.provider.to_string(),
label: identity.label.to_string(),
available: true,
version,
distro,
path,
message,
}
}
fn unavailable_probe(
identity: ProbeIdentity,
distro: Option<String>,
message: String,
) -> GitProviderProbe {
GitProviderProbe {
provider: identity.provider.to_string(),
label: identity.label.to_string(),
available: false,
version: None,
distro,
path: None,
message,
}
}
fn native_failure_message(output: &Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stderr.is_empty() {
return stderr;
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !stdout.is_empty() {
return stdout;
}
format!("Git exited with status {}", output.status)
}
fn version_from_output(output: &Output) -> Option<String> {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(ToOwned::to_owned)
}
#[cfg(target_os = "windows")]
fn wsl_supported_on_this_platform() -> bool {
true
}
#[cfg(not(target_os = "windows"))]
fn wsl_supported_on_this_platform() -> bool {
false
}
#[cfg(target_os = "windows")]
fn wsl_distribution_names() -> Result<Vec<String>, String> {
let output = Command::new("wsl.exe")
.args(["--list", "--quiet"])
.output()
.map_err(|err| format!("WSL is unavailable: {err}"))?;
if !output.status.success() {
return Err(native_failure_message(&output));
}
Ok(parse_wsl_distribution_names(&output.stdout))
}
#[cfg(not(target_os = "windows"))]
fn wsl_distribution_names() -> Result<Vec<String>, String> {
Err("WSL2 Git is only available on Windows.".to_string())
}
#[cfg(target_os = "windows")]
fn wsl_git_version_command(distro: Option<&str>, translated_vault_path: Option<&str>) -> Command {
let mut command = Command::new("wsl.exe");
if let Some(distro) = distro.map(str::trim).filter(|distro| !distro.is_empty()) {
command.args(["--distribution", distro]);
}
if let Some(path) = translated_vault_path {
command.args(["--cd", path]);
}
command.args(["--exec", "git", "--version"]);
command
}
#[cfg(not(target_os = "windows"))]
fn wsl_git_version_command(_distro: Option<&str>, _translated_vault_path: Option<&str>) -> Command {
Command::new("wsl.exe")
}
#[cfg(any(target_os = "windows", test))]
fn parse_wsl_distribution_names(output: &[u8]) -> Vec<String> {
decode_wsl_output(output)
.lines()
.map(|line| line.trim().trim_end_matches('\r').to_string())
.filter(|line| !line.is_empty())
.collect()
}
#[cfg(any(target_os = "windows", test))]
fn decode_wsl_output(output: &[u8]) -> String {
if output.len() >= 2 && output.chunks_exact(2).any(|chunk| chunk[1] == 0) {
let units = output
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect::<Vec<_>>();
return String::from_utf16_lossy(&units).replace('\0', "");
}
String::from_utf8_lossy(output).replace('\0', "")
}
fn windows_path_to_wsl_path(path: &str) -> Option<String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with('/') {
return Some(trimmed.to_string());
}
let normalized = trimmed.replace('\\', "/");
if let Some(path) = drive_path_to_wsl_path(&normalized) {
return Some(path);
}
wsl_unc_path_to_linux_path(&normalized)
}
fn drive_path_to_wsl_path(path: &str) -> Option<String> {
let bytes = path.as_bytes();
if bytes.len() < 3 {
return None;
}
if bytes[1] != b':' {
return None;
}
if bytes[2] != b'/' {
return None;
}
let drive = bytes[0] as char;
if !drive.is_ascii_alphabetic() {
return None;
}
Some(format!(
"/mnt/{}/{}",
drive.to_ascii_lowercase(),
&path[3..]
))
}
fn wsl_unc_path_to_linux_path(path: &str) -> Option<String> {
for prefix in ["//wsl$/", "//wsl.localhost/"] {
if let Some(rest) = path.strip_prefix(prefix) {
let (_, linux_path) = rest.split_once('/')?;
return Some(format!("/{linux_path}"));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_utf8_wsl_distribution_names() {
assert_eq!(
parse_wsl_distribution_names(b"Ubuntu\r\nDebian\r\n"),
vec!["Ubuntu", "Debian"]
);
}
#[test]
fn parses_utf16_wsl_distribution_names() {
let encoded = "Ubuntu\r\nDebian\r\n"
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>();
assert_eq!(
parse_wsl_distribution_names(&encoded),
vec!["Ubuntu", "Debian"]
);
}
#[test]
fn translates_windows_drive_paths_for_wsl() {
assert_eq!(
windows_path_to_wsl_path(r"C:\Users\Luca\Vault").as_deref(),
Some("/mnt/c/Users/Luca/Vault")
);
assert_eq!(
windows_path_to_wsl_path("D:/Work/Tolaria").as_deref(),
Some("/mnt/d/Work/Tolaria")
);
}
#[test]
fn translates_wsl_unc_paths_for_wsl() {
assert_eq!(
windows_path_to_wsl_path(r"\\wsl$\Ubuntu\home\luca\vault").as_deref(),
Some("/home/luca/vault")
);
assert_eq!(
windows_path_to_wsl_path(r"\\wsl.localhost\Debian\var\repo").as_deref(),
Some("/var/repo")
);
}
#[test]
fn rejects_untranslatable_relative_paths() {
assert_eq!(windows_path_to_wsl_path("notes/vault"), None);
assert_eq!(windows_path_to_wsl_path(""), None);
}
#[test]
fn builds_wsl_git_prefix_args() {
assert_eq!(
wsl_git_prefix_args(Some("Ubuntu"))
.into_iter()
.map(|arg| arg.to_string_lossy().to_string())
.collect::<Vec<_>>(),
vec!["--distribution", "Ubuntu", "--exec", "git"]
);
}
}

View file

@ -0,0 +1,599 @@
use serde::Serialize;
use std::path::Path;
use super::{git_command_at, parse_github_repo_path};
#[derive(Debug, Serialize, Clone)]
pub struct PulseFile {
pub path: String,
pub status: String,
pub title: String,
}
#[derive(Debug, Serialize, Clone)]
pub struct PulseCommit {
pub hash: String,
#[serde(rename = "shortHash")]
pub short_hash: String,
pub message: String,
pub date: i64,
#[serde(rename = "githubUrl")]
pub github_url: Option<String>,
pub files: Vec<PulseFile>,
pub added: usize,
pub modified: usize,
pub deleted: usize,
}
#[derive(Debug, Serialize, Clone)]
pub struct LastCommitInfo {
#[serde(rename = "shortHash")]
pub short_hash: String,
#[serde(rename = "commitUrl")]
pub commit_url: Option<String>,
}
#[derive(Clone, Copy)]
struct CommitHash<'a>(&'a str);
#[derive(Clone, Copy)]
struct GitLogLine<'a>(&'a str);
#[derive(Clone, Copy)]
struct GitLogOutput<'a>(&'a str);
#[derive(Clone, Copy)]
struct GitStatusCode<'a>(&'a str);
struct GitHubBaseUrl(String);
#[derive(Clone, Copy, Eq, PartialEq)]
enum FileChangeStatus {
Added,
Modified,
Deleted,
}
#[derive(Clone, Copy)]
struct VaultRelativePath<'a>(&'a str);
impl<'a> CommitHash<'a> {
fn as_str(self) -> &'a str {
self.0
}
}
impl FileChangeStatus {
fn as_str(self) -> &'static str {
match self {
FileChangeStatus::Added => "added",
FileChangeStatus::Modified => "modified",
FileChangeStatus::Deleted => "deleted",
}
}
}
impl GitHubBaseUrl {
fn commit_url(&self, hash: CommitHash<'_>) -> String {
format!("{}/commit/{}", self.0, hash.as_str())
}
}
impl<'a> GitLogLine<'a> {
fn as_str(self) -> &'a str {
self.0
}
fn is_empty(self) -> bool {
self.0.is_empty()
}
}
impl<'a> GitLogOutput<'a> {
fn lines(self) -> impl Iterator<Item = GitLogLine<'a>> {
self.0.lines().map(GitLogLine)
}
}
fn title_from_path(path: VaultRelativePath<'_>) -> String {
path.0
.rsplit('/')
.next()
.unwrap_or(path.0)
.strip_suffix(".md")
.unwrap_or(path.0)
.replace('-', " ")
}
fn parse_file_status(code: GitStatusCode<'_>) -> FileChangeStatus {
match code.0 {
"A" => FileChangeStatus::Added,
"M" => FileChangeStatus::Modified,
"D" => FileChangeStatus::Deleted,
_ => FileChangeStatus::Modified,
}
}
/// Get the pulse (commit activity feed) for a vault, showing only .md file changes.
/// `skip` offsets into the commit list for pagination; `limit` caps how many to return.
pub fn get_vault_pulse(
vault_path: impl AsRef<Path>,
limit: usize,
skip: usize,
) -> Result<Vec<PulseCommit>, String> {
let vault = vault_path.as_ref();
if !vault.join(".git").exists() {
return Err("Not a git repository".to_string());
}
let limit_str = limit.to_string();
let skip_str = skip.to_string();
let output = git_command_at(vault)
.and_then(|mut command| {
command
.args([
"log",
"--name-status",
"--pretty=format:%H|%h|%s|%aI",
"--diff-filter=ADM",
"-n",
&limit_str,
"--skip",
&skip_str,
"--",
"*.md",
])
.output()
})
.map_err(|e| format!("Failed to run git log: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("does not have any commits yet") {
return Ok(Vec::new());
}
return Err(format!("git log failed: {}", stderr));
}
let github_base = get_github_base_url(vault);
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_pulse_output(
GitLogOutput(stdout.as_ref()),
github_base.as_ref(),
))
}
fn get_github_base_url(vault: &Path) -> Option<GitHubBaseUrl> {
let output = git_command_at(vault)
.and_then(|mut command| command.args(["remote", "get-url", "origin"]).output())
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let repo_path = parse_github_repo_path(&url)?;
Some(GitHubBaseUrl(format!("https://github.com/{}", repo_path)))
}
fn parse_pulse_output(
stdout: GitLogOutput<'_>,
github_base: Option<&GitHubBaseUrl>,
) -> Vec<PulseCommit> {
let mut commits: Vec<PulseCommit> = Vec::new();
let mut current: Option<PulseCommit> = None;
for line in stdout.lines() {
if line.is_empty() {
continue;
}
if is_commit_header(line) {
push_current_commit(&mut commits, &mut current);
current = parse_commit_header(line, github_base);
continue;
}
if let Some(ref mut commit) = current {
add_file_change(commit, line);
}
}
push_current_commit(&mut commits, &mut current);
commits
}
fn is_git_status_line(line: GitLogLine<'_>) -> bool {
let line = line.as_str();
line.starts_with(|c: char| {
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
})
}
fn is_commit_header(line: GitLogLine<'_>) -> bool {
line.as_str().contains('|') && !is_git_status_line(line)
}
fn push_current_commit(commits: &mut Vec<PulseCommit>, current: &mut Option<PulseCommit>) {
if let Some(commit) = current.take() {
commits.push(commit);
}
}
fn parse_commit_header(
line: GitLogLine<'_>,
github_base: Option<&GitHubBaseUrl>,
) -> Option<PulseCommit> {
let parts: Vec<&str> = line.as_str().splitn(4, '|').collect();
if parts.len() != 4 {
return None;
}
let hash = CommitHash(parts[0]);
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
.map(|dt| dt.timestamp())
.unwrap_or(0);
let github_url = github_base.map(|base| base.commit_url(hash));
Some(PulseCommit {
hash: hash.as_str().to_string(),
short_hash: parts[1].to_string(),
message: parts[2].to_string(),
date,
github_url,
files: Vec::new(),
added: 0,
modified: 0,
deleted: 0,
})
}
fn add_file_change(commit: &mut PulseCommit, line: GitLogLine<'_>) {
let file_parts: Vec<&str> = line.as_str().splitn(2, '\t').collect();
if file_parts.len() != 2 {
return;
}
let status = parse_file_status(GitStatusCode(file_parts[0].trim()));
let path = file_parts[1].trim();
match status {
FileChangeStatus::Added => commit.added += 1,
FileChangeStatus::Deleted => commit.deleted += 1,
_ => commit.modified += 1,
}
commit.files.push(PulseFile {
path: path.to_string(),
status: status.as_str().to_string(),
title: title_from_path(VaultRelativePath(path)),
});
}
/// Get the last commit's short hash and a GitHub URL (if remote is GitHub).
pub fn get_last_commit_info(
vault_path: impl AsRef<Path>,
) -> Result<Option<LastCommitInfo>, String> {
let vault = vault_path.as_ref();
let output = git_command_at(vault)
.and_then(|mut command| command.args(["log", "-1", "--format=%H|%h"]).output())
.map_err(|e| format!("Failed to run git log: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("does not have any commits yet") {
return Ok(None);
}
return Err(format!("git log failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.trim();
if line.is_empty() {
return Ok(None);
}
let parts: Vec<&str> = line.splitn(2, '|').collect();
if parts.len() != 2 {
return Ok(None);
}
let full_hash = parts[0];
let short_hash = parts[1].to_string();
let commit_url = get_github_commit_url(vault, CommitHash(full_hash));
Ok(Some(LastCommitInfo {
short_hash,
commit_url,
}))
}
/// Try to build a GitHub commit URL from the origin remote URL.
fn get_github_commit_url(vault: &Path, full_hash: CommitHash<'_>) -> Option<String> {
get_github_base_url(vault).map(|base| base.commit_url(full_hash))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_commit;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
#[derive(Clone, Copy)]
enum GitHubRemote {
OwnerRepo,
LaputaVault,
}
enum NoteRepoChange {
ConfigOnlyCommit,
NoteUpdateCommit,
}
enum CommitUrlSource {
Pulse,
LastCommitInfo,
}
impl GitHubRemote {
fn url(&self) -> &'static str {
match self {
GitHubRemote::OwnerRepo => "https://github.com/owner/repo.git",
GitHubRemote::LaputaVault => "https://github.com/lucaong/laputa-vault.git",
}
}
fn commit_prefix(&self) -> &'static str {
match self {
GitHubRemote::OwnerRepo => "https://github.com/owner/repo/commit/",
GitHubRemote::LaputaVault => "https://github.com/lucaong/laputa-vault/commit/",
}
}
}
impl NoteRepoChange {
fn apply(self, dir: &TempDir) {
let vault = dir.path();
let vp = vault_path(dir);
match self {
NoteRepoChange::ConfigOnlyCommit => {
fs::write(vault.join("config.json"), "{}").unwrap();
git_commit(vp, "Add config").unwrap();
}
NoteRepoChange::NoteUpdateCommit => {
fs::write(vault.join("note.md"), "# Updated\n").unwrap();
git_commit(vp, "Update note").unwrap();
}
}
}
}
fn vault_path(dir: &TempDir) -> &str {
dir.path().to_str().unwrap()
}
fn repo_with_committed_note() -> TempDir {
let dir = setup_git_repo();
fs::write(dir.path().join("note.md"), "# Note\n").unwrap();
git_commit(vault_path(&dir), "Add note").unwrap();
dir
}
fn add_origin_remote(vault: &Path, remote: GitHubRemote) {
Command::new("git")
.args(["remote", "add", "origin", remote.url()])
.current_dir(vault)
.output()
.unwrap();
}
fn pulse_after_note_repo_change(change: NoteRepoChange) -> Vec<PulseCommit> {
let dir = repo_with_committed_note();
change.apply(&dir);
get_vault_pulse(vault_path(&dir), 30, 0).unwrap()
}
fn commit_url_for(source: CommitUrlSource, remote: GitHubRemote) -> String {
let dir = repo_with_committed_note();
add_origin_remote(dir.path(), remote);
match source {
CommitUrlSource::Pulse => get_vault_pulse(vault_path(&dir), 30, 0).unwrap()[0]
.github_url
.clone()
.unwrap(),
CommitUrlSource::LastCommitInfo => get_last_commit_info(vault_path(&dir))
.unwrap()
.unwrap()
.commit_url
.unwrap(),
}
}
#[test]
fn test_get_vault_pulse_with_commits() {
let dir = repo_with_committed_note();
let vault = dir.path();
let vp = vault_path(&dir);
fs::write(vault.join("project.md"), "# Project\n").unwrap();
git_commit(vp, "Add project").unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert_eq!(pulse.len(), 2);
assert_eq!(pulse[0].message, "Add project");
assert_eq!(pulse[1].message, "Add note");
assert_eq!(pulse[0].files.len(), 1);
assert_eq!(pulse[0].files[0].path, "project.md");
assert_eq!(pulse[0].files[0].status, "added");
assert_eq!(pulse[0].added, 1);
assert_eq!(pulse[0].modified, 0);
assert!(!pulse[0].short_hash.is_empty());
}
#[test]
fn test_get_vault_pulse_no_git() {
let dir = TempDir::new().unwrap();
let vp = dir.path().to_str().unwrap();
let result = get_vault_pulse(vp, 30, 0);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Not a git repository"));
}
#[test]
fn test_get_vault_pulse_empty_repo() {
let dir = setup_git_repo();
let vp = vault_path(&dir);
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert!(pulse.is_empty());
}
#[test]
fn test_get_vault_pulse_only_md_files() {
let pulse = pulse_after_note_repo_change(NoteRepoChange::ConfigOnlyCommit);
assert_eq!(pulse.len(), 1);
assert_eq!(pulse[0].files.len(), 1);
assert_eq!(pulse[0].files[0].path, "note.md");
}
#[test]
fn test_get_vault_pulse_respects_limit() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault_path(&dir);
for i in 0..5 {
fs::write(
vault.join(format!("note{}.md", i)),
format!("# Note {}\n", i),
)
.unwrap();
git_commit(vp, &format!("Add note {}", i)).unwrap();
}
let pulse = get_vault_pulse(vp, 3, 0).unwrap();
assert_eq!(pulse.len(), 3);
}
#[test]
fn test_get_vault_pulse_modified_and_deleted() {
let pulse = pulse_after_note_repo_change(NoteRepoChange::NoteUpdateCommit);
assert_eq!(pulse[0].message, "Update note");
assert_eq!(pulse[0].files[0].status, "modified");
assert_eq!(pulse[0].modified, 1);
}
#[test]
fn test_get_vault_pulse_github_url() {
let remote = GitHubRemote::OwnerRepo;
let url = commit_url_for(CommitUrlSource::Pulse, remote);
assert!(url.starts_with(remote.commit_prefix()));
}
#[test]
fn test_get_vault_pulse_no_github_url_without_remote() {
let dir = repo_with_committed_note();
let vp = vault_path(&dir);
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert!(pulse[0].github_url.is_none());
}
#[test]
fn test_title_from_path() {
assert_eq!(
title_from_path(VaultRelativePath("note/my-project.md")),
"my project"
);
assert_eq!(title_from_path(VaultRelativePath("simple.md")), "simple");
assert_eq!(
title_from_path(VaultRelativePath("deep/nested/file.md")),
"file"
);
}
#[test]
fn test_parse_pulse_output_basic() {
let stdout =
"abc123|abc123d|Add notes|2026-03-05T10:00:00+01:00\nA\tnote.md\nM\tproject.md\n";
let commits = parse_pulse_output(GitLogOutput(stdout), None);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].message, "Add notes");
assert_eq!(commits[0].files.len(), 2);
assert_eq!(commits[0].files[0].status, "added");
assert_eq!(commits[0].files[1].status, "modified");
assert_eq!(commits[0].added, 1);
assert_eq!(commits[0].modified, 1);
assert!(commits[0].github_url.is_none());
}
#[test]
fn test_parse_pulse_output_with_github() {
let stdout = "abc123|abc123d|Msg|2026-03-05T10:00:00+01:00\nA\tnote.md\n";
let base = GitHubBaseUrl("https://github.com/o/r".to_string());
let commits = parse_pulse_output(GitLogOutput(stdout), Some(&base));
assert_eq!(
commits[0].github_url.as_deref(),
Some("https://github.com/o/r/commit/abc123")
);
}
#[test]
fn test_parse_pulse_output_multiple_commits() {
let stdout = "aaa|aaa1234|First|2026-03-05T10:00:00+01:00\nA\ta.md\n\nbbb|bbb1234|Second|2026-03-04T10:00:00+01:00\nM\tb.md\nD\tc.md\n";
let commits = parse_pulse_output(GitLogOutput(stdout), None);
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].message, "First");
assert_eq!(commits[1].message, "Second");
assert_eq!(commits[1].files.len(), 2);
assert_eq!(commits[1].deleted, 1);
}
#[test]
fn test_get_last_commit_info_with_commit() {
let dir = repo_with_committed_note();
let vp = vault_path(&dir);
let info = get_last_commit_info(vp).unwrap();
assert!(info.is_some());
let info = info.unwrap();
assert_eq!(info.short_hash.len(), 7);
assert!(info.commit_url.is_none());
}
#[test]
fn test_get_last_commit_info_no_commits() {
let dir = setup_git_repo();
let vp = vault_path(&dir);
let info = get_last_commit_info(vp).unwrap();
assert!(info.is_none());
}
#[test]
fn test_get_last_commit_info_with_github_remote() {
let remote = GitHubRemote::LaputaVault;
let url = commit_url_for(CommitUrlSource::LastCommitInfo, remote);
assert!(url.starts_with(remote.commit_prefix()));
}
}

View file

@ -0,0 +1,594 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use super::command::{git_output, stderr_text, stdout_text};
use super::conflict::get_conflict_files;
use super::remote_config::has_configured_remote;
use super::upstream::{missing_upstream_message, sync_target};
const NO_REMOTE_STATUS: &str = "no_remote";
const NO_REMOTE_MESSAGE: &str = "No remote configured";
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GitPullResult {
pub status: String, // "up_to_date" | "updated" | "conflict" | "no_remote" | "error"
pub message: String,
#[serde(rename = "updatedFiles")]
pub updated_files: Vec<String>,
#[serde(rename = "conflictFiles")]
pub conflict_files: Vec<String>,
}
/// Check whether the vault repo has at least one remote configured.
pub fn has_remote(vault_path: impl AsRef<Path>) -> Result<bool, String> {
let vault = vault_path.as_ref();
has_configured_remote(vault)
}
/// Pull latest changes from remote. Uses --no-rebase to merge.
/// Returns a structured result with status and affected files.
pub fn git_pull(vault_path: impl AsRef<Path>) -> Result<GitPullResult, String> {
let vault = vault_path.as_ref();
if !has_remote(vault)? {
return Ok(GitPullResult {
status: NO_REMOTE_STATUS.to_string(),
message: NO_REMOTE_MESSAGE.to_string(),
updated_files: vec![],
conflict_files: vec![],
});
}
let target = match sync_target(vault)? {
Some(target) => target,
None => {
return Ok(GitPullResult {
status: "error".to_string(),
message: missing_upstream_message(vault)?,
updated_files: vec![],
conflict_files: vec![],
});
}
};
let output = git_output(
vault,
&["pull", "--no-rebase", &target.remote, &target.branch],
)
.map_err(|e| format!("Failed to run git pull: {}", e))?;
let stdout = stdout_text(&output);
let stderr = stderr_text(&output);
if output.status.success() {
if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
return Ok(GitPullResult {
status: "up_to_date".to_string(),
message: "Already up to date".to_string(),
updated_files: vec![],
conflict_files: vec![],
});
}
let updated = parse_updated_files(&stdout);
return Ok(GitPullResult {
status: "updated".to_string(),
message: format!("{} file(s) updated", updated.len()),
updated_files: updated,
conflict_files: vec![],
});
}
// Check for merge conflicts
let vault_text = vault.to_string_lossy();
let conflicts = get_conflict_files(vault_text.as_ref()).unwrap_or_default();
if !conflicts.is_empty() {
return Ok(GitPullResult {
status: "conflict".to_string(),
message: format!("Merge conflict in {} file(s)", conflicts.len()),
updated_files: vec![],
conflict_files: conflicts,
});
}
// Network error or other failure — report as error
let detail = if stderr.trim().is_empty() {
stdout.trim().to_string()
} else {
stderr.trim().to_string()
};
Ok(GitPullResult {
status: "error".to_string(),
message: detail,
updated_files: vec![],
conflict_files: vec![],
})
}
/// Parse `git pull` output to extract updated file paths.
fn parse_updated_files(stdout: &str) -> Vec<String> {
stdout
.lines()
.filter_map(|line| {
let trimmed = line.trim();
// Lines like " path/to/file.md | 5 ++-" in diffstat
if trimmed.contains('|') {
let path = trimmed.split('|').next()?.trim();
if !path.is_empty() {
return Some(path.to_string());
}
}
None
})
.collect()
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GitPushResult {
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "no_remote" | "error"
pub message: String,
}
#[derive(Clone, Copy)]
enum PushStatus {
Rejected,
AuthError,
NetworkError,
NoRemote,
Error,
}
impl PushStatus {
fn as_str(self) -> &'static str {
match self {
Self::Rejected => "rejected",
Self::AuthError => "auth_error",
Self::NetworkError => "network_error",
Self::NoRemote => "no_remote",
Self::Error => "error",
}
}
}
/// Classify a git push stderr message into a user-friendly status and message.
pub fn classify_push_error(stderr: impl AsRef<str>) -> GitPushResult {
let stderr = stderr.as_ref();
let lower = stderr.to_lowercase();
if is_rejected_push_error(&lower) {
return push_error(
PushStatus::Rejected,
"Push rejected: remote has new commits. Pull first, then push.",
);
}
if is_auth_push_error(&lower) {
return push_error(
PushStatus::AuthError,
"Push failed: authentication error. Check your credentials.",
);
}
if is_network_push_error(&lower) {
return push_error(
PushStatus::NetworkError,
"Push failed: network error. Check your connection and try again.",
);
}
if is_no_remote_push_error(&lower) {
return push_error(PushStatus::NoRemote, "No remote configured");
}
push_error(
PushStatus::Error,
format!("Push failed: {}", push_error_detail(stderr)),
)
}
fn push_error(status: PushStatus, message: impl Into<String>) -> GitPushResult {
GitPushResult {
status: status.as_str().to_string(),
message: message.into(),
}
}
fn contains_any(haystack: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| haystack.contains(needle))
}
fn is_rejected_push_error(lower: impl AsRef<str>) -> bool {
let lower = lower.as_ref();
if contains_any(lower, &["non-fast-forward", "[rejected]", "fetch first"]) {
return true;
}
if !lower.contains("failed to push some refs") {
return false;
}
contains_any(lower, &["updates were rejected", "non-fast-forward"])
}
fn is_auth_push_error(lower: impl AsRef<str>) -> bool {
contains_any(
lower.as_ref(),
&[
"authentication failed",
"could not read username",
"permission denied",
"403",
"invalid credentials",
],
)
}
fn is_network_push_error(lower: impl AsRef<str>) -> bool {
contains_any(
lower.as_ref(),
&[
"could not resolve host",
"unable to access",
"connection refused",
"network is unreachable",
"timed out",
],
)
}
fn is_no_remote_push_error(lower: impl AsRef<str>) -> bool {
contains_any(
lower.as_ref(),
&[
"no configured push destination",
"does not appear to be a git repository",
"no such remote",
"no upstream branch",
],
)
}
fn push_error_detail(stderr: &str) -> String {
let hint_line = stderr
.lines()
.find(|line| line.trim_start().starts_with("hint:"))
.map(|line| {
line.trim_start()
.strip_prefix("hint:")
.unwrap_or(line)
.trim()
})
.unwrap_or("")
.to_string();
if hint_line.is_empty() {
stderr.trim().to_string()
} else {
hint_line
}
}
/// Push to remote.
pub fn git_push(vault_path: impl AsRef<Path>) -> Result<GitPushResult, String> {
let vault = vault_path.as_ref();
if !has_remote(vault)? {
return Ok(GitPushResult {
status: NO_REMOTE_STATUS.to_string(),
message: NO_REMOTE_MESSAGE.to_string(),
});
}
let target = match sync_target(vault)? {
Some(target) => target,
None => {
return Ok(GitPushResult {
status: "error".to_string(),
message: missing_upstream_message(vault)?,
});
}
};
let push_refspec = format!("HEAD:refs/heads/{}", target.branch);
let output = git_output(vault, &["push", &target.remote, &push_refspec])
.map_err(|e| format!("Failed to run git push: {}", e))?;
if !output.status.success() {
let stderr = stderr_text(&output);
return Ok(classify_push_error(&stderr));
}
Ok(GitPushResult {
status: "ok".to_string(),
message: "Pushed to remote".to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_command;
use crate::git::git_commit;
use crate::git::tests::{setup_git_repo, setup_remote_pair};
use std::fs;
use tempfile::TempDir;
struct RemotePair {
_bare: TempDir,
clone_a: TempDir,
clone_b: TempDir,
}
impl RemotePair {
fn new() -> Self {
let (_bare, clone_a, clone_b) = setup_remote_pair();
Self {
_bare,
clone_a,
clone_b,
}
}
fn seeded() -> Self {
let pair = Self::new();
commit_default_note(pair.clone_a.path());
git_push(pair.vault_a()).unwrap();
pair
}
fn vault_a(&self) -> &str {
path_text(self.clone_a.path())
}
fn vault_b(&self) -> &str {
path_text(self.clone_b.path())
}
fn sync_b(&self) {
git_pull(self.vault_b()).unwrap();
}
fn update_a_note(&self) {
fs::write(self.clone_a.path().join("note.md"), "# Updated\n").unwrap();
git_commit(self.vault_a(), "update").unwrap();
}
fn push_a(&self) {
git_push(self.vault_a()).unwrap();
}
}
fn path_text(path: &Path) -> &str {
path.to_str().unwrap()
}
fn local_repo_with_note() -> TempDir {
let dir = setup_git_repo();
commit_default_note(dir.path());
dir
}
fn commit_default_note(vault_path: &Path) {
fs::write(vault_path.join("note.md"), "# Note\n").unwrap();
git_commit(vault_path.to_str().unwrap(), "initial").unwrap();
}
#[test]
fn test_has_remote_returns_false_for_local_repo() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = path_text(vault);
assert!(!has_remote(vp).unwrap());
}
#[test]
fn test_has_remote_returns_true_when_remote_exists() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = path_text(vault);
git_command()
.args(["remote", "add", "origin", "https://example.com/repo.git"])
.current_dir(vault)
.output()
.unwrap();
assert!(has_remote(vp).unwrap());
}
#[test]
fn test_has_remote_ignores_name_only_remote_without_url() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = path_text(vault);
git_command()
.args(["config", "remote.origin.prune", "true"])
.current_dir(vault)
.output()
.unwrap();
let remote_names = git_command()
.args(["remote"])
.current_dir(vault)
.output()
.unwrap();
assert!(String::from_utf8_lossy(&remote_names.stdout).contains("origin"));
assert!(!has_remote(vp).unwrap());
}
#[test]
fn test_git_pull_no_remote_returns_no_remote() {
let dir = local_repo_with_note();
let vp = path_text(dir.path());
let result = git_pull(vp).unwrap();
assert_eq!(result.status, "no_remote");
assert!(result.updated_files.is_empty());
assert!(result.conflict_files.is_empty());
}
#[test]
fn test_git_pull_up_to_date() {
let pair = RemotePair::seeded();
let result = git_pull(pair.vault_a()).unwrap();
assert_eq!(result.status, "up_to_date");
}
#[test]
fn test_git_pull_updated_files() {
let pair = RemotePair::seeded();
pair.sync_b();
pair.update_a_note();
pair.push_a();
let result = git_pull(pair.vault_b()).unwrap();
assert_eq!(result.status, "updated");
assert!(result.conflict_files.is_empty());
}
#[test]
fn test_parse_updated_files_diffstat() {
let stdout =
" Fast-forward\n note.md | 2 +-\n project/plan.md | 4 ++--\n 2 files changed\n";
let files = parse_updated_files(stdout);
assert_eq!(files, vec!["note.md", "project/plan.md"]);
}
#[test]
fn test_parse_updated_files_empty() {
let stdout = "Already up to date.\n";
let files = parse_updated_files(stdout);
assert!(files.is_empty());
}
#[test]
fn test_classify_push_error_non_fast_forward() {
let stderr = r#"To github.com:user/repo.git
! [rejected] main -> main (non-fast-forward)
error: failed to push some refs to 'github.com:user/repo.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally."#;
let result = classify_push_error(stderr);
assert_eq!(result.status, "rejected");
assert!(result.message.contains("Pull first"));
}
#[test]
fn test_classify_push_error_fetch_first() {
let stderr = "error: failed to push some refs\nhint: Updates were rejected because the tip of your current branch is behind\nhint: its remote counterpart. Integrate the remote changes (e.g.\nhint: 'git pull ...') before pushing again.\nhint: See the 'Note about fast-forwards' in 'git push --help' for details.\n ! [rejected] main -> main (fetch first)\n";
let result = classify_push_error(stderr);
assert_eq!(result.status, "rejected");
}
#[test]
fn test_classify_push_error_auth_failure() {
let stderr = "remote: Permission denied to user/repo.git\nfatal: unable to access 'https://github.com/user/repo.git/': The requested URL returned error: 403";
let result = classify_push_error(stderr);
assert_eq!(result.status, "auth_error");
assert!(result.message.contains("authentication"));
}
#[test]
fn test_classify_push_error_network() {
let stderr = "fatal: unable to access 'https://github.com/user/repo.git/': Could not resolve host: github.com";
let result = classify_push_error(stderr);
assert_eq!(result.status, "network_error");
assert!(result.message.contains("network"));
}
#[test]
fn test_classify_push_error_no_remote() {
let stderr = "fatal: No configured push destination.";
let result = classify_push_error(stderr);
assert_eq!(result.status, "no_remote");
assert!(result.message.contains("No remote"));
}
#[test]
fn test_classify_push_error_unknown() {
let stderr = "error: something unexpected happened\nhint: Try again later";
let result = classify_push_error(stderr);
assert_eq!(result.status, "error");
assert!(result.message.contains("Try again later"));
}
#[test]
fn test_classify_push_error_unknown_no_hint() {
let stderr = "error: something totally weird";
let result = classify_push_error(stderr);
assert_eq!(result.status, "error");
assert!(result.message.contains("something totally weird"));
}
#[test]
fn test_git_push_result_serialization() {
let result = GitPushResult {
status: "rejected".to_string(),
message: "Push rejected".to_string(),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"rejected\""));
let parsed: GitPushResult = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.status, "rejected");
}
#[test]
fn test_git_push_success_returns_ok() {
let pair = RemotePair::new();
commit_default_note(pair.clone_a.path());
let result = git_push(pair.vault_a()).unwrap();
assert_eq!(result.status, "ok");
}
#[test]
fn test_git_push_no_remote_returns_no_remote() {
let dir = local_repo_with_note();
let vp = path_text(dir.path());
let result = git_push(vp).unwrap();
assert_eq!(result.status, "no_remote");
}
#[test]
fn test_git_push_rejected_returns_rejected() {
let pair = RemotePair::new();
let vp_a = pair.vault_a();
let vp_b = pair.vault_b();
// Both clones commit and push — second push should be rejected
fs::write(pair.clone_a.path().join("note.md"), "# A\n").unwrap();
git_commit(vp_a, "from A").unwrap();
git_push(vp_a).unwrap();
git_pull(vp_b).unwrap();
fs::write(pair.clone_b.path().join("note.md"), "# B\n").unwrap();
git_commit(vp_b, "from B").unwrap();
git_push(vp_b).unwrap();
// Now A has a new commit but hasn't pulled B's changes
fs::write(pair.clone_a.path().join("other.md"), "# Other\n").unwrap();
git_commit(vp_a, "from A again").unwrap();
let result = git_push(vp_a).unwrap();
assert_eq!(result.status, "rejected");
assert!(result.message.contains("Pull first"));
}
#[test]
fn test_git_pull_result_serialization() {
let result = GitPullResult {
status: "updated".to_string(),
message: "2 file(s) updated".to_string(),
updated_files: vec!["note.md".to_string()],
conflict_files: vec![],
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"updatedFiles\""));
assert!(json.contains("\"conflictFiles\""));
let parsed: GitPullResult = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.status, "updated");
assert_eq!(parsed.updated_files.len(), 1);
}
}

View file

@ -0,0 +1,106 @@
use std::fs;
use std::path::Path;
use super::git_command;
use super::tests::setup_remote_pair;
use super::{git_commit, git_pull, git_push, git_remote_status};
use tempfile::TempDir;
struct RemotePair {
_bare: TempDir,
clone_a: TempDir,
clone_b: TempDir,
}
impl RemotePair {
fn seeded() -> Self {
let (_bare, clone_a, clone_b) = setup_remote_pair();
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
git_commit(path_text(clone_a.path()), "initial").unwrap();
git_push(path_text(clone_a.path())).unwrap();
Self {
_bare,
clone_a,
clone_b,
}
}
fn vault_a(&self) -> &str {
path_text(self.clone_a.path())
}
fn vault_b(&self) -> &str {
path_text(self.clone_b.path())
}
}
fn path_text(path: &Path) -> &str {
path.to_str().unwrap()
}
fn run_git(vault_path: &Path, args: &[&str]) {
let output = git_command()
.args(args)
.current_dir(vault_path)
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn git_pull_and_push_use_configured_upstream_branch() {
let pair = RemotePair::seeded();
run_git(pair.clone_a.path(), &["checkout", "-b", "local-draft"]);
run_git(
pair.clone_a.path(),
&["push", "-u", "origin", "HEAD:refs/heads/review-target"],
);
run_git(pair.clone_b.path(), &["fetch", "origin"]);
run_git(
pair.clone_b.path(),
&["checkout", "-b", "review-copy", "origin/review-target"],
);
fs::write(
pair.clone_a.path().join("branch-note.md"),
"# Branch note\n",
)
.unwrap();
git_commit(pair.vault_a(), "branch update").unwrap();
let push = git_push(pair.vault_a()).unwrap();
assert_eq!(push.status, "ok");
let pull = git_pull(pair.vault_b()).unwrap();
assert_eq!(pull.status, "updated");
assert_eq!(
fs::read_to_string(pair.clone_b.path().join("branch-note.md")).unwrap(),
"# Branch note\n"
);
}
#[test]
fn git_remote_status_reports_missing_upstream() {
let pair = RemotePair::seeded();
run_git(pair.clone_a.path(), &["checkout", "-b", "local-only"]);
let status = git_remote_status(pair.vault_a()).unwrap();
assert!(status.has_remote);
assert_eq!(status.branch, "local-only");
assert!(!status.has_upstream);
assert_eq!(status.upstream, None);
let pull = git_pull(pair.vault_a()).unwrap();
assert_eq!(pull.status, "error");
assert!(pull.message.contains("No upstream branch configured"));
let push = git_push(pair.vault_a()).unwrap();
assert_eq!(push.status, "error");
assert!(push.message.contains("No upstream branch configured"));
}

View file

@ -0,0 +1,88 @@
use std::path::Path;
use super::command::{git_output, stderr_or_failure, stdout_lines};
const DEFAULT_FETCH_REFSPEC: &str = "+refs/heads/*:refs/remotes/origin/*";
const REMOTE_URL_CONFIG_PATTERN: &str = r"^remote\..*\.url$";
const ORIGIN_URL_CONFIG_KEY: &str = "remote.origin.url";
const ORIGIN_FETCH_CONFIG_KEY: &str = "remote.origin.fetch";
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConfiguredRemote {
name: String,
url: String,
}
pub(super) fn has_configured_remote(vault: &Path) -> Result<bool, String> {
Ok(!list_configured_remotes(vault)?.is_empty())
}
pub(super) fn list_configured_remotes(vault: &Path) -> Result<Vec<String>, String> {
Ok(list_configured_remote_urls(vault)?
.into_iter()
.map(|remote| remote.name)
.collect())
}
pub(super) fn primary_remote_url(vault: &Path) -> Result<Option<String>, String> {
let remotes = list_configured_remote_urls(vault)?;
Ok(remotes
.iter()
.find(|remote| remote.name == "origin")
.or_else(|| remotes.first())
.map(|remote| remote.url.clone()))
}
fn list_configured_remote_urls(vault: &Path) -> Result<Vec<ConfiguredRemote>, String> {
let output = git_output(
vault,
&["config", "--get-regexp", REMOTE_URL_CONFIG_PATTERN],
)
.map_err(|e| format!("Failed to inspect git remotes: {e}"))?;
if output.status.code() == Some(1) {
return Ok(Vec::new());
}
if !output.status.success() {
return Err(stderr_or_failure("git config --get-regexp", &output));
}
Ok(stdout_lines(&output)
.into_iter()
.filter_map(|line| remote_from_url_config(&line))
.collect())
}
fn remote_from_url_config(line: &str) -> Option<ConfiguredRemote> {
let (key, value) = line.split_once(' ')?;
let url = value.trim();
if url.is_empty() {
return None;
}
let name = key
.strip_prefix("remote.")
.and_then(|name| name.strip_suffix(".url"))
.filter(|name| !name.is_empty())
.map(ToString::to_string)?;
Some(ConfiguredRemote {
name,
url: url.to_string(),
})
}
pub(super) fn configure_origin_remote(vault: &Path, remote_url: &str) -> Result<(), String> {
run_git_config(vault, ORIGIN_URL_CONFIG_KEY, remote_url)?;
run_git_config(vault, ORIGIN_FETCH_CONFIG_KEY, DEFAULT_FETCH_REFSPEC)
}
fn run_git_config(vault: &Path, key: &str, value: &str) -> Result<(), String> {
let output = git_output(vault, &["config", "--local", "--replace-all", key, value])
.map_err(|e| format!("Failed to run git config: {e}"))?;
if output.status.success() {
return Ok(());
}
Err(stderr_or_failure("git config", &output))
}

View file

@ -0,0 +1,223 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use super::command::{git_output, git_output_result, stdout_text};
use super::remote_config::has_configured_remote;
use super::upstream::{branch_label, sync_target};
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GitRemoteStatus {
pub branch: String,
pub ahead: u32,
pub behind: u32,
#[serde(rename = "hasRemote")]
pub has_remote: bool,
#[serde(rename = "hasUpstream")]
pub has_upstream: bool,
pub upstream: Option<String>,
}
/// Get the current branch name, and how many commits ahead/behind the upstream.
pub fn git_remote_status(vault_path: impl AsRef<Path>) -> Result<GitRemoteStatus, String> {
let vault = vault_path.as_ref();
let branch = branch_label(vault)?;
if !has_configured_remote(vault)? {
return Ok(status_without_remote(branch));
}
// Fetch latest remote refs (silent, best-effort)
let _ = git_output(vault, &["fetch", "--quiet"]);
let Some(target) = sync_target(vault)? else {
return Ok(status_without_upstream(branch));
};
let output = git_output_result(
vault,
&[
"rev-list",
"--left-right",
"--count",
&format!("HEAD...{}", target.display),
],
)?;
let (ahead, behind) = if output.status.success() {
parse_ahead_behind(&stdout_text(&output))
} else {
(0, 0)
};
Ok(status_with_upstream(branch, target.display, ahead, behind))
}
fn status_without_remote(branch: String) -> GitRemoteStatus {
GitRemoteStatus {
branch,
ahead: 0,
behind: 0,
has_remote: false,
has_upstream: false,
upstream: None,
}
}
fn status_without_upstream(branch: String) -> GitRemoteStatus {
GitRemoteStatus {
branch,
ahead: 0,
behind: 0,
has_remote: true,
has_upstream: false,
upstream: None,
}
}
fn status_with_upstream(
branch: String,
upstream: String,
ahead: u32,
behind: u32,
) -> GitRemoteStatus {
GitRemoteStatus {
branch,
ahead,
behind,
has_remote: true,
has_upstream: true,
upstream: Some(upstream),
}
}
fn parse_ahead_behind(stdout: &str) -> (u32, u32) {
let parts: Vec<&str> = stdout.split('\t').collect();
let ahead = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
let behind = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
(ahead, behind)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::tests::{setup_git_repo, setup_remote_pair};
use crate::git::{git_commit, git_pull, git_push};
use std::fs;
use std::path::Path;
use tempfile::TempDir;
struct RemotePair {
_bare: TempDir,
clone_a: TempDir,
clone_b: TempDir,
}
impl RemotePair {
fn seeded() -> Self {
let (_bare, clone_a, clone_b) = setup_remote_pair();
commit_default_note(clone_a.path());
git_push(path_text(clone_a.path())).unwrap();
Self {
_bare,
clone_a,
clone_b,
}
}
fn vault_a(&self) -> &str {
path_text(self.clone_a.path())
}
fn sync_b(&self) {
git_pull(path_text(self.clone_b.path())).unwrap();
}
fn update_a_note(&self) {
fs::write(self.clone_a.path().join("note.md"), "# Updated\n").unwrap();
git_commit(self.vault_a(), "update").unwrap();
}
fn update_b_note(&self) {
fs::write(self.clone_b.path().join("note.md"), "# B update\n").unwrap();
git_commit(path_text(self.clone_b.path()), "from B").unwrap();
}
fn push_b(&self) {
git_push(path_text(self.clone_b.path())).unwrap();
}
}
fn path_text(path: &Path) -> &str {
path.to_str().unwrap()
}
fn local_repo_with_note() -> TempDir {
let dir = setup_git_repo();
commit_default_note(dir.path());
dir
}
fn commit_default_note(vault_path: &Path) {
fs::write(vault_path.join("note.md"), "# Note\n").unwrap();
git_commit(path_text(vault_path), "initial").unwrap();
}
#[test]
fn git_remote_status_no_remote() {
let dir = local_repo_with_note();
let status = git_remote_status(path_text(dir.path())).unwrap();
assert!(!status.has_remote);
assert_eq!(status.ahead, 0);
assert_eq!(status.behind, 0);
}
#[test]
fn git_remote_status_up_to_date() {
let pair = RemotePair::seeded();
let status = git_remote_status(pair.vault_a()).unwrap();
assert!(status.has_remote);
assert!(status.has_upstream);
assert_eq!(status.upstream.as_deref(), Some("origin/main"));
assert_eq!(status.ahead, 0);
assert_eq!(status.behind, 0);
}
#[test]
fn git_remote_status_ahead() {
let pair = RemotePair::seeded();
pair.update_a_note();
let status = git_remote_status(pair.vault_a()).unwrap();
assert_eq!(status.ahead, 1);
assert_eq!(status.behind, 0);
}
#[test]
fn git_remote_status_behind() {
let pair = RemotePair::seeded();
pair.sync_b();
pair.update_b_note();
pair.push_b();
let status = git_remote_status(pair.vault_a()).unwrap();
assert_eq!(status.behind, 1);
assert_eq!(status.ahead, 0);
}
#[test]
fn git_remote_status_serialization() {
let status = GitRemoteStatus {
branch: "main".to_string(),
ahead: 2,
behind: 1,
has_remote: true,
has_upstream: true,
upstream: Some("origin/main".to_string()),
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("\"hasRemote\""));
assert!(json.contains("\"hasUpstream\""));
let parsed: GitRemoteStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.branch, "main");
assert_eq!(parsed.ahead, 2);
assert_eq!(parsed.upstream.as_deref(), Some("origin/main"));
}
}

View file

@ -0,0 +1,132 @@
const ALLOWED_REMOTE_URL_MESSAGE: &str =
"Repository URL must start with https://, http://, ssh://, or git@host:path.";
const HIERARCHICAL_REMOTE_SCHEMES: [&str; 3] = ["https://", "http://", "ssh://"];
pub(crate) fn validate_user_remote_url(remote_url: &str) -> Result<&str, String> {
let trimmed = remote_url.trim();
if let Some(message) = invalid_remote_url_message(trimmed) {
return Err(message.to_string());
}
if is_supported_remote_url(trimmed) {
return Ok(trimmed);
}
Err(ALLOWED_REMOTE_URL_MESSAGE.to_string())
}
fn invalid_remote_url_message(remote_url: &str) -> Option<&'static str> {
if remote_url.is_empty() {
return Some("Enter a repository URL before continuing.");
}
if remote_url.starts_with('-') {
return Some("Repository URL cannot start with '-'.");
}
if contains_unsafe_url_character(remote_url) {
return Some(ALLOWED_REMOTE_URL_MESSAGE);
}
None
}
fn contains_unsafe_url_character(remote_url: &str) -> bool {
remote_url
.chars()
.any(|ch| ch.is_control() || ch.is_whitespace())
}
fn is_supported_remote_url(remote_url: &str) -> bool {
HIERARCHICAL_REMOTE_SCHEMES
.iter()
.any(|scheme| is_hierarchical_remote_url(remote_url, scheme))
|| is_git_scp_remote_url(remote_url)
}
fn is_hierarchical_remote_url(url: &str, scheme: &str) -> bool {
let Some(rest) = strip_ascii_prefix(url, scheme) else {
return false;
};
let Some((authority, path)) = rest.split_once('/') else {
return false;
};
has_valid_remote_host(authority) && !path.is_empty()
}
fn strip_ascii_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
value
.get(..prefix.len())
.filter(|candidate| candidate.eq_ignore_ascii_case(prefix))
.map(|_| &value[prefix.len()..])
}
fn is_git_scp_remote_url(url: &str) -> bool {
let Some(rest) = url.strip_prefix("git@") else {
return false;
};
let Some((host, path)) = rest.split_once(':') else {
return false;
};
has_valid_remote_host(host) && !path.is_empty()
}
fn has_valid_remote_host(authority: &str) -> bool {
let host = host_from_authority(authority);
!host.is_empty() && !host.starts_with('-')
}
fn host_from_authority(authority: &str) -> &str {
let host = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
host.split_once(':').map_or(host, |(host, _)| host)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_user_remote_url_accepts_supported_remote_forms() {
for url in [
"https://github.com/refactoringhq/tolaria.git",
"http://git.example.test/org/repo.git",
"ssh://git@git.example.test/org/repo.git",
"git@github.com:refactoringhq/tolaria.git",
] {
assert_eq!(validate_user_remote_url(url).unwrap(), url);
}
}
#[test]
fn validate_user_remote_url_trims_supported_urls() {
assert_eq!(
validate_user_remote_url(" https://github.com/refactoringhq/tolaria.git ").unwrap(),
"https://github.com/refactoringhq/tolaria.git"
);
}
#[test]
fn validate_user_remote_url_rejects_dangerous_or_unsupported_inputs() {
for url in [
"",
"--upload-pack=touch-pwned",
"ext::sh -c touch-pwned %0.git",
"file:///Users/luca/private.git",
"/Users/luca/private.git",
"github.com:refactoringhq/tolaria.git",
"git@-oProxyCommand=touch-pwned:repo.git",
"https://",
"ssh://git@example.com",
"https://github.com/refactoringhq/tolaria with space.git",
] {
assert!(validate_user_remote_url(url).is_err(), "{url}");
}
}
}

View file

@ -0,0 +1,708 @@
use super::git_command_at;
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Serialize, Clone)]
pub struct ModifiedFile {
pub path: String,
#[serde(rename = "relativePath")]
pub relative_path: String,
pub status: String,
#[serde(rename = "addedLines")]
pub added_lines: Option<usize>,
#[serde(rename = "deletedLines")]
pub deleted_lines: Option<usize>,
pub binary: bool,
}
#[derive(Debug, Clone, Copy, Default)]
struct DiffStats {
added_lines: Option<usize>,
deleted_lines: Option<usize>,
binary: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct StatusEntry {
status_code: String,
relative_path: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileChangeStatus {
Modified,
Added,
Deleted,
Untracked,
Renamed,
}
impl FileChangeStatus {
fn from_code(status_code: &str) -> Self {
match status_code.trim() {
"A" => Self::Added,
"D" => Self::Deleted,
"??" => Self::Untracked,
"R" | "RM" => Self::Renamed,
_ => Self::Modified,
}
}
fn label(self) -> &'static str {
match self {
Self::Added => "added",
Self::Deleted => "deleted",
Self::Untracked => "untracked",
Self::Renamed => "renamed",
Self::Modified => "modified",
}
}
}
fn split_nul_fields(output: &[u8]) -> Vec<String> {
output
.split(|byte| *byte == 0)
.filter(|field| !field.is_empty())
.map(|field| String::from_utf8_lossy(field).into_owned())
.collect()
}
fn status_has_source_path(status_code: &str) -> bool {
status_code.contains('R') || status_code.contains('C')
}
fn parse_status_field(field: &str) -> Option<StatusEntry> {
if field.len() < 4 {
return None;
}
Some(StatusEntry {
status_code: field[..2].to_string(),
relative_path: field[3..].to_string(),
})
}
fn parse_status_output(output: &[u8]) -> Vec<StatusEntry> {
let fields = split_nul_fields(output);
let mut entries = Vec::new();
let mut index = 0;
while index < fields.len() {
let Some(entry) = parse_status_field(&fields[index]) else {
index += 1;
continue;
};
let has_source_path = status_has_source_path(&entry.status_code);
entries.push(entry);
index += if has_source_path { 2 } else { 1 };
}
entries
}
fn parse_numstat_field(field: &str) -> Option<usize> {
field.parse().ok()
}
fn parse_numstat_header(header: &str) -> Option<(Option<String>, DiffStats)> {
let mut parts = header.splitn(3, '\t');
let added = parts.next()?;
let deleted = parts.next()?;
let path = parts.next()?;
let added_lines = parse_numstat_field(added);
let deleted_lines = parse_numstat_field(deleted);
let binary = added == "-" || deleted == "-";
Some((
(!path.is_empty()).then(|| path.to_string()),
DiffStats {
added_lines,
deleted_lines,
binary,
},
))
}
fn parse_numstat_output(output: &[u8]) -> HashMap<String, DiffStats> {
let fields = split_nul_fields(output);
let mut stats = HashMap::new();
let mut index = 0;
while index < fields.len() {
let Some((path, diff_stats)) = parse_numstat_header(&fields[index]) else {
index += 1;
continue;
};
match path {
Some(path) => {
stats.insert(path, diff_stats);
index += 1;
}
None if index + 2 < fields.len() => {
stats.insert(fields[index + 2].clone(), diff_stats);
index += 3;
}
None => {
index += 1;
}
}
}
stats
}
fn repo_has_head(vault: &Path) -> Result<bool, String> {
let output = git_command_at(vault)
.and_then(|mut command| command.args(["rev-parse", "--verify", "HEAD"]).output())
.map_err(|e| format!("Failed to run git rev-parse: {e}"))?;
Ok(output.status.success())
}
fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
if !repo_has_head(vault)? {
return Ok(HashMap::new());
}
let output = git_command_at(vault)
.and_then(|mut command| {
command
.args(["diff", "--numstat", "-z", "--find-renames", "HEAD", "--"])
.output()
})
.map_err(|e| format!("Failed to run git diff --numstat: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git diff --numstat failed: {}", stderr.trim()));
}
Ok(parse_numstat_output(&output.stdout))
}
fn count_worktree_lines(vault: &Path, relative_path: &Path) -> DiffStats {
let full_path = vault.join(relative_path);
let added_lines = std::fs::read_to_string(full_path)
.ok()
.map(|content| content.lines().count());
DiffStats {
added_lines,
deleted_lines: None,
binary: false,
}
}
fn resolve_diff_stats(
vault: &Path,
relative_path: &Path,
status: FileChangeStatus,
diff_stats: &HashMap<String, DiffStats>,
include_stats: bool,
) -> DiffStats {
if !include_stats {
return DiffStats::default();
}
if status == FileChangeStatus::Untracked {
return count_worktree_lines(vault, relative_path);
}
let key = relative_path.to_string_lossy();
diff_stats.get(key.as_ref()).copied().unwrap_or_default()
}
fn ensure_path_within_vault(vault: &Path, relative_path: &Path, abs: &Path) -> Result<(), String> {
for component in relative_path.components() {
if matches!(component, std::path::Component::ParentDir) {
return Err("File path is outside the vault".into());
}
}
if !abs.exists() {
return Ok(());
}
let canonical_vault = vault
.canonicalize()
.map_err(|e| format!("Cannot resolve vault path: {e}"))?;
let canonical_file = abs
.canonicalize()
.map_err(|e| format!("Cannot resolve file path: {e}"))?;
if canonical_file.starts_with(&canonical_vault) {
Ok(())
} else {
Err("File path is outside the vault".into())
}
}
fn load_file_status(vault: &Path, relative_path: &Path) -> Result<String, String> {
let output = git_command_at(vault)
.and_then(|mut command| {
command
.args(["status", "--porcelain", "--"])
.arg(relative_path)
.output()
})
.map_err(|e| format!("Failed to run git status: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
.lines()
.find(|line| line.len() >= 4)
.map(|line| line[..2].trim().to_string())
.unwrap_or_default())
}
fn restore_tracked_file(vault: &Path, relative_path: &Path) -> Result<(), String> {
let _ = git_command_at(vault).and_then(|mut command| {
command
.args(["reset", "HEAD", "--"])
.arg(relative_path)
.output()
});
let checkout = git_command_at(vault)
.and_then(|mut command| command.args(["checkout", "--"]).arg(relative_path).output())
.map_err(|e| format!("Failed to run git checkout: {e}"))?;
if checkout.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&checkout.stderr);
Err(format!("git checkout failed: {}", stderr.trim()))
}
/// Get list of modified/added/deleted files in the vault (uncommitted changes).
pub fn get_modified_files(vault_path: impl AsRef<Path>) -> Result<Vec<ModifiedFile>, String> {
get_modified_files_impl(vault_path.as_ref(), false)
}
/// Get list of modified/added/deleted files with line-level diff statistics.
pub fn get_modified_files_with_stats(
vault_path: impl AsRef<Path>,
) -> Result<Vec<ModifiedFile>, String> {
get_modified_files_impl(vault_path.as_ref(), true)
}
fn get_modified_files_impl(vault: &Path, include_stats: bool) -> Result<Vec<ModifiedFile>, String> {
if !super::is_inside_work_tree(vault) {
return Ok(Vec::new());
}
let output = git_command_at(vault)
.and_then(|mut command| {
command
.args(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
.output()
})
.map_err(|e| format!("Failed to run git status: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git status failed: {}", stderr.trim()));
}
let diff_stats = if include_stats {
load_diff_stats(vault)?
} else {
HashMap::new()
};
let files = parse_status_output(&output.stdout)
.into_iter()
.filter_map(|entry| {
// Only include markdown files
if !entry.relative_path.ends_with(".md") {
return None;
}
let status = FileChangeStatus::from_code(&entry.status_code);
let full_path = vault
.join(&entry.relative_path)
.to_string_lossy()
.to_string();
let stats = resolve_diff_stats(
vault,
Path::new(&entry.relative_path),
status,
&diff_stats,
include_stats,
);
Some(ModifiedFile {
path: full_path,
relative_path: entry.relative_path,
status: status.label().to_string(),
added_lines: stats.added_lines,
deleted_lines: stats.deleted_lines,
binary: stats.binary,
})
})
.collect();
Ok(files)
}
/// Discard uncommitted changes to a single file.
///
/// - **Modified / Deleted**: `git checkout -- <file>` restores the last committed version.
/// - **Untracked / Added**: the file is removed from disk.
///
/// The `relative_path` must be relative to `vault_path` (the same format
/// returned by [`get_modified_files`]).
pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
let relative = Path::new(relative_path);
let abs = vault.join(relative);
ensure_path_within_vault(vault, relative, &abs)?;
let status_code = load_file_status(vault, relative)?;
match status_code.as_str() {
"??" => {
std::fs::remove_file(&abs)
.map_err(|e| format!("Failed to delete untracked file: {e}"))?;
}
_ => {
restore_tracked_file(vault, relative)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_command;
use crate::git::git_commit;
use crate::git::tests::setup_git_repo;
use std::fs;
fn write_and_commit_markdown(vault: &Path, vp: &str, relative_path: &str, content: &str) {
fs::write(vault.join(relative_path), content).unwrap();
git_commit(vp, "initial").unwrap();
}
fn force_quoted_git_paths(vault: &Path) {
git_command()
.args(["config", "core.quotePath", "true"])
.current_dir(vault)
.output()
.unwrap();
}
fn expect_modified_file(vp: &str, relative_path: &str, status: &str) -> ModifiedFile {
let modified = get_modified_files_with_stats(vp).unwrap();
let file = modified
.iter()
.find(|file| file.relative_path == relative_path)
.unwrap_or_else(|| panic!("{relative_path} should be reported as {status}"));
assert_eq!(file.status, status);
assert!(file.path.ends_with(relative_path));
file.clone()
}
fn expect_changed_file_after(
relative_path: &str,
status: &str,
change: impl FnOnce(&Path, &str),
) -> ModifiedFile {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
change(vault, vp);
expect_modified_file(vp, relative_path, status)
}
#[test]
fn test_get_modified_files_returns_empty_for_gitless_folder() {
let dir = tempfile::TempDir::new().unwrap();
fs::write(dir.path().join("note.md"), "# Note\n").unwrap();
assert!(get_modified_files(dir.path()).unwrap().is_empty());
assert!(get_modified_files_with_stats(dir.path())
.unwrap()
.is_empty());
}
#[test]
fn test_get_modified_files_with_stats() {
let dir = setup_git_repo();
let vault = dir.path();
// Create and commit a file
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_command()
.args(["add", "note.md"])
.current_dir(vault)
.output()
.unwrap();
git_command()
.args(["commit", "-m", "Add note"])
.current_dir(vault)
.output()
.unwrap();
// Modify it
fs::write(vault.join("note.md"), "# Note\n\nUpdated.").unwrap();
// Add an untracked file
fs::write(vault.join("new.md"), "# New\n").unwrap();
let modified = get_modified_files_with_stats(vault.to_str().unwrap()).unwrap();
assert!(modified.len() >= 2);
let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect();
assert!(statuses.contains(&"modified"));
assert!(statuses.contains(&"untracked"));
let modified_entry = modified
.iter()
.find(|file| file.relative_path == "note.md")
.unwrap();
assert!(modified_entry.added_lines.is_some());
assert!(!modified_entry.binary);
let untracked_entry = modified
.iter()
.find(|file| file.relative_path == "new.md")
.unwrap();
assert_eq!(untracked_entry.added_lines, Some(1));
assert_eq!(untracked_entry.deleted_lines, None);
}
#[test]
fn test_get_modified_files_omits_stats_by_default() {
let dir = setup_git_repo();
let vault = dir.path();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_command()
.args(["add", "note.md"])
.current_dir(vault)
.output()
.unwrap();
git_command()
.args(["commit", "-m", "Add note"])
.current_dir(vault)
.output()
.unwrap();
fs::write(vault.join("note.md"), "# Note\n\nUpdated.").unwrap();
fs::write(vault.join("new.md"), "# New\n").unwrap();
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
assert!(modified.len() >= 2);
assert!(modified.iter().all(|file| file.added_lines.is_none()
&& file.deleted_lines.is_none()
&& !file.binary));
}
#[test]
fn test_get_modified_files_untracked_in_subdirectory() {
let dir = setup_git_repo();
let vault = dir.path();
// Create initial commit so git is initialized
fs::write(vault.join("init.md"), "# Init\n").unwrap();
git_command()
.args(["add", "init.md"])
.current_dir(vault)
.output()
.unwrap();
git_command()
.args(["commit", "-m", "Initial"])
.current_dir(vault)
.output()
.unwrap();
// Create a new untracked file in a subdirectory (simulates new note creation)
fs::create_dir_all(vault.join("note")).unwrap();
fs::write(vault.join("note/brand-new.md"), "# Brand New\n").unwrap();
let modified = get_modified_files_with_stats(vault.to_str().unwrap()).unwrap();
assert_eq!(modified.len(), 1);
assert_eq!(modified[0].status, "untracked");
assert_eq!(modified[0].relative_path, "note/brand-new.md");
assert_eq!(modified[0].added_lines, Some(1));
assert!(
modified[0].path.ends_with("/note/brand-new.md"),
"Full path should end with relative path: {}",
modified[0].path
);
}
#[test]
fn test_get_modified_files_preserves_chinese_markdown_path() {
let relative_path = "中文笔记.md";
let file = expect_changed_file_after(relative_path, "modified", |vault, vp| {
force_quoted_git_paths(vault);
write_and_commit_markdown(vault, vp, relative_path, "# 初始\n");
fs::write(vault.join(relative_path), "# 初始\n\n更新\n").unwrap();
});
assert_eq!(file.added_lines, Some(2));
}
#[test]
fn test_get_modified_files_preserves_untracked_markdown_path_with_spaces() {
let relative_path = "test note.md";
let file = expect_changed_file_after(relative_path, "untracked", |vault, vp| {
write_and_commit_markdown(vault, vp, "init.md", "# Init\n");
fs::write(vault.join(relative_path), "# Test\n").unwrap();
});
assert_eq!(file.added_lines, Some(1));
}
#[test]
fn test_get_modified_files_preserves_modified_markdown_path_with_spaces() {
let relative_path = "test note.md";
let file = expect_changed_file_after(relative_path, "modified", |vault, vp| {
write_and_commit_markdown(vault, vp, relative_path, "# Test\n");
fs::write(vault.join(relative_path), "# Test\n\nUpdated\n").unwrap();
});
assert_eq!(file.added_lines, Some(2));
}
#[test]
fn test_get_modified_files_preserves_renamed_markdown_path_with_spaces() {
let relative_path = "test note.md";
let file = expect_changed_file_after(relative_path, "renamed", |vault, vp| {
write_and_commit_markdown(vault, vp, "alpha.md", "# Alpha\n");
git_command()
.args(["mv", "alpha.md", relative_path])
.current_dir(vault)
.output()
.unwrap();
});
assert_eq!(file.added_lines, Some(0));
assert_eq!(file.deleted_lines, Some(0));
}
#[test]
fn test_commit_flow_modified_files_then_commit_clears() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
// Create and commit initial file
fs::write(vault.join("flow.md"), "# Original\n").unwrap();
git_commit(vp, "initial").unwrap();
// Modify the file on disk
fs::write(vault.join("flow.md"), "# Modified\n").unwrap();
// get_modified_files should detect the change
let modified = get_modified_files(vp).unwrap();
assert!(
modified.iter().any(|f| f.relative_path == "flow.md"),
"Modified file should be detected after write"
);
// Commit the change
let result = git_commit(vp, "update flow").unwrap();
assert!(
result.contains("1 file changed") || result.contains("flow.md"),
"Commit output should reference the changed file: {}",
result
);
// After commit, get_modified_files should return empty
let after = get_modified_files(vp).unwrap();
assert!(
after.is_empty(),
"No modified files should remain after commit, found: {:?}",
after
);
}
#[test]
fn test_discard_modified_file() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
write_and_commit_markdown(vault, vp, "note.md", "# Original\n");
// Modify the file
fs::write(vault.join("note.md"), "# Changed\n").unwrap();
assert_eq!(get_modified_files(vp).unwrap().len(), 1);
// Discard
discard_file_changes(vp, "note.md").unwrap();
let content = fs::read_to_string(vault.join("note.md")).unwrap();
assert_eq!(content, "# Original\n");
assert!(get_modified_files(vp).unwrap().is_empty());
}
#[test]
fn test_discard_untracked_file() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
write_and_commit_markdown(vault, vp, "init.md", "# Init\n");
// Create an untracked file
fs::write(vault.join("new.md"), "# New\n").unwrap();
assert!(vault.join("new.md").exists());
discard_file_changes(vp, "new.md").unwrap();
assert!(!vault.join("new.md").exists());
assert!(get_modified_files(vp).unwrap().is_empty());
}
#[test]
fn test_discard_deleted_file() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
write_and_commit_markdown(vault, vp, "note.md", "# Original\n");
// Delete the file
fs::remove_file(vault.join("note.md")).unwrap();
assert!(!vault.join("note.md").exists());
discard_file_changes(vp, "note.md").unwrap();
assert!(vault.join("note.md").exists());
let content = fs::read_to_string(vault.join("note.md")).unwrap();
assert_eq!(content, "# Original\n");
}
#[test]
fn test_discard_rejects_path_outside_vault() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
write_and_commit_markdown(vault, vp, "init.md", "# Init\n");
let result = discard_file_changes(vp, "../../../etc/passwd");
assert!(
result.is_err(),
"Should reject path outside vault, got: {:?}",
result
);
assert!(
result.unwrap_err().contains("outside the vault"),
"Error should mention 'outside the vault'"
);
}
}

View file

@ -0,0 +1,77 @@
use std::path::Path;
use super::command::{git_output, git_output_result, stdout_text};
const DETACHED_HEAD_BRANCH: &str = "Detached HEAD";
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct UpstreamTarget {
pub remote: String,
pub branch: String,
pub display: String,
}
pub(crate) fn branch_label(vault: &Path) -> Result<String, String> {
Ok(current_branch(vault)?.unwrap_or_else(|| DETACHED_HEAD_BRANCH.to_string()))
}
pub(crate) fn missing_upstream_message(vault: &Path) -> Result<String, String> {
let branch = branch_label(vault)?;
if branch == DETACHED_HEAD_BRANCH {
return Ok(
"This vault is in detached HEAD. Check out a branch and configure its upstream before syncing in Tolaria."
.to_string(),
);
}
Ok(format!(
"No upstream branch configured for '{branch}'. Set a tracking branch with external Git tooling, then sync again in Tolaria."
))
}
pub(crate) fn sync_target(vault: &Path) -> Result<Option<UpstreamTarget>, String> {
let Some(branch_name) = current_branch(vault)? else {
return Ok(None);
};
let Some(remote) = config_value(vault, &format!("branch.{branch_name}.remote"))? else {
return Ok(None);
};
let Some(merge_ref) = config_value(vault, &format!("branch.{branch_name}.merge"))? else {
return Ok(None);
};
let branch = merge_ref
.strip_prefix("refs/heads/")
.unwrap_or(merge_ref.as_str())
.to_string();
if remote.is_empty() || branch.is_empty() {
return Ok(None);
}
Ok(Some(UpstreamTarget {
display: format!("{remote}/{branch}"),
remote,
branch,
}))
}
fn current_branch(vault: &Path) -> Result<Option<String>, String> {
let output = git_output(vault, &["branch", "--show-current"])
.map_err(|e| format!("Failed to get branch: {}", e))?;
let branch = stdout_text(&output);
if branch.is_empty() {
Ok(None)
} else {
Ok(Some(branch))
}
}
fn config_value(vault: &Path, key: &str) -> Result<Option<String>, String> {
let output = git_output_result(vault, &["config", "--get", key])?;
if !output.status.success() {
return Ok(None);
}
let value = stdout_text(&output);
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}