feat: use git history for note creation/modification dates
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>
This commit is contained in:
40
docs/adr/0039-git-history-for-note-dates.md
Normal file
40
docs/adr/0039-git-history-for-note-dates.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0039"
|
||||
title: "Use git history for note creation and modification dates"
|
||||
status: active
|
||||
date: 2026-04-02
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Filesystem metadata (`ctime`/`mtime`) is unreliable for a git-backed vault. After `git clone`, `git pull`, or iCloud sync, files appear "newly created" even when they have years of history. This causes incorrect sort ordering in the note list and wrong dates in the inspector panel.
|
||||
|
||||
## Decision
|
||||
|
||||
**Use `git log` to determine the true creation and modification dates for notes.** A single batch `git log --format="COMMIT %aI" --name-only` command walks the full commit history and extracts:
|
||||
|
||||
- **modified_at** = author date of the most recent commit that touched the file
|
||||
- **created_at** = author date of the oldest commit that touched the file
|
||||
|
||||
The batch approach runs once per vault scan (not per-file), parsing the log output in a single linear pass. Results are stored in a `HashMap<String, GitDates>` keyed by vault-relative path and threaded through the existing `parse_md_file` / `scan_vault` / `scan_vault_cached` pipeline.
|
||||
|
||||
### Fallback to filesystem dates
|
||||
|
||||
- **Non-git vaults** (no `.git` directory): all notes use filesystem `mtime`/`ctime`.
|
||||
- **Uncommitted new files**: not in git log output, so filesystem dates are used automatically.
|
||||
- **Single-file reloads** (`reload_entry`): use filesystem dates since the file was just saved and the most accurate timestamp is the filesystem one.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Per-file `git log`**: Correct but O(n) subprocesses. Too slow for vaults with 500+ notes.
|
||||
- **Frontmatter dates** (e.g., `created: 2025-01-15`): Requires user discipline. Not automatic. Breaks when users forget to set them.
|
||||
- **Filesystem metadata** (current): Unreliable across clones, pulls, and cloud sync.
|
||||
- **Single batch `git log`** (chosen): One subprocess, O(n) parsing, correct dates for all committed files.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Note sort-by-created and sort-by-modified now reflect true git history, stable across clones and machines.
|
||||
- First vault scan runs one `git log` over the full history. For a vault with 1000 files and 500 commits, output is ~100KB and parses in <100ms.
|
||||
- Renamed files get `created_at` set to the rename commit date (not the original creation). Acceptable trade-off vs. the complexity of rename tracking.
|
||||
- `CACHE_VERSION` bumped from 9 to 10 to force a full rescan with git dates on upgrade.
|
||||
233
src-tauri/src/git/dates.rs
Normal file
233
src-tauri/src/git/dates.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
use chrono::DateTime;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// 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 Command::new("git")
|
||||
.args(["log", "--format=COMMIT %aI", "--name-only"])
|
||||
.current_dir(vault_path)
|
||||
.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());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod commit;
|
||||
mod conflict;
|
||||
mod dates;
|
||||
mod history;
|
||||
mod pulse;
|
||||
mod remote;
|
||||
@@ -13,6 +14,7 @@ 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::{
|
||||
|
||||
@@ -3,12 +3,15 @@ use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::git::{get_all_file_dates, GitDates};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{parse_md_file, scan_vault, VaultEntry};
|
||||
|
||||
// --- Vault Cache ---
|
||||
|
||||
/// Bump this when VaultEntry fields change to force a full rescan.
|
||||
const CACHE_VERSION: u32 = 9;
|
||||
const CACHE_VERSION: u32 = 10;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
@@ -173,13 +176,20 @@ fn to_relative_path(abs_path: &str, vault: &Path) -> String {
|
||||
}
|
||||
|
||||
/// Parse .md files from a list of relative paths, skipping any that don't exist.
|
||||
fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
|
||||
fn parse_files_at(
|
||||
vault: &Path,
|
||||
rel_paths: &[String],
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Vec<VaultEntry> {
|
||||
rel_paths
|
||||
.iter()
|
||||
.filter_map(|rel| {
|
||||
let abs = vault.join(rel);
|
||||
if abs.is_file() {
|
||||
parse_md_file(&abs).ok()
|
||||
let dates = git_dates
|
||||
.get(rel.as_str())
|
||||
.map(|d| (d.modified_at, d.created_at));
|
||||
parse_md_file(&abs, dates).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -264,13 +274,17 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
|
||||
/// Handle same-commit cache hit: re-parse any uncommitted changes (new or modified files).
|
||||
/// Always prunes stale entries even when git reports no changes, so that files
|
||||
/// deleted outside git (e.g., via Finder) are removed from the cache on vault open.
|
||||
fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
|
||||
fn update_same_commit(
|
||||
vault: &Path,
|
||||
cache: VaultCache,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Vec<VaultEntry> {
|
||||
let changed = git_uncommitted_files(vault);
|
||||
let mut entries = cache.entries;
|
||||
if !changed.is_empty() {
|
||||
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
|
||||
entries.retain(|e| !changed_set.contains(&to_relative_path(&e.path, vault)));
|
||||
entries.extend(parse_files_at(vault, &changed));
|
||||
entries.extend(parse_files_at(vault, &changed, git_dates));
|
||||
}
|
||||
// Always finalize: prune_stale_entries inside finalize_and_cache removes
|
||||
// entries for files deleted outside git (e.g., via Finder or another app).
|
||||
@@ -282,6 +296,7 @@ fn update_different_commit(
|
||||
vault: &Path,
|
||||
cache: VaultCache,
|
||||
current_hash: String,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Vec<VaultEntry> {
|
||||
let changed_files = git_changed_files(vault, &cache.commit_hash, ¤t_hash);
|
||||
let changed_set: std::collections::HashSet<String> = changed_files.iter().cloned().collect();
|
||||
@@ -291,7 +306,7 @@ fn update_different_commit(
|
||||
.into_iter()
|
||||
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
|
||||
.collect();
|
||||
entries.extend(parse_files_at(vault, &changed_files));
|
||||
entries.extend(parse_files_at(vault, &changed_files, git_dates));
|
||||
|
||||
finalize_and_cache(vault, entries, current_hash)
|
||||
}
|
||||
@@ -319,26 +334,34 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
|
||||
let current_hash = match git_head_hash(vault_path) {
|
||||
Some(h) => h,
|
||||
None => return scan_vault(vault_path),
|
||||
None => return scan_vault(vault_path, &HashMap::new()),
|
||||
};
|
||||
|
||||
// Build git dates map once — used by all code paths below
|
||||
let git_dates = get_all_file_dates(vault_path);
|
||||
|
||||
if let Some(cache) = load_cache(vault_path) {
|
||||
let current_vault_str = vault_path.to_string_lossy();
|
||||
let cache_stale = cache.version != CACHE_VERSION
|
||||
|| (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref());
|
||||
if cache_stale {
|
||||
let entries = scan_vault(vault_path)?;
|
||||
let entries = scan_vault(vault_path, &git_dates)?;
|
||||
return Ok(finalize_and_cache(vault_path, entries, current_hash));
|
||||
}
|
||||
return if cache.commit_hash == current_hash {
|
||||
Ok(update_same_commit(vault_path, cache))
|
||||
Ok(update_same_commit(vault_path, cache, &git_dates))
|
||||
} else {
|
||||
Ok(update_different_commit(vault_path, cache, current_hash))
|
||||
Ok(update_different_commit(
|
||||
vault_path,
|
||||
cache,
|
||||
current_hash,
|
||||
&git_dates,
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
// No cache — full scan and write cache
|
||||
let entries = scan_vault(vault_path)?;
|
||||
let entries = scan_vault(vault_path, &git_dates)?;
|
||||
Ok(finalize_and_cache(vault_path, entries, current_hash))
|
||||
}
|
||||
|
||||
@@ -930,7 +953,7 @@ mod tests {
|
||||
|
||||
// Simulate a stale cache written by old code that parsed Archived: Yes as false
|
||||
let stale_entry = {
|
||||
let mut e = parse_md_file(&vault.join("note.md")).unwrap();
|
||||
let mut e = parse_md_file(&vault.join("note.md"), None).unwrap();
|
||||
e.archived = false; // simulate old parser behavior
|
||||
e
|
||||
};
|
||||
|
||||
@@ -503,7 +503,7 @@ mod tests {
|
||||
let vault_path = dir.path().join("parse-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entries = crate::vault::scan_vault(&vault_path).unwrap();
|
||||
let entries = crate::vault::scan_vault(&vault_path, &std::collections::HashMap::new()).unwrap();
|
||||
// SAMPLE_FILES + AGENTS.md
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1);
|
||||
}
|
||||
@@ -537,7 +537,7 @@ mod tests {
|
||||
let vault_path = dir.path().join("agents-parse-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md")).unwrap();
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md"), None).unwrap();
|
||||
// No frontmatter title → derived from filename slug (H1 is body content)
|
||||
assert_eq!(entry.title, "AGENTS");
|
||||
// Config files have no frontmatter type field — type is None
|
||||
|
||||
@@ -35,7 +35,14 @@ use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Parse a single markdown file into a VaultEntry.
|
||||
pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
///
|
||||
/// If `git_dates` is provided, those timestamps override filesystem metadata
|
||||
/// for `modified_at` and `created_at`. Pass `None` to use filesystem dates
|
||||
/// (appropriate for newly-saved files not yet committed, or non-git vaults).
|
||||
pub fn parse_md_file(
|
||||
path: &Path,
|
||||
git_dates: Option<(u64, u64)>,
|
||||
) -> Result<VaultEntry, String> {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
|
||||
let filename = path
|
||||
@@ -51,7 +58,11 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
let snippet = extract_snippet(&content);
|
||||
let word_count = count_body_words(&content);
|
||||
let outgoing_links = extract_outgoing_links(&parsed.content);
|
||||
let (modified_at, created_at, file_size) = read_file_metadata(path)?;
|
||||
let (fs_modified, fs_created, file_size) = read_file_metadata(path)?;
|
||||
let (modified_at, created_at) = match git_dates {
|
||||
Some((git_mod, git_create)) => (Some(git_mod), Some(git_create)),
|
||||
None => (fs_modified, fs_created),
|
||||
};
|
||||
let is_a = resolve_is_a(frontmatter.is_a);
|
||||
|
||||
// Add "Type" relationship: isA becomes a navigable link to the type document.
|
||||
@@ -111,11 +122,12 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
}
|
||||
|
||||
/// Re-read a single file from disk and return a fresh VaultEntry.
|
||||
/// Uses filesystem dates (no git lookup) since the file was likely just saved.
|
||||
pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("File does not exist: {}", path.display()));
|
||||
}
|
||||
parse_md_file(path)
|
||||
parse_md_file(path, None)
|
||||
}
|
||||
|
||||
/// Directories that are never shown in the folder tree or scanned for notes.
|
||||
@@ -129,8 +141,32 @@ fn is_md_file(path: &Path) -> bool {
|
||||
path.is_file() && path.extension().is_some_and(|ext| ext == "md")
|
||||
}
|
||||
|
||||
fn try_parse_md(path: &Path, entries: &mut Vec<VaultEntry>) {
|
||||
match parse_md_file(path) {
|
||||
use crate::git::GitDates;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn lookup_git_dates(
|
||||
path: &Path,
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Option<(u64, u64)> {
|
||||
let rel = path
|
||||
.strip_prefix(vault_path)
|
||||
.ok()?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
git_dates
|
||||
.get(&rel)
|
||||
.map(|d| (d.modified_at, d.created_at))
|
||||
}
|
||||
|
||||
fn try_parse_md(
|
||||
path: &Path,
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
entries: &mut Vec<VaultEntry>,
|
||||
) {
|
||||
let dates = lookup_git_dates(path, vault_path, git_dates);
|
||||
match parse_md_file(path, dates) {
|
||||
Ok(vault_entry) => entries.push(vault_entry),
|
||||
Err(e) => log::warn!("Skipping file: {}", e),
|
||||
}
|
||||
@@ -138,7 +174,11 @@ fn try_parse_md(path: &Path, entries: &mut Vec<VaultEntry>) {
|
||||
|
||||
/// Scan all .md files in the vault, including subdirectories.
|
||||
/// Hidden directories (starting with `.`) are excluded.
|
||||
fn scan_all_md_files(vault_path: &Path, entries: &mut Vec<VaultEntry>) {
|
||||
fn scan_all_md_files(
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
entries: &mut Vec<VaultEntry>,
|
||||
) {
|
||||
let walker = WalkDir::new(vault_path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
@@ -155,13 +195,17 @@ fn scan_all_md_files(vault_path: &Path, entries: &mut Vec<VaultEntry>) {
|
||||
});
|
||||
for entry in walker.filter_map(|e| e.ok()) {
|
||||
if is_md_file(entry.path()) {
|
||||
try_parse_md(entry.path(), entries);
|
||||
try_parse_md(entry.path(), vault_path, git_dates, entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a directory recursively for .md files and return VaultEntry for each.
|
||||
pub fn scan_vault(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
/// Pass an empty map for `git_dates` to use filesystem dates only.
|
||||
pub fn scan_vault(
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Result<Vec<VaultEntry>, String> {
|
||||
if !vault_path.exists() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist: {}",
|
||||
@@ -176,7 +220,7 @@ pub fn scan_vault(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
scan_all_md_files(vault_path, &mut entries);
|
||||
scan_all_md_files(vault_path, git_dates, &mut entries);
|
||||
|
||||
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
Ok(entries)
|
||||
|
||||
@@ -15,7 +15,7 @@ fn create_test_file(dir: &Path, name: &str, content: &str) {
|
||||
|
||||
fn parse_test_entry(dir: &TempDir, name: &str, content: &str) -> VaultEntry {
|
||||
create_test_file(dir.path(), name, content);
|
||||
parse_md_file(&dir.path().join(name)).unwrap()
|
||||
parse_md_file(&dir.path().join(name), None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -108,7 +108,7 @@ fn test_parse_no_frontmatter() {
|
||||
let content = "# A Note Without Frontmatter\n\nJust markdown.";
|
||||
create_test_file(dir.path(), "a-note-without-frontmatter.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("a-note-without-frontmatter.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("a-note-without-frontmatter.md"), None).unwrap();
|
||||
// No title in frontmatter → derived from filename
|
||||
assert_eq!(entry.title, "A Note Without Frontmatter");
|
||||
}
|
||||
@@ -119,7 +119,7 @@ fn test_parse_single_string_aliases() {
|
||||
let content = "---\naliases: SingleAlias\n---\n# Test\n";
|
||||
create_test_file(dir.path(), "single-alias.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("single-alias.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("single-alias.md"), None).unwrap();
|
||||
assert_eq!(entry.aliases, vec!["SingleAlias"]);
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ fn test_scan_vault_root_and_protected_folders() {
|
||||
create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n");
|
||||
create_test_file(dir.path(), "not-markdown.txt", "This should be ignored");
|
||||
|
||||
let entries = scan_vault(dir.path()).unwrap();
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
|
||||
let filenames: Vec<&str> = entries.iter().map(|e| e.filename.as_str()).collect();
|
||||
@@ -159,7 +159,7 @@ fn test_scan_vault_includes_subdirectory_notes() {
|
||||
"---\ntype: Project\n---\n# Old\n",
|
||||
);
|
||||
|
||||
let entries = scan_vault(dir.path()).unwrap();
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
3,
|
||||
@@ -178,7 +178,7 @@ fn test_scan_vault_includes_all_protected_folders() {
|
||||
create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n");
|
||||
create_test_file(dir.path(), "assets/image.md", "# Asset\n");
|
||||
|
||||
let entries = scan_vault(dir.path()).unwrap();
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
}
|
||||
|
||||
@@ -189,14 +189,14 @@ fn test_scan_vault_skips_hidden_folders() {
|
||||
create_test_file(dir.path(), ".laputa/cache.md", "# Cache\n");
|
||||
create_test_file(dir.path(), ".git/objects.md", "# Git\n");
|
||||
|
||||
let entries = scan_vault(dir.path()).unwrap();
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].filename, "root.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_nonexistent_path() {
|
||||
let result = scan_vault(Path::new("/nonexistent/path/that/does/not/exist"));
|
||||
let result = scan_vault(Path::new("/nonexistent/path/that/does/not/exist"), &HashMap::new());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ fn test_parse_malformed_yaml() {
|
||||
let content = "---\nIs A: [unclosed bracket\n---\n# Malformed\n";
|
||||
create_test_file(dir.path(), "malformed.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("malformed.md"));
|
||||
let entry = parse_md_file(&dir.path().join("malformed.md"), None);
|
||||
// Should still succeed — gray_matter may parse partially or skip
|
||||
assert!(entry.is_ok());
|
||||
}
|
||||
@@ -236,7 +236,7 @@ fn test_parse_md_file_has_snippet() {
|
||||
let content = "---\nIs A: Note\n---\n# Test Note\n\nHello, world! This is a snippet.";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.snippet, "Hello, world! This is a snippet.");
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ fn test_parse_md_file_has_word_count() {
|
||||
"---\nIs A: Note\n---\n# Test Note\n\nHello world. This is a test with seven words.";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.word_count, 9);
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ fn test_parse_md_file_word_count_empty_body() {
|
||||
let content = "---\nIs A: Note\n---\n# Empty Note\n";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.word_count, 0);
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ Status: Active
|
||||
"#;
|
||||
create_test_file(dir.path(), "publish-essays.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("publish-essays.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("publish-essays.md"), None).unwrap();
|
||||
assert_eq!(entry.relationships.len(), 3); // Has, Topics, Type
|
||||
assert_eq!(
|
||||
entry.relationships.get("Has").unwrap(),
|
||||
@@ -310,7 +310,7 @@ Belongs to:
|
||||
"#;
|
||||
create_test_file(dir.path(), "some-project.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("some-project.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("some-project.md"), None).unwrap();
|
||||
|
||||
// Owner with wikilink should appear in relationships
|
||||
assert!(entry.relationships.get("Owner").is_some());
|
||||
@@ -342,7 +342,7 @@ Custom Field: just a plain string
|
||||
"#;
|
||||
create_test_file(dir.path(), "plain-note.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("plain-note.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("plain-note.md"), None).unwrap();
|
||||
// Tags and Custom Field don't contain wikilinks — only the auto-generated "Type" relationship
|
||||
assert_eq!(entry.relationships.len(), 1);
|
||||
assert_eq!(
|
||||
@@ -404,7 +404,7 @@ Context: "[[area/research]]"
|
||||
"#;
|
||||
create_test_file(dir.path(), "single-vs-array.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("single-vs-array.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("single-vs-array.md"), None).unwrap();
|
||||
|
||||
// Single string → Vec with one element
|
||||
assert_eq!(
|
||||
@@ -481,7 +481,7 @@ References:
|
||||
"#;
|
||||
create_test_file(dir.path(), "mixed-array.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("mixed-array.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("mixed-array.md"), None).unwrap();
|
||||
|
||||
// Only the wikilink entries should be captured
|
||||
assert_eq!(
|
||||
@@ -545,7 +545,7 @@ title: No Code
|
||||
# No Code
|
||||
"#;
|
||||
create_test_file(dir.path(), "no-code.md", content);
|
||||
let entry = parse_md_file(&dir.path().join("no-code.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("no-code.md"), None).unwrap();
|
||||
|
||||
let notes = entry
|
||||
.relationships
|
||||
@@ -572,7 +572,7 @@ title: No Code
|
||||
fn test_type_from_frontmatter_only() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "test.md", "---\ntype: Custom\n---\n# Test\n");
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.is_a, Some("Custom".to_string()));
|
||||
}
|
||||
|
||||
@@ -580,7 +580,7 @@ fn test_type_from_frontmatter_only() {
|
||||
fn test_no_type_when_frontmatter_missing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "note/test.md", "# Test\n");
|
||||
let entry = parse_md_file(&dir.path().join("note/test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("note/test.md"), None).unwrap();
|
||||
assert_eq!(entry.is_a, None, "type should not be inferred from folder");
|
||||
}
|
||||
|
||||
@@ -592,7 +592,7 @@ fn test_created_at_from_filesystem() {
|
||||
let content = "---\nIs A: Note\n---\n# Test\n";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
// created_at should be set from filesystem metadata (not None)
|
||||
assert!(
|
||||
entry.created_at.is_some(),
|
||||
@@ -1168,7 +1168,7 @@ fn test_parse_real_engineering_management_file() {
|
||||
if !path.exists() {
|
||||
return; // Skip when the Laputa vault is not available
|
||||
}
|
||||
let entry = parse_md_file(path).unwrap();
|
||||
let entry = parse_md_file(path, None).unwrap();
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"engineering-management.md must be trashed (has Trashed: true in frontmatter)"
|
||||
@@ -1204,7 +1204,7 @@ fn test_fallback_parser_extracts_trashed_from_malformed_yaml() {
|
||||
];
|
||||
let content = fm.join("\n");
|
||||
create_test_file(dir.path(), "eng-mgmt.md", &content);
|
||||
let entry = parse_md_file(&dir.path().join("eng-mgmt.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("eng-mgmt.md"), None).unwrap();
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when YAML is malformed (fallback parser)"
|
||||
@@ -1231,7 +1231,7 @@ fn test_fallback_parser_extracts_archived_from_malformed_yaml() {
|
||||
];
|
||||
let content = fm.join("\n");
|
||||
create_test_file(dir.path(), "archived-essay.md", &content);
|
||||
let entry = parse_md_file(&dir.path().join("archived-essay.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("archived-essay.md"), None).unwrap();
|
||||
assert!(
|
||||
entry.archived,
|
||||
"Archived must be true even when YAML is malformed"
|
||||
|
||||
Reference in New Issue
Block a user