Replace unreliable filesystem ctime/mtime with git log timestamps. A single batch `git log` walks the full commit history to extract created_at (oldest commit) and modified_at (newest commit) for each .md file. Falls back to filesystem dates for non-git vaults and uncommitted files. Cache version bumped to 10 for full rescan. ADR 0039 documents the decision. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
387 lines
11 KiB
Rust
387 lines
11 KiB
Rust
mod commit;
|
|
mod conflict;
|
|
mod dates;
|
|
mod history;
|
|
mod pulse;
|
|
mod remote;
|
|
mod status;
|
|
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
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 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::{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 = "# Laputa 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";
|
|
|
|
/// 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: &str) -> Result<(), String> {
|
|
let gitignore_path = Path::new(path).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: &str) -> Result<(), String> {
|
|
let dir = Path::new(path);
|
|
|
|
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(path)?;
|
|
|
|
run_git(dir, &["add", "."])?;
|
|
run_git(dir, &["commit", "-m", "Initial vault setup"])?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Run a git command in the given directory, returning an error on failure.
|
|
fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
|
|
let output = Command::new("git")
|
|
.args(args)
|
|
.current_dir(dir)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git {}: {}", args[0], e))?;
|
|
|
|
if output.status.success() {
|
|
return Ok(());
|
|
}
|
|
|
|
Err(format!(
|
|
"git {} failed: {}",
|
|
args[0],
|
|
String::from_utf8_lossy(&output.stderr)
|
|
))
|
|
}
|
|
|
|
/// Set local user.name and user.email if not already configured.
|
|
fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
|
for (key, fallback) in [("user.name", "Laputa"), ("user.email", "vault@laputa.app")] {
|
|
let check = Command::new("git")
|
|
.args(["config", key])
|
|
.current_dir(dir)
|
|
.output()
|
|
.map_err(|e| format!("Failed to check git config: {}", e))?;
|
|
|
|
let value = String::from_utf8_lossy(&check.stdout);
|
|
if !check.status.success() || value.trim().is_empty() {
|
|
run_git(dir, &["config", 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 parse_github_repo_path(url: &str) -> Option<String> {
|
|
let trimmed = url.trim();
|
|
|
|
// SSH format: git@github.com:owner/repo.git
|
|
if let Some(rest) = trimmed.strip_prefix("git@github.com:") {
|
|
let path = rest.strip_suffix(".git").unwrap_or(rest);
|
|
if path.contains('/') {
|
|
return Some(path.to_string());
|
|
}
|
|
}
|
|
|
|
// HTTPS format: https://github.com/owner/repo.git
|
|
// Also handle token-embedded URLs: https://token@github.com/owner/repo.git
|
|
if trimmed.contains("github.com/") {
|
|
let after = trimmed.split("github.com/").nth(1)?;
|
|
let path = after.strip_suffix(".git").unwrap_or(after);
|
|
if path.contains('/') {
|
|
return Some(path.to_string());
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
use std::process::Command;
|
|
use tempfile::TempDir;
|
|
|
|
pub(crate) fn setup_git_repo() -> TempDir {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path();
|
|
|
|
Command::new("git")
|
|
.args(["init"])
|
|
.current_dir(path)
|
|
.output()
|
|
.unwrap();
|
|
|
|
Command::new("git")
|
|
.args(["config", "user.email", "test@test.com"])
|
|
.current_dir(path)
|
|
.output()
|
|
.unwrap();
|
|
|
|
Command::new("git")
|
|
.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();
|
|
|
|
Command::new("git")
|
|
.args(["init", "--bare"])
|
|
.current_dir(bare)
|
|
.output()
|
|
.unwrap();
|
|
|
|
let clone_a_dir = TempDir::new().unwrap();
|
|
Command::new("git")
|
|
.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"][..],
|
|
] {
|
|
Command::new("git")
|
|
.args(*cmd)
|
|
.current_dir(clone_a_dir.path())
|
|
.output()
|
|
.unwrap();
|
|
}
|
|
|
|
let clone_b_dir = TempDir::new().unwrap();
|
|
Command::new("git")
|
|
.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"][..],
|
|
] {
|
|
Command::new("git")
|
|
.args(*cmd)
|
|
.current_dir(clone_b_dir.path())
|
|
.output()
|
|
.unwrap();
|
|
}
|
|
|
|
(bare_dir, clone_a_dir, clone_b_dir)
|
|
}
|
|
|
|
#[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_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 = Command::new("git")
|
|
.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_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 = Command::new("git")
|
|
.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_https() {
|
|
assert_eq!(
|
|
parse_github_repo_path("https://github.com/owner/repo.git"),
|
|
Some("owner/repo".to_string())
|
|
);
|
|
assert_eq!(
|
|
parse_github_repo_path("https://github.com/owner/repo"),
|
|
Some("owner/repo".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_github_repo_path_ssh() {
|
|
assert_eq!(
|
|
parse_github_repo_path("git@github.com:owner/repo.git"),
|
|
Some("owner/repo".to_string())
|
|
);
|
|
assert_eq!(
|
|
parse_github_repo_path("git@github.com:owner/repo"),
|
|
Some("owner/repo".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_github_repo_path_token_embedded() {
|
|
assert_eq!(
|
|
parse_github_repo_path("https://gho_abc123@github.com/owner/repo.git"),
|
|
Some("owner/repo".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_github_repo_path_non_github() {
|
|
assert_eq!(
|
|
parse_github_repo_path("https://gitlab.com/owner/repo.git"),
|
|
None
|
|
);
|
|
}
|
|
}
|