Files
Marcos Paulo f3f1f51d9e feat(git): git versioning crate and engine commands
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 01:05:06 -03:00

160 lines
7.6 KiB
Rust

//! 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>, 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(&reg, &mut session, "git.init", &Value::Null);
assert!(session.git().is_repo());
identity(&session);
run(&reg, &mut session, "git.status", &Value::Null);
run(&reg, &mut session, "git.diff", &json!({"path":"note.md"}));
run(&reg, &mut session, "git.stage", &json!({"paths":["note.md"]}));
run(&reg, &mut session, "git.diff", &json!({"path":"note.md", "staged":true}));
run(&reg, &mut session, "git.unstage", &json!({"paths":["note.md"]}));
run(&reg, &mut session, "git.stage", &json!({"paths":["note.md"]}));
run(&reg, &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(&reg, &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(&reg, &mut session, "git.stage", &json!({"paths":["note.md"]}));
run(&reg, &mut session, "git.commit", &json!({"message":"second"}));
run(&reg, &mut session, "git.log", &json!({"limit":10,"path":"note.md"}));
let shown = run(&reg, &mut session, "git.show", &json!({"rev":"HEAD~1","path":"note.md"}));
assert_eq!(shown.get("content"), Some(&json!("# Original\n")));
run(&reg, &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(&reg, &mut session, "git.init", &Value::Null);
seed(vault.path(), &[("note.md", "# Readonly\n")]);
identity(&session);
run(&reg, &mut session, "git.stage", &json!({"paths":["note.md"]}));
run(&reg, &mut session, "git.commit", &json!({"message":"readonly fixture"}));
for id in IDS {
for params in adversarial_params() {
assert!(!matches!(reg.run(id, &mut session, &params), 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(&reg, &mut readonly, "git.status", &Value::Null);
run(&reg, &mut readonly, "git.log", &Value::Null);
run(&reg, &mut readonly, "git.diff", &json!({"path":"note.md"}));
run(&reg, &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, &params).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(&reg, &mut session, "git.init", &Value::Null);
identity(&session);
run(&reg, &mut session, "git.stage", &json!({"paths":["note.md"]}));
run(&reg, &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(&reg, &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(&reg, &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(&registry, &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);
}