diff --git a/TODO.md b/TODO.md index 9c58c48..252dc13 100644 --- a/TODO.md +++ b/TODO.md @@ -299,7 +299,7 @@ court; the declared direction is that it becomes the source control itself." - [ ] PR-O1 fix/rename-rename-dispute - [ ] PR-O2 docs/positioning -- [ ] PR-O3 feat/store-court +- [x] PR-O3 feat/store-court - [ ] PR-O4 feat/oot-update Mark your PR's box `[x]` in the same branch before opening it. diff --git a/src/court.rs b/src/court.rs new file mode 100644 index 0000000..f655878 --- /dev/null +++ b/src/court.rs @@ -0,0 +1,502 @@ +//! The court side of the store: adjudicating a stored change straight from +//! its trees, and persisting the verdict as sidecar governance data under +//! `.oot/`. +//! +//! Sidecar layout (governance never mutates [`ChangeRecord`] or content +//! addressing): +//! - `.oot/dockets/.json` — the latest docket for a change, +//! overwritten on every re-run. +//! - `.oot/adjudications.jsonl` — append-only audit trail, one line per run, +//! mirroring `export-log.jsonl` style. +//! +//! Coupling stays loose: dockets reference change ids; neither the DAG nor +//! export ever references dockets. + +use crate::change::{Change, Snapshot, Source}; +use crate::dispute::{finalize_adjudication, Docket, Kind, Severity}; +use crate::engine::Engine; +use crate::policy::MeaningPolicy; +use crate::store::{now_epoch, ChangeRecord, Store}; +use crate::visibility::VisibilityPolicy; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Directory under `.oot/` holding persisted dockets. +pub const DOCKETS_DIR: &str = "dockets"; +/// Append-only audit log under `.oot/`, one adjudication per line. +pub const ADJUDICATIONS_LOG: &str = "adjudications.jsonl"; +/// Envelope schema version; bump on any shape change. +pub const DOCKET_SCHEMA: u32 = 1; + +/// The persisted envelope at `.oot/dockets/.json`: provenance +/// wrapped around the rendered [`Docket`] verbatim. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistedDocket { + pub schema: u32, + /// Full change id this docket belongs to. + pub change: String, + /// Head tree sha the adjudication ran against. + pub tree: String, + /// Parent change ids; the first parent is the v1 base. + pub parents: Vec, + /// Seconds since the Unix epoch when this run happened. + pub adjudicated_at: u64, + /// Fingerprint of the meaning + visibility policies used. + pub policy_key: String, + /// The rendered docket, exactly as printed. + pub docket: Docket, +} + +/// Adjudicate stored change `id`: head is the change's own tree, base is its +/// FIRST parent's tree (a root change diffs against an empty snapshot), and +/// authors come from the record. Merge changes are judged against their +/// first parent in v1. Provenance follows `source_sha`: `[git]` for imported +/// changes, `[oot]` for native ones. +/// +/// `intent` and `authors` override the record-derived defaults when given. +pub fn adjudicate_change( + store: &Store, + id: &str, + engine: &Engine, + meaning_policy: &MeaningPolicy, + visibility_policy: &VisibilityPolicy, + intent: Option, + authors_override: Option>, +) -> Result { + let record = store.get_change(id)?; + let head = store.snapshot_from_tree(&record.tree)?; + let base = match record.parents.first() { + Some(parent) => store.snapshot_from_tree(&store.get_change(parent)?.tree)?, + None => Snapshot::default(), + }; + + // Provenance tag matches `oot log`: [git] for imported, [oot] for native. + let imported = record.source_sha.is_some(); + let authors = authors_override.unwrap_or_else(|| vec![record.author.name.clone()]); + let short = short_id(id); + + let change = Change { + name: short.clone(), + source: if imported { + Source::Git + } else { + Source::Memory + }, + base_ref: base_label(&record), + head_ref: short.clone(), + base, + head, + authors, + intent: intent.clone(), + }; + + let vis_disputes = visibility_policy.check(&change); + let cloaked = vis_disputes + .iter() + .any(|d| d.kind == Kind::Visibility && d.severity == Severity::High); + let mut disputes = engine.diff_snapshots(&change.base, &change.head)?; + disputes.extend(vis_disputes); + + let (disputes, intent, verdict) = finalize_adjudication( + disputes, + &change.base.files, + &change.head.files, + intent.clone(), + cloaked, + visibility_policy.embargo_until.is_some(), + meaning_policy, + ); + + let docket = Docket { + change: short, + source: if imported { "git" } else { "oot" }.to_string(), + base: change.base_ref.clone(), + head: change.head_ref.clone(), + disputes, + intent, + authors: change.authors.clone(), + verdict, + embargo: visibility_policy.embargo_note(), + }; + + Ok(PersistedDocket { + schema: DOCKET_SCHEMA, + change: id.to_string(), + tree: record.tree.clone(), + parents: record.parents.clone(), + adjudicated_at: now_epoch(), + policy_key: policy_key(meaning_policy, visibility_policy), + docket, + }) +} + +/// Persist the envelope to `.oot/dockets/.json`, overwriting any previous +/// adjudication of the same change. +pub fn save_docket(store: &Store, persisted: &PersistedDocket) -> Result<()> { + let dir = store.path().join(DOCKETS_DIR); + std::fs::create_dir_all(&dir)?; + let path = dir.join(format!("{}.json", persisted.change)); + std::fs::write(&path, serde_json::to_vec_pretty(persisted)?) + .with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +/// Load the persisted envelope for an already-resolved change id. +pub fn load_docket(store: &Store, id: &str) -> Result { + let path = store.path().join(DOCKETS_DIR).join(format!("{id}.json")); + let bytes = std::fs::read(&path).with_context(|| { + format!( + "no persisted docket for {} (run `oot adjudicate --change {}`)", + short_id(id), + short_id(id) + ) + })?; + Ok(serde_json::from_slice(&bytes)?) +} + +/// Append one audit line to `.oot/adjudications.jsonl`: append-only history, +/// not cache — re-running a change adds a line rather than replacing one. +pub fn log_adjudication(store: &Store, persisted: &PersistedDocket) -> Result<()> { + let entry = serde_json::json!({ + "epoch": now_epoch(), + "event": "adjudicated", + "change": persisted.change, + "verdict": persisted.docket.verdict, + "meaning": persisted.docket.meaning_count(), + "visibility": persisted.docket.visibility_count(), + "policy_key": persisted.policy_key, + }); + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(store.path().join(ADJUDICATIONS_LOG))?; + writeln!(f, "{entry}")?; + Ok(()) +} + +/// Stable fingerprint of the two policies an adjudication ran under — the +/// export cache key's trick applied to governance: canonical serialization, +/// then hash. Any new policy field MUST join the canonical string or stale +/// keys will look equal after a policy change. +pub fn policy_key(meaning: &MeaningPolicy, visibility: &VisibilityPolicy) -> String { + let mut canon = String::new(); + canon.push_str("block_on="); + push_list(&mut canon, &meaning.block_on); + canon.push_str(";review_on="); + push_list(&mut canon, &meaning.review_on); + canon.push_str(";private_paths="); + push_list(&mut canon, &visibility.private_paths); + canon.push_str(";private_branches="); + push_list(&mut canon, &visibility.private_branches); + canon.push_str(";embargo_until="); + canon.push_str(visibility.embargo_until.as_deref().unwrap_or("")); + fnv1a(canon.as_bytes()) +} + +fn push_list(out: &mut String, items: &[String]) { + for item in items { + out.push_str(item); + out.push('\u{1f}'); + } +} + +/// FNV-1a 64-bit, hex-encoded. Not cryptographic — it only has to be stable +/// and good enough to notice that a policy file changed. +fn fnv1a(bytes: &[u8]) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + hash ^= u64::from(b); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") +} + +/// Display form of a change id, matching what `oot log` prints. +fn short_id(id: &str) -> String { + id.chars().take(7).collect() +} + +/// Docket label for the base snapshot: the first parent's short id, or +/// `(root)` when the change has no parents. +fn base_label(record: &ChangeRecord) -> String { + match record.parents.first() { + Some(p) => short_id(p), + None => "(root)".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::{Identity, WorkFile}; + + fn temp_store(tag: &str) -> (std::path::PathBuf, Store) { + let tmp = std::env::temp_dir().join(format!("oot-court-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + let store = Store::init(&tmp).unwrap(); + (tmp, store) + } + + fn identity() -> Identity { + Identity { + name: "Kriday".into(), + email: "k@oot.dev".into(), + time: 1_700_000_000, + offset: "+0530".into(), + } + } + + #[test] + fn test_read_blob_and_snapshot_from_tree_roundtrip() { + let (_tmp, store) = temp_store("snap"); + let files = vec![ + WorkFile { + path: "lib.rs".into(), + contents: b"pub fn greet() -> &'static str { \"hi\" }\n".to_vec(), + executable: false, + }, + WorkFile { + path: "deep/nested/bin.dat".into(), + contents: vec![0x00, 0xff, 0x7f, 0x80], + executable: false, + }, + WorkFile { + path: "run.sh".into(), + contents: b"#!/bin/sh\n".to_vec(), + executable: true, + }, + ]; + let tree = store.write_tree_from_files(&files).unwrap(); + + let snap = store.snapshot_from_tree(&tree).unwrap(); + assert_eq!(snap.files.len(), 3); + assert_eq!( + snap.files["lib.rs"], + b"pub fn greet() -> &'static str { \"hi\" }\n".to_vec() + ); + assert_eq!( + snap.files["deep/nested/bin.dat"], + vec![0x00, 0xff, 0x7f, 0x80] + ); + + let entries = store.tree_files(&tree).unwrap(); + let (sha, _) = entries.get("run.sh").unwrap(); + assert_eq!(store.read_blob(sha).unwrap(), b"#!/bin/sh\n".to_vec()); + assert!(store.read_blob("does-not-exist").is_err()); + } + + #[test] + fn test_docket_save_load_roundtrip_and_overwrite() { + let (_tmp, store) = temp_store("docket"); + let mk = |at: u64| PersistedDocket { + schema: DOCKET_SCHEMA, + change: "abc1234567890".into(), + tree: "tree-sha".into(), + parents: vec![], + adjudicated_at: at, + policy_key: "key-1".into(), + docket: Docket { + change: "abc1234".into(), + source: "oot".into(), + base: "(root)".into(), + head: "abc1234".into(), + disputes: vec![], + intent: "no files changed".into(), + authors: vec!["Kriday".into()], + verdict: crate::dispute::Verdict::Adjudicated, + embargo: None, + }, + }; + + save_docket(&store, &mk(42)).unwrap(); + let loaded = load_docket(&store, "abc1234567890").unwrap(); + assert_eq!(loaded.adjudicated_at, 42); + assert_eq!(loaded.docket.change, "abc1234"); + assert_eq!(loaded.policy_key, "key-1"); + assert_eq!(loaded.schema, DOCKET_SCHEMA); + + // Re-running overwrites the sidecar instead of stacking copies. + save_docket(&store, &mk(43)).unwrap(); + assert_eq!( + load_docket(&store, "abc1234567890").unwrap().adjudicated_at, + 43 + ); + + // Unknown id fails loudly with the next-step hint. + let err = load_docket(&store, "ffffffff").unwrap_err().to_string(); + assert!(err.contains("no persisted docket"), "{err}"); + } + + #[test] + fn test_audit_log_appends_one_line_per_run() { + let (_tmp, store) = temp_store("audit"); + let persisted = PersistedDocket { + schema: DOCKET_SCHEMA, + change: "aaaa1111".into(), + tree: "t".into(), + parents: vec![], + adjudicated_at: 1, + policy_key: "k".into(), + docket: Docket { + change: "aaaa111".into(), + source: "oot".into(), + base: "(root)".into(), + head: "aaaa111".into(), + disputes: vec![crate::dispute::Dispute::empty_change()], + intent: "no files changed".into(), + authors: vec!["K".into()], + verdict: crate::dispute::Verdict::Adjudicated, + embargo: None, + }, + }; + + log_adjudication(&store, &persisted).unwrap(); + log_adjudication(&store, &persisted).unwrap(); + + let log = std::fs::read_to_string(store.path().join(ADJUDICATIONS_LOG)).unwrap(); + let lines: Vec<&str> = log.lines().collect(); + assert_eq!(lines.len(), 2, "{log}"); + assert!(lines[0].contains("\"event\":\"adjudicated\""), "{log}"); + assert!(lines[0].contains("\"change\":\"aaaa1111\""), "{log}"); + assert!(lines[0].contains("\"verdict\":\"adjudicated\""), "{log}"); + assert!(lines[0].contains("\"meaning\":0"), "{log}"); + assert!(lines[0].contains("\"policy_key\":\"k\""), "{log}"); + } + + #[test] + fn test_policy_key_stable_and_sensitive() { + let base = policy_key(&MeaningPolicy::default(), &VisibilityPolicy::default()); + assert_eq!(base.len(), 16); + // Deterministic across calls. + assert_eq!( + base, + policy_key(&MeaningPolicy::default(), &VisibilityPolicy::default()) + ); + + let strict = MeaningPolicy { + block_on: vec!["review".into()], + ..MeaningPolicy::default() + }; + assert_ne!(base, policy_key(&strict, &VisibilityPolicy::default())); + + let embargo = VisibilityPolicy { + embargo_until: Some("2026-09-01".into()), + ..Default::default() + }; + assert_ne!(base, policy_key(&MeaningPolicy::default(), &embargo)); + + let extra_path = VisibilityPolicy { + private_paths: vec!["secrets/".into(), ".env".into(), "vault/".into()], + ..Default::default() + }; + assert_ne!(base, policy_key(&MeaningPolicy::default(), &extra_path)); + } + + #[test] + fn test_adjudicate_root_change_against_empty_base() { + let (_tmp, store) = temp_store("root"); + let files = vec![WorkFile { + path: "lib.rs".into(), + contents: b"pub fn greet() -> &'static str { \"hi\" }\n".to_vec(), + executable: false, + }]; + let record = ChangeRecord { + parents: vec![], + tree: store.write_tree_from_files(&files).unwrap(), + author: identity(), + committer: identity(), + message: "root\n".into(), + source_sha: None, + }; + let id = store.put_record(&record).unwrap(); + + let engine = Engine::new().unwrap(); + let p = adjudicate_change( + &store, + &id, + &engine, + &MeaningPolicy::default(), + &VisibilityPolicy::default(), + None, + None, + ) + .unwrap(); + + assert_eq!(p.schema, DOCKET_SCHEMA); + assert_eq!(p.change, id); + assert_eq!(p.parents, Vec::::new()); + assert_eq!(p.docket.source, "oot", "native changes carry the oot tag"); + assert_eq!(p.docket.base, "(root)"); + assert_eq!(p.docket.authors, vec!["Kriday".to_string()]); + // Whole-tree additions are Review-level, which the default policy + // does not block on. + assert_eq!(p.docket.verdict, crate::dispute::Verdict::Adjudicated); + assert!(p + .docket + .disputes + .iter() + .any(|d| d.detail.contains("file added"))); + } + + #[test] + fn test_adjudicate_child_change_against_first_parent() { + let (_tmp, store) = temp_store("child"); + let base_tree = store + .write_tree_from_files(&[WorkFile { + path: "lib.rs".into(), + contents: b"pub fn calc() -> i32 { 1 }\n".to_vec(), + executable: false, + }]) + .unwrap(); + let base_record = ChangeRecord { + parents: vec![], + tree: base_tree, + author: identity(), + committer: identity(), + message: "base\n".into(), + source_sha: Some("0123abcd".into()), + }; + let base_id = store.put_record(&base_record).unwrap(); + + let child_tree = store + .write_tree_from_files(&[WorkFile { + path: "lib.rs".into(), + contents: b"pub fn calc() -> i32 { 2 }\n".to_vec(), + executable: false, + }]) + .unwrap(); + let child_record = ChangeRecord { + parents: vec![base_id.clone()], + tree: child_tree, + author: identity(), + committer: identity(), + message: "edit calc\n".into(), + source_sha: Some("0123abce".into()), + }; + let child_id = store.put_record(&child_record).unwrap(); + + let engine = Engine::new().unwrap(); + let p = adjudicate_change( + &store, + &child_id, + &engine, + &MeaningPolicy::default(), + &VisibilityPolicy::default(), + None, + None, + ) + .unwrap(); + + assert_eq!(p.parents, vec![base_id.clone()]); + assert_eq!(p.docket.source, "git", "imported changes carry the git tag"); + assert_eq!(p.docket.base, short_id(&base_id)); + assert_eq!(p.docket.verdict, crate::dispute::Verdict::Adjudicated); + assert!(p + .docket + .disputes + .iter() + .any(|d| d.detail.contains("both sides changed `calc`"))); + } +} diff --git a/src/lib.rs b/src/lib.rs index 6554fe4..29e227e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod adapter; pub mod change; +pub mod court; pub mod dispute; pub mod docket; pub mod engine; diff --git a/src/main.rs b/src/main.rs index 85af77e..b0103db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use anyhow::Context; use clap::{Parser, Subcommand}; use oot::adapter::{GitAdapter, GitAdjudicateOptions, JjAdapter, JjAdjudicateOptions}; use oot::change::{Change, Snapshot, Source}; +use oot::court; use oot::dispute::{finalize_adjudication, Docket, Kind, Severity, Verdict}; use oot::docket; use oot::engine::Engine; @@ -72,6 +73,10 @@ enum Commands { /// Path to save the resulting docket as JSON. #[arg(long, short = 'o')] output: Option, + /// Store mode only: skip persisting the docket into `.oot/dockets/` + /// and the `.oot/adjudications.jsonl` audit trail. + #[arg(long)] + no_save: bool, }, /// Initialize an Oot store (`.oot/`) in the current project. Init, @@ -115,6 +120,11 @@ enum Commands { #[arg(long)] visibility: Option, }, + /// Render a docket persisted by a previous `oot adjudicate --change`. + Docket { + /// Change id or unique prefix. + id: String, + }, } fn main() -> anyhow::Result { @@ -135,6 +145,7 @@ fn main() -> anyhow::Result { visibility, docket, output, + no_save, } => { if let Some(path) = docket { let d = docket::load(std::path::Path::new(&path))?; @@ -152,6 +163,50 @@ fn main() -> anyhow::Result { }; let eng = Engine::new()?; + // Store-backed adjudication: `--change ` engages only + // when an Oot store opens here and no other adjudication mode was + // requested; otherwise the existing modes below behave untouched. + // Once engaged, an unresolvable id fails loudly rather than + // silently falling through. + let other_mode = source.is_some() + || base.is_some() + || head.is_some() + || base_ref.is_some() + || head_ref.is_some() + || repo.is_some() + || docket.is_some(); + let store = match (change.clone(), other_mode) { + (Some(_), false) => Store::open(".").ok(), + _ => None, + }; + if let Some(store) = store { + let change_flag = change.as_deref().expect("checked above"); + let id = store.resolve_change(change_flag)?; + let authors_list = authors.as_ref().map(|a| { + a.split(',') + .map(|s| s.trim().to_string()) + .collect::>() + }); + let persisted = court::adjudicate_change( + &store, + &id, + &eng, + &meaning_policy, + &visibility_policy, + intent.clone(), + authors_list, + )?; + print!("{}", persisted.docket.render()); + if !no_save { + court::save_docket(&store, &persisted)?; + court::log_adjudication(&store, &persisted)?; + } + if let Some(out_path) = output { + docket::save(&persisted.docket, std::path::Path::new(&out_path))?; + } + return Ok(exit_code_for(persisted.docket.verdict)); + } + // VCS 3-way In-Memory Adjudication (git or jj) if let (Some(b_ref), Some(h_ref)) = (base_ref, head_ref) { let wants_jj = matches!(source.as_deref(), Some("jj") | Some("jujutsu")); @@ -353,6 +408,7 @@ fn main() -> anyhow::Result { "recorded {id} on {branch} as {kind}: {} file(s)", files.len() ); + println!("next: oot adjudicate --change {}", &id[..7]); Ok(std::process::ExitCode::SUCCESS) } Commands::Status { branch } => { @@ -514,6 +570,13 @@ fn main() -> anyhow::Result { ); Ok(std::process::ExitCode::SUCCESS) } + Commands::Docket { id } => { + let store = Store::open(".")?; + let change_id = store.resolve_change(&id)?; + let persisted = court::load_docket(&store, &change_id)?; + print!("{}", persisted.docket.render()); + Ok(std::process::ExitCode::SUCCESS) + } } } diff --git a/src/store.rs b/src/store.rs index 72e2230..26a5994 100644 --- a/src/store.rs +++ b/src/store.rs @@ -12,6 +12,7 @@ //! A change id is the `git hash-object` SHA of its canonical JSON, so records //! are content-addressed like everything else in the store. +use crate::change::Snapshot; use crate::visibility::VisibilityPolicy; use anyhow::{anyhow, bail, Context, Result}; use serde::{Deserialize, Serialize}; @@ -333,6 +334,91 @@ impl Store { self.hash_object(bytes, "blob", false) } + /// Read one blob's exact bytes from the store's odb. + pub fn read_blob(&self, sha: &str) -> Result> { + let out = Command::new("git") + .args(["--git-dir"]) + .arg(self.git_dir()) + .args(["cat-file", "blob", sha]) + .output() + .context("failed to read a blob from the store's odb")?; + if !out.status.success() { + bail!( + "cat-file blob {sha} failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(out.stdout) + } + + /// Rebuild a [`Snapshot`] from a tree in the store's odb: `ls-tree -r -z` + /// lists the blobs, `read_blob` fetches each one. Gitlinks (submodules) + /// are skipped — they point at other commits rather than hold content, + /// so there is nothing to adjudicate. + pub fn snapshot_from_tree(&self, tree: &str) -> Result { + let listed = Command::new("git") + .args(["--git-dir"]) + .arg(self.git_dir()) + .args(["ls-tree", "-r", "-z", tree]) + .output() + .context("failed to read the tree from the store's odb")?; + if !listed.status.success() { + bail!( + "git ls-tree failed for {tree}: {}", + String::from_utf8_lossy(&listed.stderr).trim() + ); + } + + let mut snap = Snapshot::default(); + for entry in listed.stdout.split(|&b| b == 0).filter(|s| !s.is_empty()) { + let record = String::from_utf8_lossy(entry); + let (meta, path) = record + .split_once('\t') + .ok_or_else(|| anyhow!("malformed ls-tree entry: {record}"))?; + let mut parts = meta.splitn(3, ' '); + let _mode = parts.next().unwrap_or_default(); + let kind = parts.next().unwrap_or_default(); + let sha = parts.next().unwrap_or_default(); + if kind != "blob" { + continue; + } + snap.files.insert(path.to_string(), self.read_blob(sha)?); + } + Ok(snap) + } + + /// Resolve a change id or unique prefix to its full id. Exact match wins; + /// otherwise the prefix must select exactly one stored change or this + /// fails loudly listing every candidate. + pub fn resolve_change(&self, id_or_prefix: &str) -> Result { + let changes = self.root.join(CHANGES_DIR); + if changes.join(format!("{id_or_prefix}.json")).exists() { + return Ok(id_or_prefix.to_string()); + } + let mut candidates: Vec = Vec::new(); + for entry in std::fs::read_dir(&changes)? { + let name = entry?.file_name().to_string_lossy().to_string(); + if let Some(stem) = name.strip_suffix(".json") { + if stem.starts_with(id_or_prefix) { + candidates.push(stem.to_string()); + } + } + } + candidates.sort(); + match candidates.as_slice() { + [] => bail!("no change matching '{id_or_prefix}' in store (see `oot log`)"), + [one] => Ok(one.clone()), + many => bail!( + "ambiguous change prefix '{id_or_prefix}' matches {} changes:\n{}", + many.len(), + many.iter() + .map(|c| format!(" {c}")) + .collect::>() + .join("\n") + ), + } + } + /// Store file contents as blobs in the odb and assemble them into a /// nested tree, returning the root tree sha. Paths use `/` separators; /// empty directories cannot be represented and are skipped naturally. @@ -1204,6 +1290,123 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } + #[test] + fn test_snapshot_from_tree_and_read_blob_roundtrip() { + let tmp = std::env::temp_dir().join(format!("oot-snap-test-{}", std::process::id())); + let project = tmp.join("proj"); + std::fs::create_dir_all(&project).unwrap(); + let store = Store::init(&project).unwrap(); + + let files = vec![ + WorkFile { + path: "lib.rs".into(), + contents: b"pub fn a() {}\n".to_vec(), + executable: false, + }, + WorkFile { + path: "deep/nested/bin.dat".into(), + contents: vec![0x00, 0xff, 0x7f, 0x80], + executable: false, + }, + WorkFile { + path: "run.sh".into(), + contents: b"#!/bin/sh\n".to_vec(), + executable: true, + }, + ]; + let tree = store.write_tree_from_files(&files).unwrap(); + + let snap = store.snapshot_from_tree(&tree).unwrap(); + assert_eq!(snap.files.len(), 3); + assert_eq!(snap.files["lib.rs"], b"pub fn a() {}\n".to_vec()); + assert_eq!( + snap.files["deep/nested/bin.dat"], + vec![0x00, 0xff, 0x7f, 0x80] + ); + + let (sha, _) = store + .tree_files(&tree) + .unwrap() + .get("run.sh") + .unwrap() + .clone(); + assert_eq!(store.read_blob(&sha).unwrap(), b"#!/bin/sh\n".to_vec()); + assert!(store.read_blob("does-not-exist").is_err()); + + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn test_resolve_change_exact_prefix_and_ambiguity() { + let tmp = std::env::temp_dir().join(format!("oot-resolve-test-{}", std::process::id())); + let project = tmp.join("proj"); + std::fs::create_dir_all(&project).unwrap(); + let store = Store::init(&project).unwrap(); + + // Two crafted ids sharing a long prefix; resolve_change works on + // stored filenames, so hand-written records are a deterministic fixture. + let changes = store.path().join("changes"); + for tail in ["1", "2"] { + let id = format!("aaaa00000000000000000000000000000000000{tail}"); + let record = ChangeRecord { + parents: vec![], + tree: format!("tree-{tail}"), + author: Identity { + name: "K".into(), + email: "k@oot.dev".into(), + time: 0, + offset: "+0000".into(), + }, + committer: Identity { + name: "K".into(), + email: "k@oot.dev".into(), + time: 0, + offset: "+0000".into(), + }, + message: "crafted\n".into(), + source_sha: None, + }; + std::fs::write( + changes.join(format!("{id}.json")), + serde_json::to_vec(&record).unwrap(), + ) + .unwrap(); + } + + assert_eq!( + store + .resolve_change("aaaa000000000000000000000000000000000001") + .unwrap(), + "aaaa000000000000000000000000000000000001", + "exact id wins" + ); + assert_eq!( + store + .resolve_change("aaaa000000000000000000000000000000000002") + .unwrap(), + "aaaa000000000000000000000000000000000002" + ); + + let ambiguous = store.resolve_change("aaaa").unwrap_err().to_string(); + assert!( + ambiguous.contains("ambiguous change prefix 'aaaa'"), + "{ambiguous}" + ); + assert!( + ambiguous.contains("aaaa000000000000000000000000000000000001"), + "{ambiguous}" + ); + assert!( + ambiguous.contains("aaaa000000000000000000000000000000000002"), + "{ambiguous}" + ); + + let missing = store.resolve_change("bbbb").unwrap_err().to_string(); + assert!(missing.contains("no change matching 'bbbb'"), "{missing}"); + + let _ = std::fs::remove_dir_all(&tmp); + } + #[test] fn test_identity_date_env_preserves_offset() { let id = Identity { diff --git a/tests/adjudicate_store_test.rs b/tests/adjudicate_store_test.rs new file mode 100644 index 0000000..d898254 --- /dev/null +++ b/tests/adjudicate_store_test.rs @@ -0,0 +1,405 @@ +//! Store-to-court wiring: `oot adjudicate --change` reads history straight +//! from `.oot/`, judges a change against its FIRST parent (root changes diff +//! against nothing), and persists the verdict as sidecar dockets plus an +//! append-only audit trail. Exit codes stay the court's: 0 only Adjudicated. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn bin() -> &'static str { + env!("CARGO_BIN_EXE_oot") +} + +struct Run { + code: Option, + out: String, +} + +fn oot(args: &[&str], cwd: &Path) -> Run { + let o = Command::new(bin()) + .args(args) + .current_dir(cwd) + // Identity via env: CI runners have no global git config. + .env("GIT_AUTHOR_NAME", "Kriday") + .env("GIT_AUTHOR_EMAIL", "k@oot.dev") + .env("GIT_COMMITTER_NAME", "Kriday") + .env("GIT_COMMITTER_EMAIL", "k@oot.dev") + .output() + .expect("oot binary should run"); + Run { + code: o.status.code(), + out: format!( + "{}{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ), + } +} + +fn ok(run: &Run, what: &str) { + assert_eq!(run.code, Some(0), "{what} failed: {}", run.out); +} + +fn git(repo: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .envs([ + ("GIT_AUTHOR_NAME", "Git K"), + ("GIT_AUTHOR_EMAIL", "g@x.dev"), + ("GIT_COMMITTER_NAME", "Git K"), + ("GIT_COMMITTER_EMAIL", "g@x.dev"), + ]) + .output() + .expect("git should run"); + assert!( + out.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +fn fresh(tag: &str) -> PathBuf { + let tmp = std::env::temp_dir().join(format!("oot-court-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let proj = tmp.join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + proj +} + +/// Record the working copy and return the full change id. +fn record(proj: &Path, msg: &str) -> String { + let run = oot(&["record", "-m", msg], proj); + ok(&run, "record"); + run.out + .lines() + .find(|l| l.starts_with("recorded ")) + .unwrap_or_else(|| panic!("no record line in: {}", run.out)) + .split_whitespace() + .nth(1) + .unwrap() + .to_string() +} + +/// Change ids of a branch, oldest first, parsed from `oot log` output. +fn logged_ids(proj: &Path) -> Vec { + let run = oot(&["log"], proj); + ok(&run, "log"); + let mut ids: Vec = run + .out + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.split_whitespace().next().unwrap().to_string()) + .collect(); + ids.reverse(); + ids +} + +fn audit_lines(proj: &Path) -> Vec { + std::fs::read_to_string(proj.join(".oot/adjudications.jsonl")) + .unwrap() + .lines() + .map(str::to_string) + .collect() +} + +#[test] +fn test_root_change_adjudicates_clean_exit_zero() { + let proj = fresh("happy"); + + std::fs::write( + proj.join("lib.rs"), + "pub fn greet() -> &'static str { \"hi\" }\n", + ) + .unwrap(); + ok(&oot(&["init"], &proj), "init"); + let root = record(&proj, "root change"); + + // Prefixes resolve like full ids. + let run = oot(&["adjudicate", "--change", &root[..7]], &proj); + assert_eq!(run.code, Some(0), "{}", run.out); + assert!(run.out.contains("ADJUDICATED"), "{}", run.out); + assert!(run.out.contains("from: oot"), "{}", run.out); + assert!(run.out.contains("base: (root)"), "{}", run.out); + + // The docket landed in the sidecar under the full id. + assert!(proj + .join(".oot/dockets") + .join(format!("{root}.json")) + .exists()); +} + +#[test] +fn test_meaning_dispute_blocks_under_strict_policy_exit_one() { + let proj = fresh("blocked"); + + std::fs::write(proj.join("lib.rs"), "pub fn calc() -> i32 { 1 }\n").unwrap(); + ok(&oot(&["init"], &proj), "init"); + record(&proj, "base"); + std::fs::write(proj.join("lib.rs"), "pub fn calc() -> i32 { 2 }\n").unwrap(); + let child = record(&proj, "edit calc"); + + // Default policy: a changed function is Review-level, not blocking. + let run = oot(&["adjudicate", "--change", &child], &proj); + assert_eq!(run.code, Some(0), "{}", run.out); + + // Strict policy: review blocks. Exit contract unchanged — nonzero. + std::fs::write( + proj.join("strict.toml"), + "block_on = [\"review\"]\nreview_on = []\n", + ) + .unwrap(); + let run = oot( + &[ + "adjudicate", + "--change", + &child, + "--policy", + "strict.toml", + "--no-save", + ], + &proj, + ); + assert_eq!(run.code, Some(1), "{}", run.out); + assert!(run.out.contains("BLOCKED"), "{}", run.out); + assert!(run.out.contains("both sides changed `calc`"), "{}", run.out); +} + +#[test] +fn test_child_change_shows_delta_not_whole_tree_noise() { + let proj = fresh("delta"); + + std::fs::write(proj.join("lib.rs"), "pub fn calc() -> i32 { 1 }\n").unwrap(); + std::fs::write(proj.join("keep.txt"), "untouched\n").unwrap(); + ok(&oot(&["init"], &proj), "init"); + record(&proj, "base with two files"); + + // Only lib.rs moves; keep.txt stays identical between parent and child. + std::fs::write(proj.join("lib.rs"), "pub fn calc() -> i32 { 2 }\n").unwrap(); + let child = record(&proj, "edit calc only"); + + let run = oot(&["adjudicate", "--change", &child], &proj); + assert_eq!(run.code, Some(0), "{}", run.out); + assert!(run.out.contains("`calc`"), "{}", run.out); + assert!(run.out.contains("intent: lib.rs"), "{}", run.out); + assert!( + !run.out.contains("keep.txt"), + "untouched file leaked into the docket: {}", + run.out + ); +} + +#[test] +fn test_persistence_overwrite_and_audit_line_per_run() { + let proj = fresh("persist"); + + std::fs::write(proj.join("notes.txt"), "v1\n").unwrap(); + ok(&oot(&["init"], &proj), "init"); + let id = record(&proj, "only change"); + let short = id[..7].to_string(); + + ok( + &oot(&["adjudicate", "--change", &short], &proj), + "first adjudication", + ); + let docket_path = proj.join(".oot/dockets").join(format!("{id}.json")); + let first = std::fs::read_to_string(&docket_path).unwrap(); + assert!(first.contains("\"schema\": 1"), "{first}"); + assert_eq!(audit_lines(&proj).len(), 1); + assert!(audit_lines(&proj)[0].contains("\"event\":\"adjudicated\"")); + assert!(audit_lines(&proj)[0].contains(&format!("\"change\":\"{id}\""))); + + // Second run overwrites the sidecar but appends exactly one audit line. + std::thread::sleep(std::time::Duration::from_secs(1)); + ok( + &oot(&["adjudicate", "--change", &short], &proj), + "second adjudication", + ); + let second = std::fs::read_to_string(&docket_path).unwrap(); + assert_ne!(first, second, "re-run must refresh the envelope timestamp"); + assert_eq!(second.lines().count(), first.lines().count()); + let lines = audit_lines(&proj); + assert_eq!(lines.len(), 2, "one jsonl line per run: {:?}", lines); + + // `--no-save` touches neither sidecar. + ok( + &oot(&["adjudicate", "--change", &short, "--no-save"], &proj), + "no-save adjudication", + ); + assert_eq!(audit_lines(&proj).len(), 2); + + // The persisted docket renders on demand. + let run = oot(&["docket", &short], &proj); + ok(&run, "oot docket"); + assert!(run.out.contains("OOT DOCKET"), "{}", run.out); + assert!(run.out.contains(&short), "{}", run.out); +} + +#[test] +fn test_imported_mid_history_change_carries_git_tag() { + let proj = fresh("imported"); + let src = proj.parent().unwrap().join("src"); + std::fs::create_dir_all(&src).unwrap(); + + git(&src, &["init", "--quiet", "-b", "main"]); + std::fs::write(src.join("lib.rs"), "pub fn f() -> i32 { 1 }\n").unwrap(); + git(&src, &["add", "."]); + git(&src, &["commit", "-m", "imported root"]); + std::fs::write(src.join("lib.rs"), "pub fn f() -> i32 { 2 }\n").unwrap(); + git(&src, &["add", "."]); + git(&src, &["commit", "-m", "mid-history edit"]); + + ok(&oot(&["init"], &proj), "init"); + ok( + &oot(&["import", "--repo", src.to_str().unwrap()], &proj), + "import", + ); + + // log prints newest first; flip to oldest-first and take the child. + let ids = logged_ids(&proj); + assert_eq!(ids.len(), 2); + let mid = &ids[1]; + + let run = oot(&["adjudicate", "--change", mid], &proj); + assert_eq!(run.code, Some(0), "{}", run.out); + assert!(run.out.contains("from: git"), "{}", run.out); + // Base is the imported parent, not "(root)". + assert!( + run.out.contains(&format!("base: {}", ids[0])), + "{}", + run.out + ); + assert!(run.out.contains("both sides changed `f`"), "{}", run.out); +} + +#[test] +fn test_mixed_imported_and_native_history_adjudicates() { + let proj = fresh("mixed"); + let src = proj.parent().unwrap().join("src"); + std::fs::create_dir_all(&src).unwrap(); + + git(&src, &["init", "--quiet", "-b", "main"]); + std::fs::write(src.join("seed.txt"), "seed\n").unwrap(); + git(&src, &["add", "."]); + git(&src, &["commit", "-m", "imported root"]); + + ok(&oot(&["init"], &proj), "init"); + ok( + &oot(&["import", "--repo", src.to_str().unwrap()], &proj), + "import", + ); + + // Materialize the imported head in the worktree so the only real delta + // of the next record is the new file. + std::fs::write(proj.join("seed.txt"), "seed\n").unwrap(); + std::fs::write(proj.join("native.txt"), "born in oot\n").unwrap(); + let native = record(&proj, "native child"); + + let imported_root = &logged_ids(&proj)[0]; + let run = oot(&["adjudicate", "--change", imported_root], &proj); + assert_eq!(run.code, Some(0), "{}", run.out); + assert!(run.out.contains("from: git"), "{}", run.out); + + let run = oot(&["adjudicate", "--change", &native], &proj); + assert_eq!(run.code, Some(0), "{}", run.out); + assert!(run.out.contains("from: oot"), "{}", run.out); + assert!(run.out.contains("intent: native.txt"), "{}", run.out); + assert!(!run.out.contains("seed.txt"), "{}", run.out); + + // Both dockets persist side by side (files are named by full change id; + // log only ever surfaces short prefixes). + let persisted = std::fs::read_dir(proj.join(".oot/dockets")) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!(persisted.len(), 2); + assert_eq!(audit_lines(&proj).len(), 2); +} + +#[test] +fn test_unknown_id_and_ambiguous_prefix_fail_loudly() { + let proj = fresh("resolve"); + std::fs::write(proj.join("a.txt"), "v1\n").unwrap(); + ok(&oot(&["init"], &proj), "init"); + record(&proj, "one real change"); + + // Unknown id: loud error, exit 1, no usage fallback. + let run = oot(&["adjudicate", "--change", "deadbeef"], &proj); + assert_eq!(run.code, Some(1), "{}", run.out); + assert!( + run.out.contains("no change matching 'deadbeef'"), + "{}", + run.out + ); + + // Craft two stored changes sharing a long prefix; resolve_change works + // on stored filenames so this is a deterministic ambiguity fixture. + use oot::store::{ChangeRecord, Identity}; + let changes = proj.join(".oot/changes"); + for tail in ["1", "2"] { + let id = format!("aaaa00000000000000000000000000000000000{tail}"); + let rec = ChangeRecord { + parents: vec![], + tree: format!("tree-{tail}"), + author: Identity { + name: "A".into(), + email: "a@b.c".into(), + time: 0, + offset: "+0000".into(), + }, + committer: Identity { + name: "A".into(), + email: "a@b.c".into(), + time: 0, + offset: "+0000".into(), + }, + message: "crafted\n".into(), + source_sha: None, + }; + std::fs::write( + changes.join(format!("{id}.json")), + serde_json::to_vec(&rec).unwrap(), + ) + .unwrap(); + } + + let run = oot(&["adjudicate", "--change", "aaaa"], &proj); + assert_eq!(run.code, Some(1), "{}", run.out); + assert!( + run.out.contains("ambiguous change prefix 'aaaa'"), + "{}", + run.out + ); + assert!( + run.out.contains("aaaa000000000000000000000000000000000001"), + "{}", + run.out + ); + assert!( + run.out.contains("aaaa000000000000000000000000000000000002"), + "{}", + run.out + ); + + // `oot docket` resolves through the same rules. + let run = oot(&["docket", "aaaa"], &proj); + assert_eq!(run.code, Some(1), "{}", run.out); + assert!(run.out.contains("ambiguous"), "{}", run.out); + + // Store mode does NOT engage when another mode is requested: with + // --source given, --change falls back to the legacy snapshot modes, + // which exit 2 on missing --base/--head even though a store exists. + let run = oot( + &["adjudicate", "--change", "aaaa", "--source", "memory"], + &proj, + ); + assert_eq!(run.code, Some(2), "{}", run.out); + assert!( + !run.out.contains("ambiguous"), + "--source must disable store mode: {}", + run.out + ); +}