use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use tokio::sync::Notify;
use turbovault_core::prelude::*;
use turbovault_git::{Oid, VaultRepo};
use turbovault_vault::VaultManager;
#[derive(Debug, Default)]
pub struct ReindexQueue {
pending: Mutex<VecDeque<Oid>>,
cursor: Mutex<Option<Oid>>,
notify: Notify,
flush_lock: tokio::sync::Mutex<()>,
}
impl ReindexQueue {
pub fn new() -> Self {
Self::default()
}
pub fn push(&self, commit: Oid) {
self.pending.lock().unwrap().push_back(commit);
self.notify.notify_one();
}
pub fn notify(&self) -> &Notify {
&self.notify
}
pub async fn lock_flush(&self) -> tokio::sync::MutexGuard<'_, ()> {
self.flush_lock.lock().await
}
pub fn pending_count(&self) -> usize {
self.pending.lock().unwrap().len()
}
pub fn cursor(&self) -> Option<Oid> {
*self.cursor.lock().unwrap()
}
pub fn pop_front(&self) -> Option<Oid> {
self.pending.lock().unwrap().pop_front()
}
pub fn advance_cursor(&self, commit: Oid) {
*self.cursor.lock().unwrap() = Some(commit);
}
pub async fn drain_through(
&self,
repo: &VaultRepo,
manager: &Arc<VaultManager>,
) -> Result<usize> {
let _flush_guard = self.lock_flush().await;
let mut applied = 0usize;
while let Some(commit) = self.pop_front() {
let parent = match repo.git_commit_first_parent(commit) {
Ok(parent) => parent,
Err(e) => {
log::warn!(
"reindex: skipping commit {} after first-parent lookup error: {}",
commit,
e
);
self.advance_cursor(commit);
applied += 1;
continue;
}
};
if let Err(e) = apply_commit_diff(repo, parent, commit, manager).await {
log::warn!("reindex: skipping commit {} after error: {}", commit, e);
}
self.advance_cursor(commit);
applied += 1;
}
Ok(applied)
}
}
pub async fn watch_ref_changes(
vault_path: std::path::PathBuf,
queue: Arc<ReindexQueue>,
interval: std::time::Duration,
) {
let mut last_oid = read_head_oid(&vault_path).await;
loop {
tokio::time::sleep(interval).await;
let current = read_head_oid(&vault_path).await;
if current != last_oid {
if let Some(new) = current {
for oid in first_parent_range_or_tip(&vault_path, last_oid, new).await {
queue.push(oid);
}
}
last_oid = current;
}
}
}
async fn first_parent_range_or_tip(
vault_path: &std::path::Path,
stop: Option<Oid>,
tip: Oid,
) -> Vec<Oid> {
let path = vault_path.to_path_buf();
let range = tokio::task::spawn_blocking(move || {
let repo = VaultRepo::open(&path).ok()?;
repo.first_parent_range(stop, tip).ok().flatten()
})
.await
.ok()
.flatten();
range.filter(|r| !r.is_empty()).unwrap_or_else(|| vec![tip])
}
async fn read_head_oid(vault_path: &std::path::Path) -> Option<Oid> {
let path = vault_path.to_path_buf();
tokio::task::spawn_blocking(move || {
VaultRepo::open(&path).ok().and_then(|repo| repo.head_oid())
})
.await
.ok()
.flatten()
}
pub async fn apply_commit_diff(
repo: &VaultRepo,
parent: Option<Oid>,
commit: Oid,
manager: &Arc<VaultManager>,
) -> Result<()> {
let changes = repo
.diff_path_statuses(parent, commit)
.map_err(|e| Error::config_error(format!("git diff failed: {}", e)))?;
let vault_root = manager.vault_path().clone();
let graph_handle = manager.link_graph();
for (rel_path, present_in_commit) in changes {
let full_path = vault_root.join(&rel_path);
if present_in_commit {
match manager.parse_file(std::path::Path::new(&rel_path)).await {
Ok(vault_file) => {
let mut graph = graph_handle.write().await;
let _ = graph.remove_file(&full_path);
if let Err(e) = graph.add_file(&vault_file) {
log::warn!("reindex add_file({}) failed: {}", rel_path, e);
}
if let Err(e) = graph.update_links(&vault_file) {
log::warn!("reindex update_links({}) failed: {}", rel_path, e);
}
}
Err(e) => {
log::debug!("reindex skipping {} (parse failed: {})", rel_path, e);
}
}
} else {
let mut graph = graph_handle.write().await;
let _ = graph.remove_file(&full_path);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path as StdPath;
use tempfile::TempDir;
use turbovault_core::config::{ServerConfig, VaultConfig};
use turbovault_git::{Changeset, CommitLocks};
fn init_repo(dir: &StdPath) {
let mut opts = git2::RepositoryInitOptions::new();
opts.initial_head("main");
git2::Repository::init_opts(dir, &opts).unwrap();
}
fn test_server_config(vault_dir: &StdPath) -> ServerConfig {
let mut cfg = ServerConfig::new();
cfg.vaults
.push(VaultConfig::builder("r", vault_dir).build().unwrap());
cfg
}
fn setup() -> (TempDir, Arc<VaultManager>, VaultRepo, Arc<ReindexQueue>) {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let manager = Arc::new(VaultManager::new(test_server_config(tmp.path())).unwrap());
let queue = Arc::new(ReindexQueue::new());
let queue_clone = Arc::clone(&queue);
let hook: turbovault_git::CommitHook =
Arc::new(move |_parent, commit| queue_clone.push(commit));
let repo =
VaultRepo::open_with_locks_and_hook(tmp.path(), Arc::new(CommitLocks::new()), hook)
.unwrap();
(tmp, manager, repo, queue)
}
#[test]
fn queue_starts_empty_and_no_cursor() {
let q = ReindexQueue::new();
assert_eq!(q.pending_count(), 0);
assert_eq!(q.cursor(), None);
assert_eq!(q.pop_front(), None);
}
#[test]
fn push_and_pop_are_fifo() {
let q = ReindexQueue::new();
let a = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
let b = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
q.push(a);
q.push(b);
assert_eq!(q.pending_count(), 2);
assert_eq!(q.pop_front(), Some(a));
assert_eq!(q.pop_front(), Some(b));
assert_eq!(q.pop_front(), None);
}
#[test]
fn advance_cursor_records_last_applied() {
let q = ReindexQueue::new();
let c = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap();
q.advance_cursor(c);
assert_eq!(q.cursor(), Some(c));
}
#[tokio::test]
async fn drain_applies_initial_commit_into_link_graph() {
let (_tmp, manager, repo, queue) = setup();
repo.commit_changeset(&Changeset::new("c").create("hub.md", "# Hub\n\nsee [[other]]"))
.unwrap();
assert_eq!(queue.pending_count(), 1);
let n = queue.drain_through(&repo, &manager).await.unwrap();
assert_eq!(n, 1);
assert_eq!(queue.pending_count(), 0);
let lg = manager.link_graph();
let graph = lg.read().await;
assert_eq!(graph.node_count(), 1, "hub.md added to graph");
assert!(
graph.unresolved_link_count() > 0,
"the [[other]] wikilink is recorded as unresolved"
);
}
#[tokio::test]
async fn drain_removes_deleted_files_from_graph() {
let (_tmp, manager, repo, queue) = setup();
repo.commit_changeset(&Changeset::new("c").create("ghost.md", "# Ghost"))
.unwrap();
queue.drain_through(&repo, &manager).await.unwrap();
assert_eq!(manager.link_graph().read().await.node_count(), 1);
let ghost_blob = VaultRepo::blob_oid_of(b"# Ghost").unwrap();
repo.commit_changeset(&Changeset::new("d").delete("ghost.md", ghost_blob))
.unwrap();
queue.drain_through(&repo, &manager).await.unwrap();
assert_eq!(
manager.link_graph().read().await.node_count(),
0,
"ghost.md removed after delete commit"
);
}
#[tokio::test]
async fn drain_through_handles_multi_commit_burst_in_order() {
let (_tmp, manager, repo, queue) = setup();
repo.commit_changeset(&Changeset::new("c1").create("a.md", "A"))
.unwrap();
repo.commit_changeset(&Changeset::new("c2").create("b.md", "B"))
.unwrap();
repo.commit_changeset(&Changeset::new("c3").create("c.md", "C"))
.unwrap();
assert_eq!(queue.pending_count(), 3);
let n = queue.drain_through(&repo, &manager).await.unwrap();
assert_eq!(n, 3);
assert_eq!(manager.link_graph().read().await.node_count(), 3);
}
#[tokio::test]
async fn drain_skips_commit_with_failed_first_parent_lookup() {
let (_tmp, manager, repo, queue) = setup();
let bogus = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
queue.push(bogus);
let real = repo
.commit_changeset(&Changeset::new("c").create("a.md", "A"))
.unwrap();
assert_eq!(queue.pending_count(), 2, "[bogus, real] queued");
let n = queue.drain_through(&repo, &manager).await.unwrap();
assert_eq!(n, 2, "both the bogus and the real commit are drained");
assert_eq!(queue.pending_count(), 0);
assert_eq!(
manager.link_graph().read().await.node_count(),
1,
"the real commit applied despite the bogus one ahead of it"
);
assert_eq!(queue.cursor(), Some(real.commit));
}
#[tokio::test]
async fn drain_resolves_intra_commit_link_in_one_commit() {
let (_tmp, manager, repo, queue) = setup();
repo.commit_changeset(
&Changeset::new("batch")
.create("one.md", "# One\n\nlinks [[two]]\n")
.create("two.md", "# Two\n"),
)
.unwrap();
assert_eq!(queue.pending_count(), 1, "one commit for the whole batch");
queue.drain_through(&repo, &manager).await.unwrap();
let lg = manager.link_graph();
let graph = lg.read().await;
assert_eq!(graph.node_count(), 2, "both files in the graph");
assert_eq!(
graph.unresolved_link_count(),
0,
"[[two]] should resolve once both files land in one commit"
);
assert_eq!(graph.edge_count(), 1, "one.md -> two.md edge should exist");
}
#[tokio::test]
async fn drain_promotes_link_authored_before_target_across_commits() {
let (_tmp, manager, repo, queue) = setup();
repo.commit_changeset(&Changeset::new("c1").create("linker.md", "see [[target]]\n"))
.unwrap();
queue.drain_through(&repo, &manager).await.unwrap();
{
let lg = manager.link_graph();
let graph = lg.read().await;
assert_eq!(
graph.unresolved_link_count(),
1,
"[[target]] parked unresolved while target absent"
);
assert_eq!(graph.edge_count(), 0);
}
repo.commit_changeset(&Changeset::new("c2").create("target.md", "# Target\n"))
.unwrap();
queue.drain_through(&repo, &manager).await.unwrap();
let lg = manager.link_graph();
let graph = lg.read().await;
assert_eq!(graph.node_count(), 2, "both files in the graph");
assert_eq!(
graph.unresolved_link_count(),
0,
"link promoted once target landed in a later commit"
);
assert_eq!(
graph.edge_count(),
1,
"linker -> target edge after promotion"
);
let bl = graph
.backlinks(&manager.vault_path().join("target.md"))
.unwrap();
assert_eq!(bl.len(), 1, "backlinks(target) must see the linker");
}
#[tokio::test]
async fn drain_move_keeps_backlinks_coherent() {
let (_tmp, manager, repo, queue) = setup();
repo.commit_changeset(
&Changeset::new("c1")
.create("linker.md", "see [[target]]\n")
.create("target.md", "# T\n"),
)
.unwrap();
queue.drain_through(&repo, &manager).await.unwrap();
assert_eq!(
manager.link_graph().read().await.edge_count(),
1,
"precondition: linker -> target edge exists"
);
repo.commit_changeset(
&Changeset::new("move")
.remove("target.md")
.upsert("target-renamed.md", b"# T\n".to_vec())
.upsert("linker.md", b"see [[target-renamed]]\n".to_vec()),
)
.unwrap();
queue.drain_through(&repo, &manager).await.unwrap();
let lg = manager.link_graph();
let graph = lg.read().await;
assert_eq!(graph.node_count(), 2, "linker + renamed target");
assert_eq!(graph.edge_count(), 1, "linker -> renamed edge");
let bl = graph
.backlinks(&manager.vault_path().join("target-renamed.md"))
.unwrap();
assert_eq!(bl.len(), 1, "backlinks(renamed) sees the linker");
let fl = graph
.forward_links(&manager.vault_path().join("linker.md"))
.unwrap();
assert_eq!(fl.len(), 1, "forward_links(linker) sees renamed");
let old = graph
.backlinks(&manager.vault_path().join("target.md"))
.unwrap();
assert!(old.is_empty(), "old target node gone");
}
#[tokio::test]
async fn drain_advances_cursor_to_latest_applied_commit() {
let (_tmp, manager, repo, queue) = setup();
let r1 = repo
.commit_changeset(&Changeset::new("c").create("a.md", "A"))
.unwrap();
queue.drain_through(&repo, &manager).await.unwrap();
assert_eq!(queue.cursor(), Some(r1.commit));
}
#[tokio::test]
async fn push_wakes_notify_so_background_drainer_does_not_poll() {
let q = Arc::new(ReindexQueue::new());
let q2 = Arc::clone(&q);
let woken = Arc::new(std::sync::atomic::AtomicBool::new(false));
let woken_clone = Arc::clone(&woken);
let waiter = tokio::spawn(async move {
let notified = q2.notify().notified();
tokio::pin!(notified);
tokio::time::timeout(std::time::Duration::from_secs(2), &mut notified)
.await
.expect("notify should fire before timeout");
woken_clone.store(true, std::sync::atomic::Ordering::SeqCst);
});
tokio::task::yield_now().await;
let oid = Oid::from_str("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee").unwrap();
q.push(oid);
waiter.await.unwrap();
assert!(woken.load(std::sync::atomic::Ordering::SeqCst));
}
#[tokio::test]
async fn apply_commit_diff_modify_clears_stale_links_then_adds_new() {
let (_tmp, manager, repo, queue) = setup();
repo.commit_changeset(&Changeset::new("c").create("n.md", "see [[alpha]]"))
.unwrap();
queue.drain_through(&repo, &manager).await.unwrap();
let unresolved_after_v1 = manager.link_graph().read().await.unresolved_link_count();
assert!(unresolved_after_v1 >= 1);
let v1_blob = VaultRepo::blob_oid_of(b"see [[alpha]]").unwrap();
repo.commit_changeset(&Changeset::new("u").update("n.md", "see [[beta]]", v1_blob))
.unwrap();
queue.drain_through(&repo, &manager).await.unwrap();
let lg = manager.link_graph();
let graph = lg.read().await;
assert_eq!(graph.node_count(), 1);
let n_path = manager.vault_path().join("n.md");
let targets: Vec<String> = graph
.all_unresolved_links()
.get(&n_path)
.map(|links| links.iter().map(|l| l.target.clone()).collect())
.unwrap_or_default();
assert!(
targets.iter().any(|t| t == "beta"),
"[[beta]] must be recorded after the modify: {targets:?}"
);
assert!(
!targets.iter().any(|t| t == "alpha"),
"stale [[alpha]] must be cleared after the modify: {targets:?}"
);
}
fn make_external_commit(repo_path: &StdPath, file_name: &str, content: &str) -> Oid {
let repo = git2::Repository::open(repo_path).unwrap();
std::fs::write(repo_path.join(file_name), content).unwrap();
let mut index = repo.index().unwrap();
index.add_path(StdPath::new(file_name)).unwrap();
let tree_oid = index.write_tree().unwrap();
index.write().unwrap();
let tree = repo.find_tree(tree_oid).unwrap();
let sig = git2::Signature::now("Ext", "ext@example").unwrap();
let parent = repo
.head()
.ok()
.and_then(|h| h.target())
.and_then(|oid| repo.find_commit(oid).ok());
match parent {
Some(parent) => repo
.commit(Some("HEAD"), &sig, &sig, content, &tree, &[&parent])
.unwrap(),
None => repo
.commit(Some("HEAD"), &sig, &sig, content, &tree, &[])
.unwrap(),
}
}
async fn wait_for_pending(
queue: &ReindexQueue,
target: usize,
timeout: std::time::Duration,
) -> bool {
let start = std::time::Instant::now();
while start.elapsed() < timeout {
if queue.pending_count() >= target {
return true;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
false
}
#[tokio::test]
async fn watch_ref_changes_detects_external_commit() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
make_external_commit(tmp.path(), "seed.md", "seed");
let queue = Arc::new(ReindexQueue::new());
let vault_path = tmp.path().to_path_buf();
let queue_clone = Arc::clone(&queue);
let listener = tokio::spawn(async move {
watch_ref_changes(
vault_path,
queue_clone,
std::time::Duration::from_millis(25),
)
.await;
});
tokio::time::sleep(std::time::Duration::from_millis(75)).await;
assert_eq!(queue.pending_count(), 0, "baseline shouldn't enqueue");
let new_oid = make_external_commit(tmp.path(), "ext.md", "ext-content");
let detected = wait_for_pending(&queue, 1, std::time::Duration::from_millis(500)).await;
assert!(detected, "listener should detect external commit");
assert_eq!(queue.pop_front(), Some(new_oid));
listener.abort();
}
#[tokio::test]
async fn watch_ref_changes_idle_no_pushes() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
make_external_commit(tmp.path(), "seed.md", "seed");
let queue = Arc::new(ReindexQueue::new());
let vault_path = tmp.path().to_path_buf();
let queue_clone = Arc::clone(&queue);
let listener = tokio::spawn(async move {
watch_ref_changes(
vault_path,
queue_clone,
std::time::Duration::from_millis(25),
)
.await;
});
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert_eq!(queue.pending_count(), 0, "idle listener stays quiet");
listener.abort();
}
#[tokio::test]
async fn watch_ref_changes_handles_unborn_baseline() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path());
let queue = Arc::new(ReindexQueue::new());
let vault_path = tmp.path().to_path_buf();
let queue_clone = Arc::clone(&queue);
let listener = tokio::spawn(async move {
watch_ref_changes(
vault_path,
queue_clone,
std::time::Duration::from_millis(25),
)
.await;
});
tokio::time::sleep(std::time::Duration::from_millis(75)).await;
let first_oid = make_external_commit(tmp.path(), "first.md", "first");
let detected = wait_for_pending(&queue, 1, std::time::Duration::from_millis(500)).await;
assert!(detected, "listener should detect the first commit");
assert_eq!(queue.pop_front(), Some(first_oid));
listener.abort();
}
}