use git2::{Repository, Signature};
use gity_daemon::{NngClient, NngServer, Runtime};
use gity_git::{working_tree_status, RepoConfigurator};
use gity_ipc::{
DaemonCommand, DaemonResponse, DaemonService, FsMonitorSnapshot, JobKind, ValidatedPath,
};
use gity_storage::{InMemoryMetadataStore, MetadataStore, SledMetadataStore};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use tempfile::TempDir;
use tokio::time::sleep;
fn validated_path(path: &Path) -> ValidatedPath {
ValidatedPath::new(path.to_path_buf()).expect("valid path for test")
}
fn create_test_repo(dir: &Path) -> Repository {
let repo = Repository::init(dir).expect("init repo");
let file_path = dir.join("README.md");
fs::write(&file_path, "# Test Repo\n").expect("write file");
{
let mut index = repo.index().expect("get index");
index
.add_path(Path::new("README.md"))
.expect("add to index");
index.write().expect("write index");
let tree_id = index.write_tree().expect("write tree");
let tree = repo.find_tree(tree_id).expect("find tree");
let sig = Signature::now("Test", "test@example.com").expect("signature");
repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.expect("commit");
}
repo
}
fn modify_file(dir: &Path, filename: &str, content: &str) {
let path = dir.join(filename);
fs::write(&path, content).expect("write file");
}
fn create_untracked_file(dir: &Path, filename: &str, content: &str) {
let path = dir.join(filename);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ok();
}
fs::write(&path, content).expect("write file");
}
#[test]
fn test_working_tree_status_clean_repo() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let status = working_tree_status(dir.path(), &[]).expect("get status");
assert!(status.is_empty(), "Clean repo should have no dirty paths");
}
#[test]
fn test_working_tree_status_modified_file() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
modify_file(dir.path(), "README.md", "# Modified\n");
let status = working_tree_status(dir.path(), &[]).expect("get status");
assert_eq!(status.len(), 1);
assert_eq!(status[0], PathBuf::from("README.md"));
}
#[test]
fn test_working_tree_status_untracked_file() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
create_untracked_file(dir.path(), "new_file.txt", "hello");
let status = working_tree_status(dir.path(), &[]).expect("get status");
assert_eq!(status.len(), 1);
assert_eq!(status[0], PathBuf::from("new_file.txt"));
}
#[test]
fn test_working_tree_status_multiple_changes() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
modify_file(dir.path(), "README.md", "# Modified\n");
create_untracked_file(dir.path(), "file1.txt", "one");
create_untracked_file(dir.path(), "file2.txt", "two");
let status = working_tree_status(dir.path(), &[]).expect("get status");
assert_eq!(status.len(), 3);
}
#[test]
fn test_working_tree_status_with_path_filter() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
modify_file(dir.path(), "README.md", "# Modified\n");
create_untracked_file(dir.path(), "src/main.rs", "fn main() {}");
let status = working_tree_status(dir.path(), &[PathBuf::from("src")]).expect("get status");
assert_eq!(status.len(), 1);
assert!(status[0].starts_with("src"));
}
#[test]
fn test_repo_configurator_applies_settings() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let configurator = RepoConfigurator::open(dir.path()).expect("open repo");
configurator
.apply_performance_settings(Some("gity fsmonitor-helper"))
.expect("apply settings");
let config = fs::read_to_string(dir.path().join(".git/config")).expect("read config");
assert!(config.contains("fsmonitor"));
assert!(config.contains("untrackedCache"));
}
#[test]
fn test_repo_configurator_clears_settings() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let configurator = RepoConfigurator::open(dir.path()).expect("open repo");
configurator
.apply_performance_settings(Some("gity fsmonitor-helper"))
.expect("apply settings");
configurator
.clear_performance_settings()
.expect("clear settings");
let config = fs::read_to_string(dir.path().join(".git/config")).expect("read config");
assert!(!config.contains("fsmonitor = gity"));
}
#[test]
fn test_register_real_repo_in_memory_store() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let store = InMemoryMetadataStore::new();
let meta = store
.register_repo(dir.path().to_path_buf())
.expect("register");
assert_eq!(meta.repo_path, dir.path());
assert_eq!(meta.pending_jobs, 0);
}
#[test]
fn test_register_real_repo_in_sled_store() {
let repo_dir = TempDir::new().unwrap();
let _repo = create_test_repo(repo_dir.path());
let db_dir = TempDir::new().unwrap();
let store = SledMetadataStore::open(db_dir.path()).expect("open sled");
let meta = store
.register_repo(repo_dir.path().to_path_buf())
.expect("register");
assert_eq!(meta.repo_path, repo_dir.path());
let loaded = store.get_repo(repo_dir.path()).expect("get repo");
assert!(loaded.is_some());
}
#[test]
fn test_dirty_paths_tracked_correctly() {
let store = InMemoryMetadataStore::new();
let repo_path = PathBuf::from("/tmp/test_repo");
store.register_repo(repo_path.clone()).expect("register");
store
.mark_dirty_path(&repo_path, PathBuf::from("file1.txt"))
.expect("mark 1");
store
.mark_dirty_path(&repo_path, PathBuf::from("file2.txt"))
.expect("mark 2");
store
.mark_dirty_path(&repo_path, PathBuf::from("file1.txt"))
.expect("mark dup");
let count = store.dirty_path_count(&repo_path).expect("count");
assert_eq!(count, 2);
let drained = store.drain_dirty_paths(&repo_path).expect("drain");
assert_eq!(drained.len(), 2);
let count_after = store.dirty_path_count(&repo_path).expect("count after");
assert_eq!(count_after, 0);
}
#[tokio::test]
async fn test_daemon_register_and_status_flow() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let store = InMemoryMetadataStore::new();
let runtime = Runtime::new(store, None);
let service = runtime.service_handle();
let response = service
.execute(DaemonCommand::RegisterRepo {
repo_path: validated_path(dir.path()),
})
.await
.expect("register");
assert!(matches!(response, DaemonResponse::Ack(_)));
let response = service
.execute(DaemonCommand::Status {
repo_path: validated_path(dir.path()),
known_generation: None,
})
.await
.expect("status");
match response {
DaemonResponse::RepoStatus(detail) => {
assert_eq!(detail.repo_path, dir.path());
}
other => panic!("unexpected response: {:?}", other),
}
}
#[tokio::test]
async fn test_daemon_status_detects_real_file_changes() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let store = InMemoryMetadataStore::new();
let runtime = Runtime::new(store, None);
let service = runtime.service_handle();
service
.execute(DaemonCommand::RegisterRepo {
repo_path: validated_path(dir.path()),
})
.await
.expect("register");
let _response = service
.execute(DaemonCommand::Status {
repo_path: validated_path(dir.path()),
known_generation: None,
})
.await
.expect("status");
modify_file(dir.path(), "README.md", "# Changed content\n");
let response = service
.execute(DaemonCommand::Status {
repo_path: validated_path(dir.path()),
known_generation: None,
})
.await
.expect("status after change");
match response {
DaemonResponse::RepoStatus(detail) => {
assert!(
detail.dirty_paths.contains(&PathBuf::from("README.md")),
"Should detect modified README.md, got: {:?}",
detail.dirty_paths
);
}
other => panic!("unexpected response: {:?}", other),
}
}
#[tokio::test]
async fn test_daemon_fsmonitor_snapshot() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let store = InMemoryMetadataStore::new();
let runtime = Runtime::new(store, None);
let service = runtime.service_handle();
service
.execute(DaemonCommand::RegisterRepo {
repo_path: validated_path(dir.path()),
})
.await
.expect("register");
modify_file(dir.path(), "README.md", "# Changed\n");
create_untracked_file(dir.path(), "new.txt", "new file");
let response = service
.execute(DaemonCommand::FsMonitorSnapshot {
repo_path: validated_path(dir.path()),
last_seen_generation: None,
})
.await
.expect("snapshot");
match response {
DaemonResponse::FsMonitorSnapshot(snapshot) => {
assert_eq!(snapshot.repo_path, dir.path());
assert!(!snapshot.dirty_paths.is_empty() || snapshot.generation > 0);
}
other => panic!("unexpected response: {:?}", other),
}
}
#[tokio::test]
async fn test_daemon_job_queueing() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let store = InMemoryMetadataStore::new();
let runtime = Runtime::new(store, None);
let service = runtime.service_handle();
service
.execute(DaemonCommand::RegisterRepo {
repo_path: validated_path(dir.path()),
})
.await
.expect("register");
let response = service
.execute(DaemonCommand::QueueJob {
repo_path: validated_path(dir.path()),
job: JobKind::Prefetch,
})
.await
.expect("queue job");
assert!(matches!(response, DaemonResponse::Ack(_)));
let response = service
.execute(DaemonCommand::HealthCheck)
.await
.expect("health");
match response {
DaemonResponse::Health(health) => {
assert!(health.pending_jobs >= 1);
}
other => panic!("unexpected response: {:?}", other),
}
}
#[tokio::test]
async fn test_daemon_repo_health_diagnostics() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let store = InMemoryMetadataStore::new();
let runtime = Runtime::new(store, None);
let service = runtime.service_handle();
service
.execute(DaemonCommand::RegisterRepo {
repo_path: validated_path(dir.path()),
})
.await
.expect("register");
let response = service
.execute(DaemonCommand::RepoHealth {
repo_path: validated_path(dir.path()),
})
.await
.expect("repo health");
match response {
DaemonResponse::RepoHealth(detail) => {
assert_eq!(detail.repo_path, dir.path());
assert!(detail.sled_ok);
assert!(!detail.needs_reconciliation);
}
other => panic!("unexpected response: {:?}", other),
}
}
#[tokio::test]
async fn test_daemon_list_repos() {
let dir1 = TempDir::new().unwrap();
let dir2 = TempDir::new().unwrap();
let _repo1 = create_test_repo(dir1.path());
let _repo2 = create_test_repo(dir2.path());
let store = InMemoryMetadataStore::new();
let runtime = Runtime::new(store, None);
let service = runtime.service_handle();
service
.execute(DaemonCommand::RegisterRepo {
repo_path: validated_path(dir1.path()),
})
.await
.expect("register 1");
service
.execute(DaemonCommand::RegisterRepo {
repo_path: validated_path(dir2.path()),
})
.await
.expect("register 2");
let response = service
.execute(DaemonCommand::ListRepos)
.await
.expect("list");
match response {
DaemonResponse::RepoList(list) => {
assert_eq!(list.len(), 2);
}
other => panic!("unexpected response: {:?}", other),
}
}
#[tokio::test]
async fn test_daemon_unregister_repo() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let store = InMemoryMetadataStore::new();
let runtime = Runtime::new(store, None);
let service = runtime.service_handle();
service
.execute(DaemonCommand::RegisterRepo {
repo_path: validated_path(dir.path()),
})
.await
.expect("register");
let response = service
.execute(DaemonCommand::UnregisterRepo {
repo_path: validated_path(dir.path()),
})
.await
.expect("unregister");
assert!(matches!(response, DaemonResponse::Ack(_)));
let response = service
.execute(DaemonCommand::ListRepos)
.await
.expect("list");
match response {
DaemonResponse::RepoList(list) => {
assert!(list.is_empty());
}
other => panic!("unexpected response: {:?}", other),
}
}
#[tokio::test]
async fn test_nng_client_server_with_real_repo() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let store = InMemoryMetadataStore::new();
let runtime = Runtime::new(store, None);
let shutdown = runtime.shutdown_signal();
let shared = runtime.shared();
let port = 19000 + (std::process::id() % 1000) as u16;
let address = format!("tcp://127.0.0.1:{}", port);
let server = NngServer::new(address.clone(), shared, shutdown.clone());
let server_handle = tokio::spawn(async move {
let _ = server.run().await;
});
sleep(Duration::from_millis(100)).await;
let client = NngClient::new(address);
let response = client
.execute(DaemonCommand::RegisterRepo {
repo_path: validated_path(dir.path()),
})
.await
.expect("register via client");
assert!(matches!(response, DaemonResponse::Ack(_)));
let response = client
.execute(DaemonCommand::Status {
repo_path: validated_path(dir.path()),
known_generation: None,
})
.await
.expect("status via client");
assert!(matches!(response, DaemonResponse::RepoStatus(_)));
shutdown.shutdown();
let _ = server_handle.await;
}
#[tokio::test]
async fn test_generation_increments_on_changes() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let store = InMemoryMetadataStore::new();
let runtime = Runtime::new(store, None);
let service = runtime.service_handle();
service
.execute(DaemonCommand::RegisterRepo {
repo_path: validated_path(dir.path()),
})
.await
.expect("register");
let response1 = service
.execute(DaemonCommand::Status {
repo_path: validated_path(dir.path()),
known_generation: None,
})
.await
.expect("status 1");
let gen1 = match response1 {
DaemonResponse::RepoStatus(detail) => detail.generation,
other => panic!("unexpected: {:?}", other),
};
modify_file(dir.path(), "README.md", "# Changed again\n");
let response2 = service
.execute(DaemonCommand::Status {
repo_path: validated_path(dir.path()),
known_generation: None, })
.await
.expect("status 2");
let gen2 = match response2 {
DaemonResponse::RepoStatus(detail) => detail.generation,
DaemonResponse::RepoStatusUnchanged { generation, .. } => generation,
other => panic!("unexpected: {:?}", other),
};
assert!(gen2 >= gen1, "Generation should be at least the same");
}
#[tokio::test]
async fn test_full_workflow_register_modify_status() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let store = InMemoryMetadataStore::new();
let runtime = Runtime::new(store, None);
let service = runtime.service_handle();
let response = service
.execute(DaemonCommand::RegisterRepo {
repo_path: validated_path(dir.path()),
})
.await
.expect("register");
assert!(matches!(response, DaemonResponse::Ack(_)));
modify_file(dir.path(), "README.md", "# New content\n");
create_untracked_file(dir.path(), "src/lib.rs", "pub fn hello() {}");
create_untracked_file(dir.path(), "tests/test.rs", "#[test] fn it_works() {}");
let response = service
.execute(DaemonCommand::Status {
repo_path: validated_path(dir.path()),
known_generation: None,
})
.await
.expect("status");
match response {
DaemonResponse::RepoStatus(detail) => {
assert_eq!(detail.repo_path, dir.path());
assert!(!detail.dirty_paths.is_empty(), "Should have dirty paths");
let has_readme = detail.dirty_paths.iter().any(|p| p.ends_with("README.md"));
assert!(has_readme, "Should detect modified README.md");
}
other => panic!("unexpected response: {:?}", other),
}
let response = service
.execute(DaemonCommand::HealthCheck)
.await
.expect("health");
match response {
DaemonResponse::Health(health) => {
assert_eq!(health.repo_count, 1);
}
other => panic!("unexpected: {:?}", other),
}
}
#[test]
fn test_fsmonitor_protocol_output_format() {
let snapshot = FsMonitorSnapshot {
repo_path: PathBuf::from("/test/repo"),
generation: 42,
dirty_paths: vec![PathBuf::from("src/main.rs"), PathBuf::from("Cargo.toml")],
};
let mut output = Vec::new();
output.extend_from_slice(snapshot.generation.to_string().as_bytes());
output.push(0); for path in &snapshot.dirty_paths {
if let Some(s) = path.to_str() {
output.extend_from_slice(s.as_bytes());
output.push(0); }
}
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.starts_with("42\0"));
assert!(output_str.contains("src/main.rs\0"));
assert!(output_str.contains("Cargo.toml\0"));
}
#[test]
fn test_git_maintenance_command_exists() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let output = Command::new("git")
.args(["maintenance", "run", "--task=prefetch"])
.current_dir(dir.path())
.output();
assert!(
output.is_ok(),
"git maintenance command should be available"
);
}
#[test]
fn test_git_status_command_works() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
modify_file(dir.path(), "README.md", "# Changed\n");
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(dir.path())
.output()
.expect("git status should work");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("README.md"),
"git status should show modified file"
);
}
#[test]
fn test_working_tree_status_matches_git_status() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
modify_file(dir.path(), "README.md", "# Changed\n");
create_untracked_file(dir.path(), "new_file.txt", "new");
let our_status = working_tree_status(dir.path(), &[]).expect("our status");
let output = Command::new("git")
.args(["status", "--porcelain", "-uall"])
.current_dir(dir.path())
.output()
.expect("git status");
let git_output = String::from_utf8_lossy(&output.stdout);
for path in &our_status {
let path_str = path.to_string_lossy();
assert!(
git_output.contains(&*path_str),
"Our status path '{}' should be in git status output: {}",
path_str,
git_output
);
}
let git_lines: Vec<_> = git_output.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
our_status.len(),
git_lines.len(),
"Status count should match: ours={:?}, git={:?}",
our_status,
git_lines
);
}
#[test]
fn test_git_config_fsmonitor_setting() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let configurator = RepoConfigurator::open(dir.path()).expect("open");
configurator
.apply_performance_settings(Some("gity fsmonitor-helper 2"))
.expect("apply");
let output = Command::new("git")
.args(["config", "--get", "core.fsmonitor"])
.current_dir(dir.path())
.output()
.expect("git config");
let value = String::from_utf8_lossy(&output.stdout);
assert!(
value.contains("gity fsmonitor-helper"),
"fsmonitor should be set: {}",
value
);
configurator.clear_performance_settings().expect("clear");
let output = Command::new("git")
.args(["config", "--get", "core.fsmonitor"])
.current_dir(dir.path())
.output()
.expect("git config after clear");
assert!(
!output.status.success(),
"fsmonitor config should be removed"
);
}
#[tokio::test]
async fn test_fsmonitor_filters_git_internal_paths() {
let dir = TempDir::new().unwrap();
let _repo = create_test_repo(dir.path());
let store = InMemoryMetadataStore::new();
store
.register_repo(dir.path().to_path_buf())
.expect("register");
store
.mark_dirty_path(dir.path(), PathBuf::from("README.md"))
.expect("mark");
store
.mark_dirty_path(dir.path(), PathBuf::from("src/lib.rs"))
.expect("mark");
store
.mark_dirty_path(dir.path(), PathBuf::from(".git/HEAD"))
.expect("mark");
store
.mark_dirty_path(dir.path(), PathBuf::from(".git/index"))
.expect("mark");
store
.mark_dirty_path(dir.path(), PathBuf::from(".git/refs/heads/main"))
.expect("mark");
let runtime = Runtime::new(store, None);
let service = runtime.service_handle();
let response = service
.execute(DaemonCommand::FsMonitorSnapshot {
repo_path: validated_path(dir.path()),
last_seen_generation: None,
})
.await
.expect("fsmonitor");
match response {
DaemonResponse::FsMonitorSnapshot(snapshot) => {
let has_readme = snapshot
.dirty_paths
.iter()
.any(|p| p.ends_with("README.md"));
let has_lib = snapshot
.dirty_paths
.iter()
.any(|p| p.ends_with("src/lib.rs"));
assert!(has_readme, "Should contain README.md");
assert!(has_lib, "Should contain src/lib.rs");
let has_git_head = snapshot
.dirty_paths
.iter()
.any(|p| p.to_string_lossy().contains(".git"));
assert!(
!has_git_head,
"Should NOT contain .git paths, but got: {:?}",
snapshot.dirty_paths
);
}
other => panic!("unexpected: {:?}", other),
}
}
#[test]
fn test_branch_switch_detected_via_file_changes() {
let dir = TempDir::new().unwrap();
let repo = create_test_repo(dir.path());
let head = repo.head().expect("head");
let commit = head.peel_to_commit().expect("commit");
repo.branch("feature", &commit, false)
.expect("create branch");
repo.set_head("refs/heads/feature").expect("set head");
repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
.expect("checkout");
modify_file(dir.path(), "README.md", "# Feature branch content\n");
{
let mut index = repo.index().expect("index");
index.add_path(Path::new("README.md")).expect("add");
index.write().expect("write");
let tree_id = index.write_tree().expect("tree");
let tree = repo.find_tree(tree_id).expect("find tree");
let parent = repo.head().expect("head").peel_to_commit().expect("commit");
let sig = Signature::now("Test", "test@example.com").expect("sig");
repo.commit(
Some("HEAD"),
&sig,
&sig,
"Feature commit",
&tree,
&[&parent],
)
.expect("commit");
}
let default_branch = if repo.find_branch("main", git2::BranchType::Local).is_ok() {
"refs/heads/main"
} else {
"refs/heads/master"
};
repo.set_head(default_branch).expect("set head back");
repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
.expect("checkout back");
let content = fs::read_to_string(dir.path().join("README.md")).expect("read");
assert!(
content.contains("# Test Repo"),
"Should have original content after branch switch"
);
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(dir.path())
.output()
.expect("git status");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.trim().is_empty(),
"Working tree should be clean after checkout: {}",
stdout
);
}