diff --git a/.repository-projection.json b/.repository-projection.json index ad87d00..fe0b2c0 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "capobara", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "511bd2305f5a92cfbedb91208d7bc0820fda29b5", + "sourceSha": "4ab4845398fdca60f84eb815733b8e2361b4856d", "destinationRepository": "dx-corp/capobara", - "priorProjectedBase": "01e475cef45ab0d58e52c9c54ece7ff6cff35d03", + "priorProjectedBase": "f7484854022cf9a6b47fa93c445f3b5917e7e54d", "definitionDigest": "7826c75cbd356dad6c867f2606ebde8b79ef4361353908cf33ada720034ec488", - "toolDigest": "e9fc82741fdf4a797b076d29d51e14aced32368b", - "contentDigest": "6966b9fe44e8b9c45addee6ee807e2fd03993de04f3763fe6b7f3d319db00f8b", + "toolDigest": "f58d71f023a4f0a27d77cb96dcc5348a40816d0b", + "contentDigest": "c364d273ea10128a23de009cd2180dde514640e12bb6494bd36f246afe778bc0", "publicationEligible": true } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 47bf873..15562c3 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -8,6 +8,7 @@ pub mod catalog; pub mod project; pub mod run; pub mod transport; +pub mod vendor; use std::path::PathBuf; diff --git a/src/cli/vendor.rs b/src/cli/vendor.rs new file mode 100644 index 0000000..53a9625 --- /dev/null +++ b/src/cli/vendor.rs @@ -0,0 +1,50 @@ +//! `capobara vendor check` — verify a vendored upstream tree against its pin. +//! +//! Read-only by design. Capobara publishes outward and that stays true; this +//! subcommand only answers whether a vendored subtree still matches the +//! upstream commit it claims, and which divergences were declared. + +use std::path::PathBuf; + +use clap::Args; + +use crate::vendor::{self, VendorReport}; +use crate::{Error, Result}; + +#[derive(Args, Debug)] +pub struct VendorCliArgs { + /// The vendor-import definition to check. + #[arg(long)] + pub definition: PathBuf, + /// Mono checkout holding the vendored tree. + #[arg(long)] + pub root: PathBuf, + /// A checkout of the upstream repository containing the pinned commit. + #[arg(long)] + pub upstream: PathBuf, + /// Revision of Mono to read. Defaults to HEAD. + #[arg(long, default_value = "HEAD")] + pub rev: String, +} + +pub fn check(args: VendorCliArgs) -> Result { + let definition = vendor::load(&args.definition)?; + let report = vendor::check(&args.root, &args.rev, &args.upstream, &definition)?; + println!("{}", report.summary()); + for divergence in &report.declared { + println!(" declared {divergence:?}"); + } + for divergence in &report.drift { + println!(" DRIFT {divergence:?}"); + } + if report.clean() { + Ok(report) + } else { + Err(Error::Contract(format!( + "{}: {} undeclared divergence(s) from upstream {}", + report.name, + report.drift.len(), + &report.upstream_commit[..12] + ))) + } +} diff --git a/src/git.rs b/src/git.rs index b16e195..1b17f72 100644 --- a/src/git.rs +++ b/src/git.rs @@ -5,7 +5,7 @@ use crate::{Error, Result}; /// The reviewed process boundary for git: every other module reaches git /// only through this function (or the helpers below that call it). -fn run(root: &Path, args: &[&str]) -> Result { +fn run(root: &Path, args: &[&str], environment: &[(&str, &str)]) -> Result { #[allow( clippy::disallowed_methods, reason = "git is the reviewed process boundary for capobara" @@ -16,12 +16,17 @@ fn run(root: &Path, args: &[&str]) -> Result { .args(args) .stdin(Stdio::null()) .env("GIT_TERMINAL_PROMPT", "0") + .envs(environment.iter().copied()) .output() .map_err(Error::Io) } pub fn git_bytes(root: &Path, args: &[&str]) -> Result> { - let out = run(root, args)?; + checked_output(root, args, &[]) +} + +fn checked_output(root: &Path, args: &[&str], environment: &[(&str, &str)]) -> Result> { + let out = run(root, args, environment)?; if !out.status.success() { return Err(Error::Invalid(format!( "git {} failed: {}", @@ -32,13 +37,32 @@ pub fn git_bytes(root: &Path, args: &[&str]) -> Result> { Ok(out.stdout) } +/// Scratch repositories retain deterministic authors and ignore host Git config. +#[cfg(test)] +pub(crate) fn test_git_bytes(root: &Path, args: &[&str]) -> Result> { + checked_output( + root, + args, + &[ + ("GIT_AUTHOR_NAME", "t"), + ("GIT_AUTHOR_EMAIL", "t@example.invalid"), + ("GIT_COMMITTER_NAME", "t"), + ("GIT_COMMITTER_EMAIL", "t@example.invalid"), + ("GIT_CONFIG_GLOBAL", "/dev/null"), + ("GIT_CONFIG_SYSTEM", "/dev/null"), + ], + ) +} + pub fn git(root: &Path, args: &[&str]) -> Result { String::from_utf8(git_bytes(root, args)?) .map_err(|_| Error::Invalid(format!("git {} produced non-UTF-8 output", args.join(" ")))) } pub fn git_ok(root: &Path, args: &[&str]) -> bool { - run(root, args).map(|o| o.status.success()).unwrap_or(false) + run(root, args, &[]) + .map(|o| o.status.success()) + .unwrap_or(false) } pub fn is_ancestor(root: &Path, ancestor: &str, descendant: &str) -> bool { diff --git a/src/lib.rs b/src/lib.rs index ec0dbb8..278f07b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,4 +13,5 @@ pub mod snapshot; pub mod tooldigest; pub mod transport; pub mod tree; +pub mod vendor; pub use error::{Error, Result, contract, invalid}; diff --git a/src/main.rs b/src/main.rs index d061c64..83cdd0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use std::path::PathBuf; +use std::process::ExitCode; use clap::{Parser, Subcommand}; @@ -7,6 +8,7 @@ use capobara::cli::catalog::CatalogCommand; use capobara::cli::project::ProjectCommand; use capobara::cli::run::RunArgs; use capobara::cli::transport::{PrepareArgs, ReportArgs}; +use capobara::cli::vendor::VendorCliArgs; #[derive(Parser)] #[command( @@ -40,6 +42,9 @@ enum Command { Verify(ProjectCliArgs), /// Report drift between source and destination. Check(ProjectCliArgs), + /// Verify a vendored upstream tree against its pinned commit. + #[command(subcommand)] + Vendor(VendorCommand), /// Clone-side preparation of the destination branch. Prepare(PrepareArgs), /// Recheck a prepared projection before publication. @@ -106,7 +111,7 @@ fn exit_catalog(result: capobara::Result<()>) -> ! { } } -fn main() { +fn main() -> ExitCode { let cli = Cli::parse(); match cli.command { Command::Plan(args) => { @@ -139,5 +144,24 @@ fn main() { Command::Preflight(args) => exit_transport(capobara::cli::transport::preflight(args)), Command::Publish(args) => exit_transport(capobara::cli::transport::publish(args)), Command::Run(args) => exit_transport(capobara::cli::run::run(args)), + Command::Vendor(VendorCommand::Check(args)) => { + exit_vendor(capobara::cli::vendor::check(args)) + } + } +} + +#[derive(Subcommand)] +enum VendorCommand { + /// Report divergence between a vendored tree and its pinned upstream commit. + Check(VendorCliArgs), +} + +fn exit_vendor(result: capobara::Result) -> ExitCode { + match result { + Ok(_) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::from(u8::try_from(error.exit_code()).expect("capobara exit codes fit in u8")) + } } } diff --git a/src/vendor.rs b/src/vendor.rs new file mode 100644 index 0000000..ca91199 --- /dev/null +++ b/src/vendor.rs @@ -0,0 +1,476 @@ +//! Inbound vendoring: the direction Capobara did not have. +//! +//! Capobara publishes Mono subtrees *outward* — Mono is the source of truth and +//! a projection lands in a standalone repository. A vendored third-party tree +//! runs the other way, and nothing verified it, so a vendored directory was an +//! unfalsifiable snapshot: no way to answer "is this still what upstream said, +//! and which files have we changed?" without doing the import again by hand. +//! +//! This module answers exactly that question and nothing more. It **never +//! writes**. Refreshing a vendored tree stays a reviewed human operation; +//! what belongs in a tool is the check that tells you whether the tree still +//! matches its pin, and which divergences were declared. +//! +//! Comparison is on git blob object ids, not file bytes: two blobs are equal +//! exactly when their content is equal, `git ls-tree` gives them for free on +//! both sides, and nothing has to be read into memory. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use serde::Deserialize; + +use crate::git::git_bytes; +use crate::tree::path::Matcher; +use crate::{Error, Result, contract, invalid}; + +/// A vendored upstream tree inside Mono, pinned to one upstream commit. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct VendorDefinition { + pub schema_version: u32, + pub name: String, + pub class: String, + pub upstream: Upstream, + pub destination: Destination, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Upstream { + /// Clone URL, recorded so a reader knows what the pin refers to. + pub repository: String, + /// Full 40-hex commit. A branch or tag would make the check non-reproducible. + pub commit: String, + /// Upstream paths that were vendored. Globs: `*` within a segment, `**` across. + pub include: Vec, + #[serde(default)] + pub exclude: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Destination { + /// Repository-relative directory the upstream subset lives in. + pub path: String, + /// Paths under `path`, relative to it, that Mono deliberately owns. + /// Divergence here is reported as declared rather than as drift. + #[serde(default)] + pub local_paths: Vec, +} + +impl VendorDefinition { + pub fn validate(&self) -> Result<()> { + invalid( + self.schema_version == 1, + format!( + "{}: unsupported schemaVersion {}", + self.name, self.schema_version + ), + )?; + invalid( + self.class == "vendor-import", + format!( + "{}: class must be vendor-import, got {}", + self.name, self.class + ), + )?; + invalid( + self.upstream.commit.len() == 40 + && self + .upstream + .commit + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), + format!( + "{}: upstream.commit must be a full lowercase 40-hex commit, got {:?}", + self.name, self.upstream.commit + ), + )?; + invalid( + !self.upstream.include.is_empty(), + format!("{}: upstream.include must not be empty", self.name), + )?; + let dest = &self.destination.path; + invalid( + !dest.is_empty() + && !dest.starts_with('/') + && !dest.split('/').any(|seg| seg == ".." || seg.is_empty()), + format!( + "{}: destination.path must be a clean relative path, got {dest:?}", + self.name + ), + )?; + Ok(()) + } +} + +pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(Error::Io)?; + let definition: VendorDefinition = serde_json::from_str(&text) + .map_err(|error| Error::Invalid(format!("{}: {error}", path.display())))?; + definition.validate()?; + Ok(definition) +} + +/// One divergence between the vendored tree and its pin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Divergence { + /// Upstream has the file at this pin; the vendored tree does not. + Missing(String), + /// The vendored tree has the file; upstream at this pin does not. + Extra(String), + /// Both have it and the blobs differ. + Modified(String), +} + +impl Divergence { + pub fn path(&self) -> &str { + match self { + Divergence::Missing(p) | Divergence::Extra(p) | Divergence::Modified(p) => p, + } + } +} + +#[derive(Debug)] +pub struct VendorReport { + pub name: String, + pub upstream_commit: String, + pub destination: String, + /// Upstream paths selected by include/exclude at this pin. + pub selected: usize, + /// Divergences not covered by `destination.localPaths`. + pub drift: Vec, + /// Divergences that `destination.localPaths` accounts for. + pub declared: Vec, +} + +impl VendorReport { + pub fn clean(&self) -> bool { + self.drift.is_empty() + } + + pub fn summary(&self) -> String { + format!( + "{}: pin {} -> {} | {} upstream paths, {} drift, {} declared", + self.name, + &self.upstream_commit[..12], + self.destination, + self.selected, + self.drift.len(), + self.declared.len() + ) + } +} + +/// `git ls-tree -r -z [-- ]` as path -> blob id. +/// +/// `-z` because a vendored upstream tree is not ours and may carry paths that +/// git would otherwise quote. Non-blob entries (submodule gitlinks) are +/// rejected rather than silently skipped: a gitlink inside a vendored subset +/// means the vendor is incomplete, which is exactly the thing worth catching. +fn ls_tree(root: &Path, rev: &str, subpath: Option<&str>) -> Result> { + let mut args: Vec<&str> = vec!["ls-tree", "-r", "-z", rev]; + if let Some(subpath) = subpath { + args.push("--"); + args.push(subpath); + } + let out = git_bytes(root, &args)?; + let text = String::from_utf8(out) + .map_err(|_| Error::Invalid(format!("git ls-tree {rev} returned non-UTF-8 paths")))?; + let mut entries = BTreeMap::new(); + for record in text.split('\0').filter(|r| !r.is_empty()) { + let (meta, path) = record + .split_once('\t') + .ok_or_else(|| Error::Invalid(format!("unparseable ls-tree record: {record:?}")))?; + let mut parts = meta.split_whitespace(); + let _mode = parts.next(); + let kind = parts + .next() + .ok_or_else(|| Error::Invalid(format!("unparseable ls-tree record: {record:?}")))?; + let oid = parts + .next() + .ok_or_else(|| Error::Invalid(format!("unparseable ls-tree record: {record:?}")))?; + contract( + kind == "blob", + format!("{path}: vendored trees must contain only blobs, found {kind}"), + )?; + entries.insert(path.to_owned(), oid.to_owned()); + } + Ok(entries) +} + +/// Compare a vendored subtree in `mono_root` against `upstream_checkout` at the +/// pinned commit. `mono_rev` is the revision of Mono to read (`HEAD` normally). +pub fn check( + mono_root: &Path, + mono_rev: &str, + upstream_checkout: &Path, + definition: &VendorDefinition, +) -> Result { + definition.validate()?; + + let include = Matcher::new(&definition.upstream.include)?; + let exclude = Matcher::new(&definition.upstream.exclude)?; + let local = Matcher::new(&definition.destination.local_paths)?; + + let upstream_all = ls_tree(upstream_checkout, &definition.upstream.commit, None)?; + let upstream: BTreeMap<&str, &str> = upstream_all + .iter() + .filter(|(path, _)| include.matches(path) && !exclude.matches(path)) + .map(|(path, oid)| (path.as_str(), oid.as_str())) + .collect(); + + let dest_prefix = format!("{}/", definition.destination.path.trim_end_matches('/')); + let vendored_raw = ls_tree(mono_root, mono_rev, Some(&definition.destination.path))?; + let vendored: BTreeMap<&str, &str> = vendored_raw + .iter() + .filter_map(|(path, oid)| { + path.strip_prefix(&dest_prefix) + .map(|rel| (rel, oid.as_str())) + }) + .collect(); + + let mut drift = Vec::new(); + let mut declared = Vec::new(); + let paths: BTreeSet<&str> = upstream + .keys() + .copied() + .chain(vendored.keys().copied()) + .collect(); + for path in paths { + let divergence = match (upstream.get(path), vendored.get(path)) { + (Some(_), None) => Divergence::Missing(path.to_owned()), + (None, Some(_)) => Divergence::Extra(path.to_owned()), + (Some(up), Some(here)) if up != here => Divergence::Modified(path.to_owned()), + _ => continue, + }; + if local.matches(divergence.path()) { + declared.push(divergence); + } else { + drift.push(divergence); + } + } + + Ok(VendorReport { + name: definition.name.clone(), + upstream_commit: definition.upstream.commit.clone(), + destination: definition.destination.path.clone(), + selected: upstream.len(), + drift, + declared, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::git::test_git_bytes; + use tempfile::TempDir; + + fn run(dir: &Path, args: &[&str]) { + test_git_bytes(dir, args).expect("scratch git command succeeds"); + } + + fn write(dir: &Path, rel: &str, contents: &str) { + let path = dir.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); + } + + fn commit_all(dir: &Path, message: &str) -> String { + run(dir, &["add", "-A"]); + run(dir, &["commit", "-q", "-m", message]); + String::from_utf8(test_git_bytes(dir, &["rev-parse", "HEAD"]).unwrap()) + .unwrap() + .trim() + .to_owned() + } + + struct Fixture { + upstream: TempDir, + mono: TempDir, + commit: String, + } + + /// Upstream has src/a.ts, src/b.ts and a docs/ tree; Mono vendors only src/. + fn fixture() -> Fixture { + let upstream = TempDir::new().unwrap(); + run(upstream.path(), &["init", "-q", "-b", "main"]); + write(upstream.path(), "src/a.ts", "export const a = 1;\n"); + write(upstream.path(), "src/b.ts", "export const b = 2;\n"); + write(upstream.path(), "docs/readme.md", "not vendored\n"); + let commit = commit_all(upstream.path(), "upstream"); + + let mono = TempDir::new().unwrap(); + run(mono.path(), &["init", "-q", "-b", "main"]); + write( + mono.path(), + "vendor/thing/src/a.ts", + "export const a = 1;\n", + ); + write( + mono.path(), + "vendor/thing/src/b.ts", + "export const b = 2;\n", + ); + commit_all(mono.path(), "vendored"); + + Fixture { + upstream, + mono, + commit, + } + } + + fn definition(commit: &str, local: &[&str]) -> VendorDefinition { + serde_json::from_value(serde_json::json!({ + "schemaVersion": 1, + "name": "thing", + "class": "vendor-import", + "upstream": { + "repository": "https://example.invalid/thing", + "commit": commit, + "include": ["src/**"], + "exclude": [] + }, + "destination": { "path": "vendor/thing", "localPaths": local } + })) + .unwrap() + } + + #[test] + fn faithful_vendor_is_clean() { + let f = fixture(); + let author = + test_git_bytes(f.upstream.path(), &["log", "-1", "--format=%an <%ae>"]).unwrap(); + assert_eq!( + String::from_utf8(author).unwrap().trim(), + "t " + ); + let def = definition(&f.commit, &[]); + let report = check(f.mono.path(), "HEAD", f.upstream.path(), &def).unwrap(); + assert!(report.clean(), "{:?}", report.drift); + assert_eq!(report.selected, 2, "docs/ must not be selected by src/**"); + } + + #[test] + fn an_edited_vendored_file_is_drift() { + let f = fixture(); + write( + f.mono.path(), + "vendor/thing/src/a.ts", + "export const a = 99;\n", + ); + commit_all(f.mono.path(), "local edit"); + let report = check( + f.mono.path(), + "HEAD", + f.upstream.path(), + &definition(&f.commit, &[]), + ) + .unwrap(); + assert!(!report.clean()); + assert_eq!(report.drift, vec![Divergence::Modified("src/a.ts".into())]); + } + + #[test] + fn a_declared_local_path_is_not_drift() { + let f = fixture(); + write( + f.mono.path(), + "vendor/thing/src/a.ts", + "export const a = 99;\n", + ); + commit_all(f.mono.path(), "local edit"); + let def = definition(&f.commit, &["src/a.ts"]); + let report = check(f.mono.path(), "HEAD", f.upstream.path(), &def).unwrap(); + assert!(report.clean(), "{:?}", report.drift); + assert_eq!( + report.declared, + vec![Divergence::Modified("src/a.ts".into())] + ); + } + + #[test] + fn a_dropped_upstream_file_is_missing_not_silence() { + let f = fixture(); + std::fs::remove_file(f.mono.path().join("vendor/thing/src/b.ts")).unwrap(); + commit_all(f.mono.path(), "drop b"); + let report = check( + f.mono.path(), + "HEAD", + f.upstream.path(), + &definition(&f.commit, &[]), + ) + .unwrap(); + assert_eq!(report.drift, vec![Divergence::Missing("src/b.ts".into())]); + } + + #[test] + fn a_file_we_added_is_extra() { + let f = fixture(); + write(f.mono.path(), "vendor/thing/src/local.ts", "ours\n"); + commit_all(f.mono.path(), "add local"); + let report = check( + f.mono.path(), + "HEAD", + f.upstream.path(), + &definition(&f.commit, &[]), + ) + .unwrap(); + assert_eq!(report.drift, vec![Divergence::Extra("src/local.ts".into())]); + + let declared = definition(&f.commit, &["src/local.ts"]); + let report = check(f.mono.path(), "HEAD", f.upstream.path(), &declared).unwrap(); + assert!(report.clean()); + } + + #[test] + fn exclude_narrows_the_upstream_selection() { + let f = fixture(); + std::fs::remove_file(f.mono.path().join("vendor/thing/src/b.ts")).unwrap(); + commit_all(f.mono.path(), "drop b"); + let mut def = definition(&f.commit, &[]); + def.upstream.exclude = vec!["src/b.ts".into()]; + let report = check(f.mono.path(), "HEAD", f.upstream.path(), &def).unwrap(); + assert!(report.clean(), "{:?}", report.drift); + assert_eq!(report.selected, 1); + } + + #[test] + fn a_short_or_uppercase_pin_is_rejected() { + let f = fixture(); + let short = serde_json::from_value::(serde_json::json!({ + "schemaVersion": 1, "name": "t", "class": "vendor-import", + "upstream": { "repository": "r", "commit": &f.commit[..8], "include": ["src/**"] }, + "destination": { "path": "vendor/thing" } + })) + .unwrap(); + assert!(short.validate().is_err()); + + let upper = serde_json::from_value::(serde_json::json!({ + "schemaVersion": 1, "name": "t", "class": "vendor-import", + "upstream": { "repository": "r", "commit": f.commit.to_uppercase(), "include": ["src/**"] }, + "destination": { "path": "vendor/thing" } + })) + .unwrap(); + assert!(upper.validate().is_err()); + } + + #[test] + fn an_escaping_destination_is_rejected() { + let f = fixture(); + let mut def = definition(&f.commit, &[]); + def.destination.path = "vendor/../../etc".into(); + assert!(def.validate().is_err()); + } + + #[test] + fn the_wrong_class_is_rejected() { + let f = fixture(); + let mut def = definition(&f.commit, &[]); + def.class = "source-tree".into(); + assert!(def.validate().is_err()); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 3a95472..aa6ad6f 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,3 +1,5 @@ +mod support; + use std::process::Command; fn bin() -> Command { @@ -27,3 +29,45 @@ fn help_lists_every_subcommand() { assert!(text.contains(name), "missing subcommand {name}"); } } + +#[test] +fn vendor_check_preserves_success_drift_and_invalid_input_exit_codes() { + let upstream = support::Repo::init("https://example.invalid/upstream.git"); + upstream.write("src/a.txt", b"original"); + let pin = upstream.commit("upstream"); + let mono = support::Repo::init("https://example.invalid/mono.git"); + mono.write("vendor/thing/src/a.txt", b"original"); + mono.commit("vendor"); + mono.write("vendor.json", serde_json::json!({ + "schemaVersion": 1, "name": "thing", "class": "vendor-import", + "upstream": { "repository": "https://example.invalid/upstream.git", "commit": pin, "include": ["src/**"] }, + "destination": { "path": "vendor/thing" } + }).to_string().as_bytes()); + let run = |definition: &str| { + bin() + .args(["vendor", "check", "--definition"]) + .arg(mono.path().join(definition)) + .arg("--root") + .arg(mono.path()) + .arg("--upstream") + .arg(upstream.path()) + .output() + .unwrap() + }; + let clean = run("vendor.json"); + assert_eq!( + clean.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&clean.stderr) + ); + mono.write("vendor/thing/src/a.txt", b"changed"); + mono.commit("drift"); + let drift = run("vendor.json"); + assert_eq!(drift.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&drift.stdout).contains("DRIFT")); + assert!(String::from_utf8_lossy(&drift.stderr).contains("undeclared divergence")); + let invalid = run("missing.json"); + assert_eq!(invalid.status.code(), Some(2)); + assert!(!invalid.stderr.is_empty()); +}