mod clone; mod commit; mod conflict; mod connect; mod dates; mod history; mod pulse; mod remote; mod status; use std::path::Path; use std::process::Command; pub use clone::clone_repo; pub use commit::git_commit; pub use conflict::{ get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict, is_merge_in_progress, is_rebase_in_progress, }; pub use connect::{disconnect_all_remotes, git_add_remote, GitAddRemoteResult}; pub use dates::{get_all_file_dates, GitDates}; pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history}; pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile}; pub use remote::{ git_pull, git_push, git_remote_status, has_remote, GitPullResult, GitPushResult, GitRemoteStatus, }; pub use status::{discard_file_changes, get_modified_files, ModifiedFile}; use serde::Serialize; #[derive(Debug, Serialize, Clone)] pub struct GitCommit { pub hash: String, #[serde(rename = "shortHash")] pub short_hash: String, pub message: String, pub author: String, pub date: i64, } const DEFAULT_GITIGNORE: &str = "# Tolaria app files (machine-specific, never commit)\n\ .laputa/settings.json\n\ \n\ # macOS\n\ .DS_Store\n\ .AppleDouble\n\ .LSOverride\n\ \n\ # Thumbnails\n\ ._*\n\ \n\ # Editors\n\ .vscode/\n\ .idea/\n\ *.swp\n\ *.swo\n"; fn git_command() -> Command { let mut command = crate::hidden_command("git"); sanitize_linux_appimage_git_env(&mut command); command } #[cfg(any(test, all(desktop, target_os = "linux")))] const LINUX_APPIMAGE_GIT_ENV_REMOVALS: [&str; 3] = ["LD_LIBRARY_PATH", "LD_PRELOAD", "GIT_EXEC_PATH"]; #[cfg(all(desktop, target_os = "linux"))] fn sanitize_linux_appimage_git_env(command: &mut Command) { sanitize_linux_appimage_git_env_for_launch(command, linux_appimage_env_present()); } #[cfg(not(all(desktop, target_os = "linux")))] fn sanitize_linux_appimage_git_env(_command: &mut Command) {} #[cfg(any(test, all(desktop, target_os = "linux")))] fn sanitize_linux_appimage_git_env_for_launch(command: &mut Command, is_appimage: bool) { if !is_appimage { return; } for key in LINUX_APPIMAGE_GIT_ENV_REMOVALS { command.env_remove(key); } } #[cfg(all(desktop, target_os = "linux"))] fn linux_appimage_env_present() -> bool { ["APPIMAGE", "APPDIR"] .into_iter() .any(|key| std::env::var(key).is_ok_and(|value| !value.trim().is_empty())) } /// Ensure a `.gitignore` with sensible defaults exists in the vault directory. /// Creates the file if missing; leaves existing `.gitignore` files untouched. pub fn ensure_gitignore(path: impl AsRef) -> Result<(), String> { let gitignore_path = path.as_ref().join(".gitignore"); if !gitignore_path.exists() { std::fs::write(&gitignore_path, DEFAULT_GITIGNORE) .map_err(|e| format!("Failed to write .gitignore: {}", e))?; } Ok(()) } /// Initialize a new git repository, stage all files, and create an initial commit. pub fn init_repo(path: impl AsRef) -> Result<(), String> { let dir = path.as_ref(); run_git(dir, &["init"])?; ensure_author_config(dir)?; // Write .gitignore before the first commit so machine-specific and // macOS metadata files are never tracked and don't cause conflicts. ensure_gitignore(dir)?; run_git(dir, &["add", "."])?; commit_initial_vault_setup(dir)?; Ok(()) } fn commit_initial_vault_setup(dir: &Path) -> Result<(), String> { run_git( dir, &[ "-c", "commit.gpgsign=false", "commit", "-m", "Initial vault setup", ], ) } /// Run a git command in the given directory, returning an error on failure. fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> { let output = git_command() .args(args) .current_dir(dir) .output() .map_err(|e| format!("Failed to run git {}: {e}", git_command_label(args)))?; if output.status.success() { return Ok(()); } Err(format!( "git {} failed: {}", git_command_label(args), String::from_utf8_lossy(&output.stderr) )) } 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] } /// Set local user.name and user.email if not already configured. pub(crate) fn ensure_author_config(dir: &Path) -> Result<(), String> { for (key, fallback) in [("user.name", "Tolaria"), ("user.email", "vault@tolaria.md")] { let local = git_command() .args(["config", "--local", key]) .current_dir(dir) .output() .map_err(|e| format!("Failed to check git config {key}: {e}"))?; let value = String::from_utf8_lossy(&local.stdout); if local.status.success() && !value.trim().is_empty() { continue; } run_git(dir, &["config", "--local", key, fallback])?; } Ok(()) } /// Extract "owner/repo" from a GitHub remote URL. /// Supports HTTPS (https://github.com/owner/repo.git) and /// SSH (git@github.com:owner/repo.git) formats. fn normalize_github_repo_path(repo_path: &str) -> Option { let repo_path = repo_path.strip_suffix(".git").unwrap_or(repo_path); repo_path.contains('/').then(|| repo_path.to_string()) } fn github_remote_suffix(url: &str) -> Option<&str> { const GITHUB_PREFIXES: [&str; 4] = [ "git@github.com:", "https://github.com/", "http://github.com/", "ssh://git@github.com/", ]; GITHUB_PREFIXES .iter() .find_map(|prefix| url.strip_prefix(prefix)) .or_else(|| url.split_once("@github.com/").map(|(_, suffix)| suffix)) } fn parse_github_repo_path(url: &str) -> Option { github_remote_suffix(url.trim()).and_then(normalize_github_repo_path) } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; use std::fs; use tempfile::TempDir; fn assert_repo_path(url: &str, expected: Option<&str>) { assert_eq!( parse_github_repo_path(url), expected.map(ToString::to_string) ); } pub(crate) fn setup_git_repo() -> TempDir { let dir = TempDir::new().unwrap(); let path = dir.path(); git_command() .args(["init", "--initial-branch=main"]) .current_dir(path) .output() .unwrap(); git_command() .args(["config", "user.email", "test@test.com"]) .current_dir(path) .output() .unwrap(); git_command() .args(["config", "user.name", "Test User"]) .current_dir(path) .output() .unwrap(); dir } /// Set up a bare "remote" and a clone that acts as the working vault. pub(crate) fn setup_remote_pair() -> (TempDir, TempDir, TempDir) { let bare_dir = TempDir::new().unwrap(); let bare = bare_dir.path(); git_command() .args(["init", "--bare"]) .current_dir(bare) .output() .unwrap(); let clone_a_dir = TempDir::new().unwrap(); git_command() .args(["clone", bare.to_str().unwrap(), "."]) .current_dir(clone_a_dir.path()) .output() .unwrap(); for cmd in &[ &["config", "user.email", "a@test.com"][..], &["config", "user.name", "User A"][..], ] { git_command() .args(*cmd) .current_dir(clone_a_dir.path()) .output() .unwrap(); } let clone_b_dir = TempDir::new().unwrap(); git_command() .args(["clone", bare.to_str().unwrap(), "."]) .current_dir(clone_b_dir.path()) .output() .unwrap(); for cmd in &[ &["config", "user.email", "b@test.com"][..], &["config", "user.name", "User B"][..], ] { git_command() .args(*cmd) .current_dir(clone_b_dir.path()) .output() .unwrap(); } (bare_dir, clone_a_dir, clone_b_dir) } fn command_envs(command: &Command) -> HashMap> { command .get_envs() .map(|(key, value)| { ( key.to_string_lossy().to_string(), value.map(|entry| entry.to_string_lossy().to_string()), ) }) .collect() } #[test] fn test_ensure_gitignore_creates_file() { let dir = TempDir::new().unwrap(); let path = dir.path().to_str().unwrap(); ensure_gitignore(path).unwrap(); let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); assert!(content.contains(".DS_Store")); assert!(content.contains(".laputa/settings.json")); } #[test] fn test_ensure_gitignore_preserves_existing() { let dir = TempDir::new().unwrap(); fs::write(dir.path().join(".gitignore"), "my-rule\n").unwrap(); ensure_gitignore(dir.path().to_str().unwrap()).unwrap(); let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); assert_eq!(content, "my-rule\n"); } #[test] fn test_linux_appimage_git_commands_remove_appimage_loader_env() { let mut command = crate::hidden_command("git"); sanitize_linux_appimage_git_env_for_launch(&mut command, true); let envs = command_envs(&command); for key in LINUX_APPIMAGE_GIT_ENV_REMOVALS { assert_eq!(envs.get(key), Some(&None)); } } #[test] fn test_non_appimage_git_commands_keep_parent_env_unmodified() { let mut command = crate::hidden_command("git"); sanitize_linux_appimage_git_env_for_launch(&mut command, false); let envs = command_envs(&command); for key in LINUX_APPIMAGE_GIT_ENV_REMOVALS { assert!(!envs.contains_key(key)); } } #[test] fn test_init_repo_creates_git_directory() { let dir = TempDir::new().unwrap(); let vault = dir.path().join("new-vault"); fs::create_dir_all(&vault).unwrap(); fs::write(vault.join("note.md"), "# Test\n").unwrap(); init_repo(vault.to_str().unwrap()).unwrap(); assert!(vault.join(".git").exists()); } #[test] fn test_init_repo_creates_initial_commit() { let dir = TempDir::new().unwrap(); let vault = dir.path().join("new-vault"); fs::create_dir_all(&vault).unwrap(); fs::write(vault.join("note.md"), "# Test\n").unwrap(); init_repo(vault.to_str().unwrap()).unwrap(); let log = git_command() .args(["log", "--oneline"]) .current_dir(&vault) .output() .unwrap(); let log_str = String::from_utf8_lossy(&log.stdout); assert!(log_str.contains("Initial vault setup")); } #[test] fn test_init_repo_creates_initial_commit_when_signing_is_misconfigured() { let dir = TempDir::new().unwrap(); let vault = dir.path().join("new-vault"); fs::create_dir_all(&vault).unwrap(); fs::write(vault.join("note.md"), "# Test\n").unwrap(); git_command() .args(["init"]) .current_dir(&vault) .output() .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(); init_repo(vault.to_str().unwrap()).unwrap(); let log = git_command() .args(["log", "--oneline"]) .current_dir(&vault) .output() .unwrap(); assert!(String::from_utf8_lossy(&log.stdout).contains("Initial vault setup")); } #[test] fn test_init_repo_stages_all_files() { let dir = TempDir::new().unwrap(); let vault = dir.path().join("new-vault"); fs::create_dir_all(vault.join("sub")).unwrap(); fs::write(vault.join("note.md"), "# Test\n").unwrap(); fs::write(vault.join("sub/nested.md"), "# Nested\n").unwrap(); init_repo(vault.to_str().unwrap()).unwrap(); let status = git_command() .args(["status", "--porcelain"]) .current_dir(&vault) .output() .unwrap(); assert!( String::from_utf8_lossy(&status.stdout).trim().is_empty(), "All files should be committed" ); } #[test] fn test_init_repo_creates_gitignore() { let dir = TempDir::new().unwrap(); let vault = dir.path().join("new-vault"); fs::create_dir_all(&vault).unwrap(); fs::write(vault.join("note.md"), "# Test\n").unwrap(); init_repo(vault.to_str().unwrap()).unwrap(); let gitignore = vault.join(".gitignore"); assert!( gitignore.exists(), ".gitignore should be created by init_repo" ); let content = fs::read_to_string(&gitignore).unwrap(); assert!( content.contains(".DS_Store"), ".gitignore should exclude .DS_Store" ); assert!( content.contains(".laputa/settings.json"), ".gitignore should exclude settings.json" ); // Cache is now stored outside the vault — no need for .gitignore entry assert!( !content.contains(".laputa-cache.json"), ".gitignore should NOT contain .laputa-cache.json (cache is external)" ); } #[test] fn test_init_repo_does_not_overwrite_existing_gitignore() { let dir = TempDir::new().unwrap(); let vault = dir.path().join("new-vault"); fs::create_dir_all(&vault).unwrap(); fs::write(vault.join("note.md"), "# Test\n").unwrap(); fs::write(vault.join(".gitignore"), "custom-rule\n").unwrap(); init_repo(vault.to_str().unwrap()).unwrap(); let content = fs::read_to_string(vault.join(".gitignore")).unwrap(); assert_eq!( content, "custom-rule\n", "existing .gitignore should not be overwritten" ); } #[test] fn test_parse_github_repo_path_variants() { for url in [ "https://github.com/owner/repo.git", "https://github.com/owner/repo", "http://github.com/owner/repo.git", "git@github.com:owner/repo.git", "git@github.com:owner/repo", "ssh://git@github.com/owner/repo.git", "https://gho_abc123@github.com/owner/repo.git", ] { assert_repo_path(url, Some("owner/repo")); } } #[test] fn test_parse_github_repo_path_non_github() { assert_repo_path("https://gitlab.com/owner/repo.git", None); assert_repo_path("owner/repo", None); } }