508 lines
15 KiB
Rust
508 lines
15 KiB
Rust
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<Path>) -> 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<Path>) -> 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<String> {
|
|
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<String> {
|
|
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<String, Option<String>> {
|
|
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);
|
|
}
|
|
}
|