//! Git commands dispatch through the registry and synchronize notes safely. mod common; use common::{on_disk, seed, temp_config, temp_vault}; use notescraft_engine::{AppSettings, Error, Registry, Session, adversarial_params, register_core}; use serde_json::{Value, json}; use std::process::Command; const IDS: &[&str] = &["git.init", "git.status", "git.diff", "git.stage", "git.unstage", "git.restore", "git.commit", "git.log", "git.show", "git.restore_version"]; const WRITES: &[&str] = &["git.init", "git.stage", "git.unstage", "git.restore", "git.commit", "git.restore_version"]; fn available() -> bool { if Command::new("git").arg("--version").output().is_ok_and(|out| out.status.success()) { return true; } eprintln!("git missing: skipping real git test"); false } fn run(reg: &Registry, session: &mut Session, id: &str, params: &Value) -> Value { reg.run(id, session, params).unwrap_or_else(|error| panic!("{id}: {error}")) } fn identity(session: &Session) { for (key, value) in [("user.email", "test@example.invalid"), ("user.name", "NotesCraft test")] { assert!(Command::new("git").args(["config", key, value]).current_dir(session.vault().root()).status().expect("config").success()); } } #[test] fn every_git_command_round_trips_and_restore_reindexes() { if !available() { return; } let vault = temp_vault("git-commands"); seed(vault.path(), &[("note.md", "# Original\n")]); let mut session = Session::open(vault.path()).expect("session"); let mut reg = Registry::new(); register_core(&mut reg).expect("register"); run(®, &mut session, "git.init", &Value::Null); assert!(session.git().is_repo()); identity(&session); run(®, &mut session, "git.status", &Value::Null); run(®, &mut session, "git.diff", &json!({"path":"note.md"})); run(®, &mut session, "git.stage", &json!({"paths":["note.md"]})); run(®, &mut session, "git.diff", &json!({"path":"note.md", "staged":true})); run(®, &mut session, "git.unstage", &json!({"paths":["note.md"]})); run(®, &mut session, "git.stage", &json!({"paths":["note.md"]})); run(®, &mut session, "git.commit", &json!({"message":"original"})); seed(vault.path(), &[("note.md", "# Changed\n")]); session.apply_event(notescraft_vault::VaultEvent::Modified("note.md".into())).expect("update index"); session.open_buffer("note.md").expect("buffer"); run(®, &mut session, "git.restore", &json!({"paths":["note.md"]})); assert_eq!(on_disk(vault.path(), "note.md"), "# Original\n"); assert_eq!(session.buffer("note.md").expect("buffer").text(), "# Original\n"); assert_eq!(session.search("Original", 10).expect("search").len(), 1); assert_eq!(session.search("Changed", 10).expect("search"), []); seed(vault.path(), &[("note.md", "# Second\n")]); run(®, &mut session, "git.stage", &json!({"paths":["note.md"]})); run(®, &mut session, "git.commit", &json!({"message":"second"})); run(®, &mut session, "git.log", &json!({"limit":10,"path":"note.md"})); let shown = run(®, &mut session, "git.show", &json!({"rev":"HEAD~1","path":"note.md"})); assert_eq!(shown.get("content"), Some(&json!("# Original\n"))); run(®, &mut session, "git.restore_version", &json!({"rev":"HEAD~1","path":"note.md"})); assert_eq!(session.search("Original", 10).expect("search").len(), 1); assert_eq!(session.search("Second", 10).expect("search"), []); for id in IDS { let spec = reg.get(id).expect("registered"); assert_ne!(spec.doc, ""); assert!(spec.params.iter().all(|param| !param.doc.is_empty())); } } #[test] fn readonly_gates_writes_and_all_git_commands_survive_adversarial_params() { if !available() { return; } let vault = temp_vault("git-adversarial"); let config = temp_config("git-adversarial"); let mut session = Session::open(vault.path()).expect("session"); session.use_settings_dir(config.path()); let mut reg = Registry::new(); register_core(&mut reg).expect("register"); run(®, &mut session, "git.init", &Value::Null); seed(vault.path(), &[("note.md", "# Readonly\n")]); identity(&session); run(®, &mut session, "git.stage", &json!({"paths":["note.md"]})); run(®, &mut session, "git.commit", &json!({"message":"readonly fixture"})); for id in IDS { for params in adversarial_params() { assert!(!matches!(reg.run(id, &mut session, ¶ms), Err(Error::Panicked(_))), "{id}"); } } let mut readonly = Session::open_read_only(vault.path()).expect("readonly"); for id in WRITES { assert!(matches!(reg.run(id, &mut readonly, &Value::Null), Err(Error::Disabled(_))), "{id}"); } for id in ["git.status", "git.diff", "git.log", "git.show"] { assert!(reg.is_enabled(id, &readonly)); } run(®, &mut readonly, "git.status", &Value::Null); run(®, &mut readonly, "git.log", &Value::Null); run(®, &mut readonly, "git.diff", &json!({"path":"note.md"})); run(®, &mut readonly, "git.show", &json!({"rev":"HEAD","path":"note.md"})); for params in [json!({"paths":["../escape"]}), json!({"paths":[1]}), json!({"paths":[".obsidian/config"]})] { assert!(reg.run("git.stage", &mut session, ¶ms).is_err()); } assert!(reg.run("git.log", &mut session, &json!({"limit":-1})).is_err()); } #[test] fn restoring_records_dirty_conflict_without_overwriting_buffer() { if !available() { return; } let vault = temp_vault("git-dirty"); seed(vault.path(), &[("note.md", "# Original\n")]); let mut session = Session::open(vault.path()).expect("session"); let mut reg = Registry::new(); register_core(&mut reg).expect("register"); run(®, &mut session, "git.init", &Value::Null); identity(&session); run(®, &mut session, "git.stage", &json!({"paths":["note.md"]})); run(®, &mut session, "git.commit", &json!({"message":"original"})); session.open_buffer("note.md").expect("open"); session.buffer_mut("note.md").expect("buffer").edit(0..0, "dirty ").expect("edit"); seed(vault.path(), &[("note.md", "# External\n")]); run(®, &mut session, "git.restore", &json!({"paths":["note.md"]})); assert_eq!(session.buffer("note.md").expect("buffer").text(), "dirty # Original\n"); assert_eq!(session.conflicts(), &["note.md"]); run(®, &mut session, "git.restore_version", &json!({"rev":"HEAD","path":"note.md"})); assert_eq!(session.buffer("note.md").expect("buffer").text(), "dirty # Original\n"); } #[test] fn auto_commit_defaults_off_and_clamps_and_persists() { let mut settings = AppSettings::default(); assert_eq!(settings.git.auto_commit_minutes, 0); settings.git.auto_commit_minutes = 999; assert!(settings.validate().iter().any(|note| note.contains("git.auto_commit_minutes"))); assert_eq!(settings.git.auto_commit_minutes, 240); let config = temp_config("git-settings"); notescraft_engine::save_at(config.path(), &settings).expect("save"); let (loaded, _) = notescraft_engine::load_at(config.path()); assert_eq!(loaded.git, settings.git); settings.git.auto_commit_minutes = -1; assert_eq!(settings.validate().len(), 1); assert_eq!(settings.git.auto_commit_minutes, 0); let vault = temp_vault("git-settings"); let mut session = Session::open(vault.path()).expect("session"); session.use_settings_dir(config.path()); let mut registry = Registry::new(); register_core(&mut registry).expect("registry"); run(®istry, &mut session, "settings.set", &json!({"key":"git.auto_commit_minutes", "value":15})); assert_eq!(notescraft_engine::load_at(config.path()).0.git.auto_commit_minutes, 15); }