//! Evidence-packet construction for the goal-verification stage. //! //! The packet is a strictly-formatted block that each adversarial //! skeptic subagent receives as its user prompt: //! //! ```text //! OBJECTIVE: //! //! //! CHANGES_FILE: //! //! CHANGED_FILES: //! - //! ... //! //! PLAN_FILE: //! //! PLAN_CHANGES: //! //! FINAL_RESPONSE: //! //! ``` //! //! `PLAN_CHANGES` is the baseline→current diff of the plan file (the //! agent may edit `plan.md` mid-run); `(none)` when there is no baseline, //! no edits, or the diff could not be captured. //! //! `CHANGES_FILE` is a unified-diff *changelog* (a scope pointer, and //! the anchor for the claim↔diff honesty check) — it may be truncated. //! `CHANGED_FILES` is the *complete* list of touched paths the skeptic //! reads in their current state; verification rests on the live files //! and on running the code, not on the diff alone. The section names //! are consumed verbatim by `templates/goal_verifier_prompt.md`, so the //! format constants here are load-bearing and must not change without //! updating the template (and bumping any prompt-eval baselines). use super::GOAL_CLASSIFIER_DIFF_MAX_BYTES; use kigi_sampling_types::ConversationItem; use std::borrow::Cow; use std::io; use std::io::Read; use std::path::Path; use std::sync::OnceLock; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::process::Command; /// Max wall-clock for git commands during evidence capture. const DIFF_COMMAND_TIMEOUT: Duration = Duration::from_secs(5 * 60); /// Resolve the `git` binary path. Honours `GIT_BIN_PATH` (Bazel's /// hermetic-git data dep) and falls back to bare `git` on `PATH`. /// Bazel passes the env var as a runfiles-relative path; resolve it /// against the current working directory so spawn doesn't see a /// non-existent relative path. pub(crate) fn git_bin() -> std::ffi::OsString { let Some(raw) = std::env::var_os("GIT_BIN_PATH") else { return std::ffi::OsString::from("git"); }; let path = std::path::PathBuf::from(&raw); if path.is_relative() { match std::env::current_dir() { Ok(cwd) => cwd.join(&path).into_os_string(), Err(_) => raw, } } else { raw } } /// Build a `tokio::process::Command` for `git` with `kill_on_drop(true)` /// so a `tokio::time::timeout` firing reaps the child instead of /// orphaning it. fn git_command(cwd: &Path) -> Command { let mut cmd = Command::new(git_bin()); cmd.current_dir(cwd).kill_on_drop(true); cmd } /// SHA-1 hash of the empty tree object. Used as a synthetic parent /// for the initial-commit case (`git diff --root HEAD` does NOT /// include the initial commit's additions). SHA-256 repos fall back /// to this constant if `git hash-object` fails. const EMPTY_TREE_SHA1: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; /// Cached empty-tree hash. Only SUCCESSFUL derivations populate the /// cache so a transient git failure does not poison subsequent calls. static EMPTY_TREE_HASH_CACHE: OnceLock = OnceLock::new(); /// Per-file cap when synthesising a walkdir-based diff; protects the /// total diff budget from being dominated by a single generated file. const WALKDIR_PER_FILE_MAX_BYTES: usize = 64 * 1024; /// Sentinel rendered as the `CHANGES_FILE:` value when the harness /// could not capture a diff. The verifier prompt's rule 5 keys on /// this literal — keep in sync. pub(super) const CHANGES_UNAVAILABLE: &str = "(unavailable)"; /// `PLAN_FILE:` value when `plan_file == None` (planner disabled or never /// ran). A recorded plan still renders its path even if the file was later /// deleted — only `None` selects this sentinel. The verifier prompt's /// rule 2 keys on this literal to fall through to rule 1; keep in sync. pub(super) const PLAN_UNAVAILABLE: &str = "(unavailable)"; /// `PLAN_CHANGES:` value when there is no baseline, the plan was not /// edited, or the diff could not be captured. Distinct literal from /// `PLAN_UNAVAILABLE` — `(unavailable)` means "no plan at all", `(none)` /// means "a plan exists but nothing changed in it". pub(super) const PLAN_CHANGES_NONE: &str = "(none)"; /// Reference to the captured changes for the evidence packet. `Copy` /// so the orchestrator can fan-out the same reference to N parallel /// skeptic spawns without cloning the (already-borrowed) string. #[derive(Debug, Clone, Copy)] pub(crate) enum ChangesRef<'a> { /// Absolute path to a unified-diff patch file on disk. File(&'a str), /// Capture failed — verifier prompt rule 5 takes over. Unavailable, } /// Errors capturing the workspace diff against the goal baseline. /// All variants degrade to `(unavailable)` at the call site; the /// distinction is kept so dashboards can tell apart the failure modes. #[derive(Debug)] pub(crate) enum ChangesCaptureError { /// `create_goal` did not record a baseline commit AND the lazy /// `git rev-parse HEAD` retry did not find one (typically a /// non-git workspace). NoBaseline, /// `git diff` exited non-zero, timed out, or could not be spawned. DiffCommandFailed(String), /// The walkdir-based fallback returned no candidate files — the /// workspace had no modifications since `goal_created_at`. Distinct /// from `NoBaseline` so the caller can surface "nothing changed" /// rather than "couldn't tell". WalkdirEmpty, /// The walkdir fallback hit an I/O error before producing any /// output. Different from `WalkdirEmpty` (a legitimate no-op) so /// the failure is observable in dashboards. WalkdirFailed(io::Error), } impl std::fmt::Display for ChangesCaptureError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::NoBaseline => write!(f, "no git baseline commit recorded for this goal"), Self::DiffCommandFailed(detail) => write!(f, "git diff failed: {detail}"), Self::WalkdirEmpty => write!(f, "walkdir found no modified files in workspace"), Self::WalkdirFailed(err) => write!(f, "walkdir fallback failed: {err}"), } } } /// Max paths rendered into the `CHANGED_FILES` section. A sprawling /// diff can't blow up the packet; the overflow is surfaced to the /// skeptic as a `(… and N more)` note so it knows the list was capped. const CHANGED_FILES_MAX: usize = 300; /// Parse the changed-file paths from a unified diff's /// `diff --git a/ b/` headers (the new path). Deduplicated /// and sorted. [`capture_changes_diff`] feeds it the FULL /// pre-truncation diff, so the list stays complete even when the /// rendered patch is byte-capped. pub(crate) fn extract_changed_files(diff: &str) -> Vec { let mut files: Vec = diff .lines() .filter_map(|l| l.strip_prefix("diff --git ")) .filter_map(|rest| rest.rsplit_once(" b/").map(|(_, p)| p.to_string())) .filter(|p| !p.is_empty()) .collect(); files.sort(); files.dedup(); files } /// Build the evidence packet. `CHANGES_FILE` / `PLAN_FILE` carry an /// absolute path or the `(unavailable)` sentinel — each skeptic reads /// them with its own `read_file` tool. `changed_files` is the complete /// list of touched paths (verification's primary anchor; the skeptic /// reads their current contents). `plan_file` is borrowed (never /// cloned); `None` renders [`PLAN_UNAVAILABLE`]. `plan_changes` is the /// borrowed baseline→current plan diff (already sanitized + truncated by /// the caller); `None` renders [`PLAN_CHANGES_NONE`]. pub(crate) fn build_classifier_evidence_packet( objective: &str, changes: ChangesRef<'_>, changed_files: &[String], plan_file: Option<&Path>, plan_changes: Option<&str>, final_response: &str, ) -> String { let changes_value: &str = match changes { ChangesRef::File(p) => p, ChangesRef::Unavailable => CHANGES_UNAVAILABLE, }; let plan_value: Cow<'_, str> = match plan_file { Some(p) => p.to_string_lossy(), None => Cow::Borrowed(PLAN_UNAVAILABLE), }; let mut out = String::with_capacity( objective.len() + changes_value.len() + plan_value.len() + plan_changes.map_or(0, str::len) + final_response.len() + 128, ); // `objective` is the user's own trusted instruction (the goal they // typed), so it is embedded verbatim — only model-/workspace-derived // text (FINAL_RESPONSE, skeptic evidence) is control-token sanitized. out.push_str("OBJECTIVE:\n"); out.push_str(objective); out.push_str("\n\nCHANGES_FILE: "); out.push_str(changes_value); out.push_str("\n\nCHANGED_FILES:\n"); if changed_files.is_empty() { out.push_str("(none captured)\n"); } else { for path in changed_files.iter().take(CHANGED_FILES_MAX) { out.push_str("- "); // Paths are workspace-derived (the agent can `touch` arbitrary // names): escape frame-closing tags like FINAL_RESPONSE and // replace line-breaking chars, which `-z` delivers raw. out.push_str(&sanitize_final_response(&sanitize_path_control_chars(path))); out.push('\n'); } if changed_files.len() > CHANGED_FILES_MAX { out.push_str(&format!( "(… and {} more)\n", changed_files.len() - CHANGED_FILES_MAX )); } } out.push_str("\nPLAN_FILE: "); out.push_str(&plan_value); // PLAN_CHANGES is model-/workspace-derived (the agent authored the // plan), so the caller sanitizes it for control tokens exactly like // FINAL_RESPONSE before passing it here. out.push_str("\n\nPLAN_CHANGES:"); match plan_changes { Some(diff) => { out.push('\n'); out.push_str(diff); if !diff.ends_with('\n') { out.push('\n'); } } None => { out.push(' '); out.push_str(PLAN_CHANGES_NONE); out.push('\n'); } } out.push_str("\nFINAL_RESPONSE:\n"); out.push_str(final_response); out.push('\n'); out } /// Replace line-breaking chars (ASCII controls + U+2028/U+2029, which /// some renderers treat as newlines) with `U+FFFD`: filenames may legally /// contain them and `-z` delivers them raw, letting a path inject /// free-standing packet lines. Borrows when clean (the common case). fn sanitize_path_control_chars(path: &str) -> Cow<'_, str> { fn line_breaking(c: char) -> bool { c.is_control() || c == '\u{2028}' || c == '\u{2029}' } if path.chars().any(line_breaking) { Cow::Owned( path.chars() .map(|c| if line_breaking(c) { '\u{FFFD}' } else { c }) .collect(), ) } else { Cow::Borrowed(path) } } /// Truncate `raw` to at most `GOAL_CLASSIFIER_DIFF_MAX_BYTES`, adding /// an explicit truncation marker so each skeptic knows the diff was /// elided rather than the agent actually changing little. fn truncate_diff(raw: String) -> String { if raw.len() <= GOAL_CLASSIFIER_DIFF_MAX_BYTES { return raw; } let elided = raw.len().saturating_sub(GOAL_CLASSIFIER_DIFF_MAX_BYTES); // Truncate at a UTF-8 boundary at or below the budget; `floor_char_boundary` // is stable as of 1.79 but we use a manual scan to stay on the // crate's MSRV path. let mut cut = GOAL_CLASSIFIER_DIFF_MAX_BYTES; while cut > 0 && !raw.is_char_boundary(cut) { cut -= 1; } let mut out = String::with_capacity(cut + 64); out.push_str(&raw[..cut]); out.push_str(&format!( "\n... (diff truncated, {elided} bytes elided) ...\n" )); out } /// Captured workspace changes for the evidence packet: the (truncated) /// unified diff destined for the patch file plus the COMPLETE changed-file /// list, extracted from the full pre-truncation diff (and including /// untracked files the git layers cannot show). #[derive(Debug)] pub(crate) struct CapturedChanges { pub diff: String, pub changed_files: Vec, } /// Capture the workspace diff that each verifier skeptic will reason over. /// /// The capture strategy walks three layers of fallback, top down, and /// returns the first that yields a usable diff: /// /// 1. **Recorded baseline.** If `baseline_commit` is `Some` (the /// `setup_goal` baseline-capture path succeeded at goal-creation), /// run `git diff `. This is the happy path. /// 2. **Lazy baseline.** If `baseline_commit` is `None` but the /// workspace is a git repo *now* (the agent ran `git init` and /// committed during the goal's lifespan), re-run `git rev-parse /// HEAD`, find the OLDEST commit since `goal_created_at`, and emit /// the cumulative diff from its parent (or `--root` if the oldest /// is the very first commit in the repo). /// 3. **Walkdir + mtime.** If git is unavailable even after the lazy /// retry, recursively walk `workspace_root` for files with mtime /// newer than `goal_created_at` and synthesise a unified-diff-like /// blob (`--- /dev/null` / `+++ b/` followed by `+` /// prefixed contents, per-file capped at /// [`WALKDIR_PER_FILE_MAX_BYTES`]). Common vendor / build dirs are /// skipped. /// /// `goal_created_at` is the unix-seconds timestamp recorded on /// `GoalOrchestration.created_at`. Used to bound the lookback for /// `git log --since` and to gate the walkdir mtime filter. /// /// The changed-file list comes from the FULL pre-truncation diff (an /// over-cap diff never drops tail files); the git layers also append /// untracked paths, which `git diff` omits, while the walkdir layer /// already covers them via mtime. pub(crate) async fn capture_changes_diff( baseline_commit: Option<&str>, workspace_root: &Path, goal_created_at: i64, ) -> Result { // Layer 1 — recorded baseline. On `DiffCommandFailed` (stale SHA, // workspace was `git reset --hard`'d, etc.) fall through to // Layer 2/3 instead of propagating immediately. // // LOG-WIRE: the `"goal classifier: …"` prefix on tracing // messages in this file (and in `goal_classifier.rs`) is the // stable string dashboards / log-grep tooling matches on; // renaming it silently breaks those consumers. if let Some(baseline) = baseline_commit { match run_git_diff_against_baseline(baseline, workspace_root).await { Ok(raw) => return Ok(finish_git_capture(raw, workspace_root).await), Err(e @ ChangesCaptureError::DiffCommandFailed(_)) => { tracing::debug!( error = %e, "goal classifier: layer 1 diff failed; falling through", ); } Err(other) => return Err(other), } } // Layer 2 — lazy `git rev-parse HEAD`. If the agent initialised // the repo during the goal, emit the cumulative diff from the // oldest-in-window commit's parent. match lazy_git_baseline_diff(workspace_root, goal_created_at).await { Ok(raw) => return Ok(finish_git_capture(raw, workspace_root).await), Err(ChangesCaptureError::NoBaseline) => {} Err(other) => { tracing::debug!( error = %other, "goal classifier: lazy git baseline diff failed; falling back to walkdir", ); } } // Layer 3 — walkdir + mtime. Untracked files are already covered by // the mtime filter, so no separate untracked merge. let raw = walkdir_changes_since(workspace_root, goal_created_at).await?; let changed_files = extract_changed_files(&raw); Ok(CapturedChanges { diff: truncate_diff(raw), changed_files, }) } /// Finish a git-layer capture: extract the file list from the FULL diff, /// merge in untracked paths (invisible to `git diff`), then truncate the /// patch body. A note about untracked files is appended AFTER truncation /// so the cap can never elide it. async fn finish_git_capture(raw: String, workspace_root: &Path) -> CapturedChanges { let mut changed_files = extract_changed_files(&raw); let mut diff = truncate_diff(raw); let untracked = git_untracked_files(workspace_root).await; if !untracked.is_empty() { diff.push_str(&format!( "\n# Note: {} untracked file(s) exist but are not shown in this diff; \ they are listed in CHANGED_FILES (capped at {CHANGED_FILES_MAX} entries).\n", untracked.len() )); changed_files.extend(untracked); changed_files.sort(); changed_files.dedup(); } CapturedChanges { diff, changed_files, } } /// Untracked, non-ignored paths via `git ls-files --others /// --exclude-standard -z`. NUL-separated output so non-ASCII / special /// filenames arrive verbatim instead of octal-escaped-and-quoted. /// Best-effort: any failure returns an empty list (the diff itself is /// still usable). async fn git_untracked_files(workspace_root: &Path) -> Vec { let mut cmd = git_command(workspace_root); cmd.arg("ls-files") .arg("--others") .arg("--exclude-standard") .arg("-z"); let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await { Ok(Ok(o)) if o.status.success() => o, Ok(Ok(o)) => { tracing::debug!( exit = ?o.status.code(), "goal classifier: git ls-files non-zero exit; skipping untracked list", ); return Vec::new(); } Ok(Err(err)) => { tracing::debug!(error = %err, "goal classifier: git ls-files spawn failed"); return Vec::new(); } Err(_) => { tracing::debug!("goal classifier: git ls-files timed out"); return Vec::new(); } }; String::from_utf8_lossy(&output.stdout) .split('\0') .filter(|p| !p.is_empty()) .map(str::to_string) .collect() } /// Unified diff of the plan baseline → current plan for the /// `PLAN_CHANGES:` evidence section. The plan lives OUTSIDE the workspace /// repo, so this uses `git diff --no-index` (which works on arbitrary files; /// exit-code handling is at the match arms below). Returns `None` — rendering /// [`PLAN_CHANGES_NONE`] — when there is no baseline, either file is missing, /// the plan is unchanged, or git failed. Output is capped via [`truncate_diff`]; /// diff headers carry basenames, not the absolute session path, when both /// files share a parent dir. pub(crate) async fn capture_plan_changes( baseline_path: &Path, current_path: &Path, ) -> Option { if !baseline_path.is_file() || !current_path.is_file() { return None; } // Run from the shared parent dir and pass BASENAMES so the diff // headers read `plan.baseline.md` / `plan.md` instead of leaking the // absolute session-dir path (which embeds the session UUID) to the // skeptic. Both files are session-goal siblings; if they somehow are // not, fall back to the absolute paths rather than break. let (cmd_cwd, baseline_arg, current_arg) = match ( baseline_path.parent(), current_path.parent(), baseline_path.file_name(), current_path.file_name(), ) { (Some(bp), Some(cp), Some(bn), Some(cn)) if bp == cp => (bp, Path::new(bn), Path::new(cn)), _ => ( baseline_path.parent().unwrap_or(baseline_path), baseline_path, current_path, ), }; let mut cmd = git_command(cmd_cwd); cmd.arg("diff") .arg("--no-index") .arg("--no-prefix") .arg(baseline_arg) .arg(current_arg); let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await { Ok(Ok(o)) => o, Ok(Err(err)) => { tracing::debug!(error = %err, "goal classifier: plan diff spawn failed"); return None; } Err(_) => { tracing::debug!("goal classifier: plan diff timed out"); return None; } }; match output.status.code() { // Identical files — git prints nothing and exits 0. Some(0) => None, // Files differ — the diff is on stdout. Empty stdout here would be // anomalous, so guard against rendering an empty section. Some(1) => { let diff = String::from_utf8_lossy(&output.stdout).into_owned(); (!diff.trim().is_empty()).then(|| truncate_diff(diff)) } other => { tracing::debug!( exit = ?other, stderr = %String::from_utf8_lossy(&output.stderr).trim(), "goal classifier: plan diff returned a non-0/1 exit; rendering (none)", ); None } } } /// Pin the `a/` `b/` header prefixes [`extract_changed_files`] parses. const DIFF_PREFIX_ARGS: [&str; 2] = ["--src-prefix=a/", "--dst-prefix=b/"]; /// Run `git diff ` and return the FULL (untruncated) stdout; /// the caller extracts the changed-file list before truncating. async fn run_git_diff_against_baseline( baseline: &str, workspace_root: &Path, ) -> Result { let mut cmd = git_command(workspace_root); cmd.arg("diff").args(DIFF_PREFIX_ARGS).arg(baseline); let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await { Ok(Ok(output)) => output, Ok(Err(err)) => return Err(ChangesCaptureError::DiffCommandFailed(err.to_string())), Err(_) => { return Err(ChangesCaptureError::DiffCommandFailed( "timed out waiting for git diff".to_string(), )); } }; if !output.status.success() { return Err(ChangesCaptureError::DiffCommandFailed(format!( "exit={:?} stderr={}", output.status.code(), String::from_utf8_lossy(&output.stderr).trim() ))); } Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } /// Try to find a git baseline AFTER goal creation — the agent may /// have `git init`'d during the goal's lifespan. Returns the diff /// from the oldest in-window commit's parent (or `--root` when the /// oldest IS the initial commit) up to `HEAD`. Returns `NoBaseline` /// if the workspace is still not a git repo. async fn lazy_git_baseline_diff( workspace_root: &Path, goal_created_at: i64, ) -> Result { // First: does the workspace have a HEAD now? If `git rev-parse // HEAD` fails we're still in the no-git case. let head = git_rev_parse_head(workspace_root) .await .ok_or(ChangesCaptureError::NoBaseline)?; // Find the oldest commit since `goal_created_at`. `git log --since` // accepts a unix-timestamp string. `--reverse --format=%H` lists // matches oldest-first; we take the first line. let oldest = git_oldest_commit_since(workspace_root, goal_created_at).await; let Some(oldest) = oldest else { // Repo exists but no commits land within the goal's lifespan // — fall back to walkdir (the agent may have edits not yet // committed). Caller treats `NoBaseline` as "drop through". return Err(ChangesCaptureError::NoBaseline); }; // If `oldest` has a parent, diff `parent..HEAD`. Otherwise the // oldest IS the initial commit; diff against the empty-tree SHA // as a synthetic parent (`git diff --root HEAD` does not include // the initial commit's additions). Hash derived dynamically to // support SHA-256 repos. let mut cmd = git_command(workspace_root); if git_has_parent(workspace_root, &oldest).await { cmd.arg("diff") .args(DIFF_PREFIX_ARGS) .arg(format!("{oldest}^..{head}")); } else { let empty_tree = derive_empty_tree_sha(workspace_root).await; cmd.arg("diff") .args(DIFF_PREFIX_ARGS) .arg(format!("{empty_tree}..{head}")); } let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await { Ok(Ok(output)) => output, Ok(Err(err)) => return Err(ChangesCaptureError::DiffCommandFailed(err.to_string())), Err(_) => { return Err(ChangesCaptureError::DiffCommandFailed( "timed out waiting for lazy git diff".to_string(), )); } }; if !output.status.success() { return Err(ChangesCaptureError::DiffCommandFailed(format!( "lazy diff exit={:?} stderr={}", output.status.code(), String::from_utf8_lossy(&output.stderr).trim() ))); } Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } /// Empty-tree object hash for the calling workspace, derived via /// `git hash-object -t tree --stdin &'static str { if let Some(cached) = EMPTY_TREE_HASH_CACHE.get() { return cached.as_str(); } match run_hash_object_tree(workspace_root).await { Some(sha) => { // First-writer wins on the OnceLock. let _ = EMPTY_TREE_HASH_CACHE.set(sha); EMPTY_TREE_HASH_CACHE .get() .map(String::as_str) .unwrap_or(EMPTY_TREE_SHA1) } None => EMPTY_TREE_SHA1, } } /// Spawn `git hash-object -t tree --stdin` with empty stdin and return /// the hash. Returns `None` on any failure so the caller falls back to /// the SHA-1 constant. async fn run_hash_object_tree(workspace_root: &Path) -> Option { use std::process::Stdio; let mut cmd = git_command(workspace_root); cmd.arg("hash-object") .arg("-t") .arg("tree") .arg("--stdin") .stdin(Stdio::null()); let output = match tokio::time::timeout(Duration::from_secs(1), cmd.output()).await { Ok(Ok(o)) => o, Ok(Err(err)) => { tracing::warn!( error = %err, "goal classifier: `git hash-object` failed; using SHA-1 empty-tree fallback", ); return None; } Err(_) => { tracing::warn!( "goal classifier: `git hash-object` timed out; using SHA-1 empty-tree fallback", ); return None; } }; if !output.status.success() { tracing::warn!( exit = ?output.status.code(), "goal classifier: `git hash-object` non-zero exit; using SHA-1 empty-tree fallback", ); return None; } let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); (!sha.is_empty()).then_some(sha) } /// Best-effort `git rev-parse HEAD`. Returns `None` for any failure /// (workspace is not a git repo, git unavailable, timeout). Mirrors /// `goal_classifier::capture_git_baseline` but with a shorter budget /// so the lazy retry adds at most ~1s to the runner's wall clock. async fn git_rev_parse_head(workspace_root: &Path) -> Option { let mut cmd = git_command(workspace_root); cmd.arg("rev-parse").arg("HEAD"); let output = match tokio::time::timeout(Duration::from_secs(1), cmd.output()).await { Ok(Ok(o)) => o, Ok(Err(err)) => { tracing::debug!(error = %err, "goal classifier: git rev-parse HEAD spawn failed"); return None; } Err(_) => { tracing::debug!("goal classifier: git rev-parse HEAD timed out"); return None; } }; if !output.status.success() { tracing::debug!( exit = ?output.status.code(), stderr = %String::from_utf8_lossy(&output.stderr).trim(), "goal classifier: git rev-parse HEAD non-zero exit (likely not a git repo)", ); return None; } let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); (!sha.is_empty()).then_some(sha) } /// `git log --since= --reverse --format=%H` — first line if any. async fn git_oldest_commit_since(workspace_root: &Path, since_unix: i64) -> Option { let mut cmd = git_command(workspace_root); cmd.arg("log") .arg(format!("--since={since_unix}")) .arg("--reverse") .arg("--format=%H"); let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await { Ok(Ok(o)) => o, Ok(Err(err)) => { tracing::debug!(error = %err, "goal classifier: git log spawn failed"); return None; } Err(_) => { tracing::debug!("goal classifier: git log timed out"); return None; } }; if !output.status.success() { tracing::debug!( exit = ?output.status.code(), "goal classifier: git log non-zero exit", ); return None; } let stdout = String::from_utf8_lossy(&output.stdout); stdout .lines() .next() .map(str::to_string) .filter(|s| !s.is_empty()) } /// `true` if `commit` has at least one parent. Used to choose between /// `parent..HEAD` and `--root HEAD` for the lazy-baseline diff. async fn git_has_parent(workspace_root: &Path, commit: &str) -> bool { let mut cmd = git_command(workspace_root); cmd.arg("rev-parse").arg(format!("{commit}^")); match tokio::time::timeout(Duration::from_secs(1), cmd.output()).await { Ok(Ok(o)) => o.status.success(), Ok(Err(err)) => { tracing::debug!(error = %err, "goal classifier: git_has_parent spawn failed"); false } Err(_) => { tracing::debug!("goal classifier: git_has_parent timed out"); false } } } /// Walkdir-based fallback. Walks `workspace_root` for files with /// mtime > `goal_created_at` and synthesises a unified-diff-like /// payload. Skips `.git/`, `target/`, `node_modules/`, etc. so a /// build cache does not dominate the diff budget. Per-file output is /// capped at [`WALKDIR_PER_FILE_MAX_BYTES`]; the walk stops shortly past /// [`GOAL_CLASSIFIER_DIFF_MAX_BYTES`] and the caller applies the exact /// [`truncate_diff`] cap. async fn walkdir_changes_since( workspace_root: &Path, goal_created_at: i64, ) -> Result { let root = workspace_root.to_path_buf(); let threshold_unix = goal_created_at; // Walk + I/O is sync — offload onto the blocking pool so we don't // wedge the runtime on a large workspace. let result = tokio::task::spawn_blocking(move || walkdir_changes_blocking(&root, threshold_unix)) .await .map_err(|join_err| { ChangesCaptureError::WalkdirFailed(io::Error::other(format!( "walkdir join error: {join_err}" ))) })?; let buf = result?; if buf.is_empty() { return Err(ChangesCaptureError::WalkdirEmpty); } Ok(buf) } fn walkdir_changes_blocking( workspace_root: &Path, threshold_unix: i64, ) -> Result { use ignore::WalkBuilder; let mut builder = WalkBuilder::new(workspace_root); builder .standard_filters(true) .git_ignore(true) .git_global(false) .git_exclude(true) .hidden(false) // Symlinks never followed: a symlink-into-skipped-dir cannot // smuggle target bytes into the diff. .follow_links(false) .filter_entry(|entry| { if entry.depth() == 0 { return true; } let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir()); if !is_dir { return true; } entry .file_name() .to_str() .map(|name| { name != ".git" && !kigi_file_utils::skip_dir_set().contains(name.to_lowercase().as_str()) }) .unwrap_or(true) }); let mut out = String::new(); let mut walked_at_least_one = false; let mut walk_error: Option = None; for dent in builder.build() { let dent = match dent { Ok(d) => d, Err(err) => { walked_at_least_one = true; // Capture the first hard I/O error; continue walking // so one bad symlink doesn't abort the rest. if walk_error.is_none() && let Some(io_err) = err.io_error() { walk_error = Some(io::Error::new(io_err.kind(), err.to_string())); } continue; } }; walked_at_least_one = true; // Files only — directories are not part of the diff payload. let Some(ft) = dent.file_type() else { continue }; if !ft.is_file() { continue; } let path = dent.path(); let meta = match path.metadata() { Ok(m) => m, Err(_) => continue, }; let modified = meta.modified().ok().and_then(|m| { m.duration_since(UNIX_EPOCH) .ok() .map(|d| d.as_secs() as i64) .or_else(|| { UNIX_EPOCH .duration_since(m) .ok() .map(|d| -(d.as_secs() as i64)) }) }); let Some(mtime) = modified else { continue }; // `<` not `<=`: include files modified in the same second as // `goal_created_at` (some filesystems have 1s mtime granularity). if mtime < threshold_unix { continue; } let rel = path .strip_prefix(workspace_root) .unwrap_or(path) .display() .to_string(); // Early-exit: don't even stat the file if the global budget // is already exhausted. if out.len() >= GOAL_CLASSIFIER_DIFF_MAX_BYTES { break; } let mut head_buf = Vec::with_capacity(meta.len().min(8192) as usize); let exceeded_per_file_cap; match std::fs::File::open(path) { Ok(file) => { // Read at most cap+1 bytes — the extra byte signals // truncation without slurping multi-GB files. let read_limit = (WALKDIR_PER_FILE_MAX_BYTES as u64).saturating_add(1); let mut reader = std::io::BufReader::new(file).take(read_limit); if reader.read_to_end(&mut head_buf).is_err() { continue; } exceeded_per_file_cap = head_buf.len() > WALKDIR_PER_FILE_MAX_BYTES; if exceeded_per_file_cap { // Truncate to a UTF-8 boundary so // `String::from_utf8_lossy` cannot inject // `U+FFFD` replacement chars for a split codepoint. let cap = utf8_truncate_boundary(&head_buf, WALKDIR_PER_FILE_MAX_BYTES); head_buf.truncate(cap); } } Err(_) => continue, } // Binary heuristic: matches git's "is_binary" — NUL byte in // the head 8 KiB. Do not "improve" this without re-checking git. let head_for_binary_check = &head_buf[..head_buf.len().min(8192)]; let is_binary = head_for_binary_check.contains(&0); emit_walkdir_diff_header(&mut out, &rel); if is_binary { out.push_str(&format!( "Binary file b/{rel} differs ({} bytes)\n", meta.len() )); continue; } let text = String::from_utf8_lossy(&head_buf); // Count newlines for the hunk header. Files missing a // trailing newline get +1 to match git's hunk-counting. let mut hunk_lines = text.matches('\n').count(); let trailing_synthetic_newline = !text.ends_with('\n') && !text.is_empty(); if trailing_synthetic_newline { hunk_lines += 1; } out.push_str(&format!("@@ -0,0 +1,{hunk_lines} @@\n")); let mut truncated = false; let mut content_bytes_written = 0usize; for line in text.split_inclusive('\n') { // Count `out`-bytes (including the `+` prefix) against // the per-file cap so many-short-line inputs cannot // overshoot. let projected = content_bytes_written + 1 + line.len(); if projected > WALKDIR_PER_FILE_MAX_BYTES { out.push_str(&format!( "+... (file truncated at {WALKDIR_PER_FILE_MAX_BYTES} bytes) ...\n" )); truncated = true; break; } out.push('+'); out.push_str(line); content_bytes_written = projected; } // Trailing newline patch only if NOT truncated; the // truncation marker already ends with `\n`. if !truncated && exceeded_per_file_cap { out.push_str(&format!( "+... (file truncated at {WALKDIR_PER_FILE_MAX_BYTES} bytes) ...\n" )); } else if !truncated && trailing_synthetic_newline { out.push('\n'); } if out.len() >= GOAL_CLASSIFIER_DIFF_MAX_BYTES { break; } } // A walk-iterator I/O error with no usable output surfaces as // `WalkdirFailed`; partial output is preferred over erroring. if out.is_empty() && walked_at_least_one && let Some(err) = walk_error { return Err(ChangesCaptureError::WalkdirFailed(err)); } Ok(out) } /// Emit the per-file unified-diff header (`diff --git`, `new file /// mode`, `--- /dev/null`, `+++ b/`) so standard diff parsers /// accept the synthetic output. `100644` is a deliberate /// simplification — the walkdir fallback does not mirror real /// filesystem modes. fn emit_walkdir_diff_header(out: &mut String, rel: &str) { out.push_str(&format!("diff --git a/{rel} b/{rel}\n")); out.push_str("new file mode 100644\n"); out.push_str("--- /dev/null\n"); out.push_str(&format!("+++ b/{rel}\n")); } /// Walk back from `desired` to the last valid UTF-8 char boundary /// at or below it. Bounded by 3 hops (max codepoint width − 1). fn utf8_truncate_boundary(buf: &[u8], desired: usize) -> usize { let mut cap = desired.min(buf.len()); // Positions 0 and buf.len() are always boundaries. if cap == 0 || cap == buf.len() { return cap; } // Walk back over continuation bytes until `buf[cap]` is a leading // byte (or we reach 0). At most 3 hops since the widest UTF-8 // codepoint has 1 leading + 3 continuation bytes. for _ in 0..3 { if cap == 0 || buf[cap] & 0b1100_0000 != 0b1000_0000 { break; } cap -= 1; } cap } /// Convert a `chrono::DateTime` style RFC-3339 string to unix-seconds. /// Returns `0` (the unix epoch) on parse failure so the walkdir /// fallback degrades to "everything that exists is newer than the /// epoch" — surfaces too much diff but is preferable to silently /// rendering `(unavailable)` when the agent committed real work. pub(crate) fn parse_created_at_to_unix(rfc3339: &str) -> i64 { match chrono::DateTime::parse_from_rfc3339(rfc3339) { Ok(dt) => dt.timestamp(), Err(err) => { // Loud warn — the epoch fallback can dump every file in // the workspace into the synthetic diff. tracing::warn!( input = %rfc3339, error = %err, "goal classifier: created_at parse failed; threshold = epoch", ); 0 } } } /// Current wall-clock time as unix seconds (helper for tests + sites /// that need a "now" baseline without pulling chrono). #[cfg_attr( not(test), expect( dead_code, reason = "used from tests; wire production call sites as needed" ) )] pub(crate) fn now_unix_seconds() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } /// Reverse-scan for the last text-bearing assistant item (skips tool-only /// assistant turns). Used by skeptics / completed-goal paths. pub(crate) fn extract_final_response(items: &[ConversationItem]) -> Option { for item in items.iter().rev() { if let ConversationItem::Assistant(a) = item && !a.content.trim().is_empty() { return Some(a.content.to_string()); } } None } /// Cap (in `char`s, not bytes) on the persisted breadth anchor /// (`first_final_response`): bounds only the on-disk value; the live panel /// still receives the full summary. Mirrors `GOAL_STRATEGIST_RECOMMENDATION_MAX_CHARS`. const FIRST_FINAL_RESPONSE_MAX_CHARS: usize = 4096; /// Output of [`compose_verifier_final_response`]. `to_send` is the /// `FINAL_RESPONSE` for this round's panel; `to_persist` is `Some` only /// on the first round, carrying the (capped) value to freeze as the /// goal's breadth anchor. pub(crate) struct ComposedFinalResponse { pub to_send: String, pub to_persist: Option, } /// Compose the verifier `FINAL_RESPONSE` for one verification round. /// /// `first` is the persisted breadth anchor (`None` on the first round, /// where `current` IS the full deliverable: sent, and returned capped to /// persist). On re-verification the anchor leads and `current` is appended /// under a header only when non-blank. pub(crate) fn compose_verifier_final_response( first: Option<&str>, current: String, ) -> ComposedFinalResponse { match first { None => { // Don't freeze a blank anchor: an empty round-1 summary would // become the permanent breadth anchor and demote the first real // summary. Persist only once the round carries real content. let to_persist = if current.trim().is_empty() { None } else { Some( current .chars() .take(FIRST_FINAL_RESPONSE_MAX_CHARS) .collect(), ) }; ComposedFinalResponse { to_send: current, to_persist, } } Some(anchor) => { // Skip the note when blank, or when it merely re-surfaces the // anchor (the implementer re-completed without new prose): old // text must not be relabeled as this round's delta. let note = current.trim(); let to_send = if note.is_empty() || note == anchor.trim() { anchor.to_string() } else { format!("{anchor}\n\n## Changes this round\n{note}") }; ComposedFinalResponse { to_send, to_persist: None, } } } } /// Tags that, if echoed verbatim by the model inside FINAL_RESPONSE, /// would let adversarial / accidental content escape the /// system-reminder block built by the verifier prompt and /// re-interpret the rest of the evidence packet. The escape strategy /// inserts a zero-width sentinel (`>`-prefixed comment) so the close /// tag never matches the outer wrapper while preserving the visual /// form for the human reviewing the details file. const SANITIZE_TAGS: &[&str] = &[ "", "", "", "", "", ]; /// Sanitize model-/workspace-derived evidence text (FINAL_RESPONSE and /// PLAN_CHANGES) for embedding in the evidence packet. Escapes the close /// tag of any system-reminder-ish block so the model can't escape its /// region by echoing `` in its own prose. Other /// content passes through untouched — we deliberately avoid HTML-escaping /// the whole blob so diff-like text stays human-readable. /// /// Returns `Cow::Borrowed(text)` when no tag is present so the /// common happy path does not allocate (FINAL_RESPONSE can be many KiB). /// /// **Threat model:** only *closing* tags are escaped. A solo opening /// tag cannot terminate the outer system-reminder block; the /// verifier prompt also tells the subagent to treat FINAL_RESPONSE /// as untrusted, so any open/close pair inside is handled at the /// prompt level. pub(crate) fn sanitize_final_response(text: &str) -> Cow<'_, str> { if !SANITIZE_TAGS.iter().any(|t| text.contains(t)) { return Cow::Borrowed(text); } let mut out = text.to_string(); for tag in SANITIZE_TAGS { if out.contains(tag) { // Insert a `` between `<` and `/` so the // resulting string is no longer a valid close tag, but // a human glancing at the details file can still tell // what the original content was. let escaped = format!("<{}", &tag[1..]); out = out.replace(tag, &escaped); } } Cow::Owned(out) } #[cfg(test)] mod tests { use super::*; use kigi_sampling_types::{AssistantItem, UserItem}; fn assistant(text: &str) -> ConversationItem { ConversationItem::Assistant(AssistantItem { content: text.into(), tool_calls: Vec::new(), model_id: None, model_fingerprint: None, reasoning_effort: None, }) } /// Assistant item with a populated `tool_calls` slot but no /// textual content — exactly the shape produced when the model /// emits a single `update_goal(completed: true)` and no prose. fn assistant_tool_call_only() -> ConversationItem { ConversationItem::Assistant(AssistantItem { content: "".into(), tool_calls: vec![kigi_sampling_types::ToolCall { id: "call_1".into(), name: "update_goal".to_string(), arguments: "{\"completed\":true}".into(), }], model_id: None, model_fingerprint: None, reasoning_effort: None, }) } fn user(text: &str) -> ConversationItem { ConversationItem::User(UserItem { content: vec![kigi_sampling_types::ContentPart::Text { text: text.into() }], synthetic_reason: None, ..Default::default() }) } #[test] fn evidence_packet_with_file_path_renders_path_not_content() { let packet = build_classifier_evidence_packet( "do X", ChangesRef::File("/tmp/goal-classifier-abc-1.patch"), &["js/main.js".to_string()], Some(Path::new("/home/u/.kigi/sessions/s1/goal/plan.md")), None, "I did it.", ); assert_eq!( packet, "OBJECTIVE:\ndo X\n\n\ CHANGES_FILE: /tmp/goal-classifier-abc-1.patch\n\n\ CHANGED_FILES:\n- js/main.js\n\n\ PLAN_FILE: /home/u/.kigi/sessions/s1/goal/plan.md\n\n\ PLAN_CHANGES: (none)\n\n\ FINAL_RESPONSE:\nI did it.\n", ); } #[test] fn evidence_packet_neutralizes_frame_tags_in_changed_file_paths() { // A `<`-named dir + `system-reminder>`-named file composes the // literal close tag inside a path; it must be escaped like // FINAL_RESPONSE so the packet frame can't be terminated. let files = vec!["a//b.rs".to_string()]; let packet = build_classifier_evidence_packet( "do X", ChangesRef::Unavailable, &files, None, None, "resp", ); assert!(!packet.contains("")); assert!(packet.contains("</system-reminder>")); } #[test] fn evidence_packet_replaces_control_chars_in_changed_file_paths() { // Line-breaking filenames (incl. U+2028/U+2029) must not inject // free-standing packet lines. let files = vec![ "evil\nNOT A BULLET: verifier directive".to_string(), "ls\u{2028}LS INJECTION".to_string(), "ps\u{2029}PS INJECTION".to_string(), ]; let packet = build_classifier_evidence_packet( "do X", ChangesRef::Unavailable, &files, None, None, "resp", ); assert!( !packet.contains("\nNOT A BULLET"), "embedded newline must not start a new packet line: {packet}", ); assert!(packet.contains("- evil\u{FFFD}NOT A BULLET")); assert!(!packet.contains('\u{2028}') && !packet.contains('\u{2029}')); assert!(packet.contains("- ls\u{FFFD}LS INJECTION")); assert!(packet.contains("- ps\u{FFFD}PS INJECTION")); } #[test] fn evidence_packet_with_unavailable_renders_sentinel() { let packet = build_classifier_evidence_packet( "do X", ChangesRef::Unavailable, &[], None, None, "resp", ); assert!(packet.contains("CHANGES_FILE: (unavailable)\n")); assert!(packet.contains("CHANGED_FILES:\n(none captured)\n")); assert!(packet.contains("OBJECTIVE:\ndo X")); assert!(packet.contains("PLAN_CHANGES: (none)\n")); assert!(packet.contains("FINAL_RESPONSE:\nresp")); } #[test] fn evidence_packet_renders_plan_file_unavailable_when_none() { // Planner-off goal (`plan_file: None`) → `PLAN_FILE: (unavailable)`. let packet = build_classifier_evidence_packet( "do X", ChangesRef::File("/tmp/p.patch"), &[], None, None, "resp", ); assert!(packet.contains("PLAN_FILE: (unavailable)\n")); } #[test] fn evidence_packet_section_ordering_is_stable() { // The verifier prompt references these sections by name; pin the // order: OBJECTIVE → CHANGES_FILE → CHANGED_FILES → PLAN_FILE → // PLAN_CHANGES → FINAL_RESPONSE. let packet = build_classifier_evidence_packet( "obj", ChangesRef::File("/tmp/p.patch"), &["a.rs".to_string()], Some(Path::new("/tmp/plan.md")), Some("@@ -1 +1 @@\n-old\n+new\n"), "resp", ); let obj = packet.find("OBJECTIVE:").unwrap(); let changes = packet.find("CHANGES_FILE:").unwrap(); let changed = packet.find("CHANGED_FILES:").unwrap(); let plan = packet.find("PLAN_FILE:").unwrap(); let plan_changes = packet.find("PLAN_CHANGES:").unwrap(); let final_resp = packet.find("FINAL_RESPONSE:").unwrap(); assert!( obj < changes && changes < changed && changed < plan && plan < plan_changes && plan_changes < final_resp ); } /// When `plan_changes` is `Some`, the packet renders the diff as a /// `PLAN_CHANGES:` block (not the sentinel) right after PLAN_FILE. #[test] fn evidence_packet_renders_plan_changes_block_when_present() { let diff = "@@ -3 +3 @@\n-- [ ] criterion 3\n+- [ ] criterion 3 (relaxed)\n"; let packet = build_classifier_evidence_packet( "obj", ChangesRef::Unavailable, &[], Some(Path::new("/tmp/plan.md")), Some(diff), "resp", ); assert!( packet.contains(&format!("PLAN_CHANGES:\n{diff}")), "PLAN_CHANGES block must carry the diff verbatim:\n{packet}", ); assert!(!packet.contains("PLAN_CHANGES: (none)")); let plan = packet.find("PLAN_FILE:").unwrap(); let plan_changes = packet.find("PLAN_CHANGES:").unwrap(); let final_resp = packet.find("FINAL_RESPONSE:").unwrap(); assert!(plan < plan_changes && plan_changes < final_resp); } #[test] fn evidence_packet_caps_changed_files_with_overflow_note() { let files: Vec = (0..CHANGED_FILES_MAX + 5) .map(|i| format!("f{i:04}.rs")) .collect(); let packet = build_classifier_evidence_packet( "obj", ChangesRef::Unavailable, &files, None, None, "r", ); assert!(packet.contains("- f0000.rs\n")); assert!(packet.contains("(… and 5 more)\n")); // Exactly CHANGED_FILES_MAX bullet lines rendered (the rest elided). assert_eq!(packet.matches("\n- ").count(), CHANGED_FILES_MAX); } #[tokio::test] async fn capture_plan_changes_returns_diff_for_edited_plan() { let dir = tempfile::tempdir().unwrap(); let baseline = dir.path().join("plan.baseline.md"); let current = dir.path().join("plan.md"); std::fs::write(&baseline, "- [ ] criterion 1\n- [ ] criterion 2\n").unwrap(); std::fs::write(¤t, "- [ ] criterion 1\n- [ ] criterion 2 (relaxed)\n").unwrap(); let diff = capture_plan_changes(&baseline, ¤t) .await .expect("an edited plan must produce a diff"); assert!(diff.contains("criterion 2 (relaxed)"), "got: {diff}"); assert!(diff.contains("-- [ ] criterion 2"), "got: {diff}"); // Headers carry basenames, never the absolute session path. assert!( diff.contains("plan.md") && diff.contains("plan.baseline.md"), "headers must name the basenames: {diff}", ); assert!( !diff.contains(dir.path().to_string_lossy().as_ref()), "diff headers must not leak the absolute session-dir path: {diff}", ); } #[tokio::test] async fn capture_plan_changes_is_none_for_identical_plan() { let dir = tempfile::tempdir().unwrap(); let baseline = dir.path().join("plan.baseline.md"); let current = dir.path().join("plan.md"); let body = "- [ ] criterion 1\n"; std::fs::write(&baseline, body).unwrap(); std::fs::write(¤t, body).unwrap(); assert!(capture_plan_changes(&baseline, ¤t).await.is_none()); } #[tokio::test] async fn capture_plan_changes_is_none_when_a_file_is_missing() { let dir = tempfile::tempdir().unwrap(); let baseline = dir.path().join("plan.baseline.md"); let current = dir.path().join("plan.md"); std::fs::write(¤t, "- [ ] criterion 1\n").unwrap(); // Baseline missing → no diff (and we never even spawn git). assert!(capture_plan_changes(&baseline, ¤t).await.is_none()); // Current missing → also None. std::fs::write(&baseline, "- [ ] criterion 1\n").unwrap(); std::fs::remove_file(¤t).unwrap(); assert!(capture_plan_changes(&baseline, ¤t).await.is_none()); } #[test] fn extract_changed_files_parses_git_headers_dedup_sorted() { let diff = "diff --git a/js/b.js b/js/b.js\n@@ -1 +1 @@\n-x\n+y\n\ diff --git a/js/a.js b/js/a.js\nnew file mode 100644\n\ diff --git a/old/name.rs b/new/name.rs\nrename from old/name.rs\n"; assert_eq!( extract_changed_files(diff), vec![ "js/a.js".to_string(), "js/b.js".to_string(), "new/name.rs".to_string(), ], ); } #[test] fn extract_changed_files_empty_diff_is_empty() { assert!(extract_changed_files("").is_empty()); assert!(extract_changed_files("just prose\nno headers\n").is_empty()); } #[test] fn evidence_packet_plan_path_with_spaces_and_unicode_round_trips() { let plan = "/tmp/kigi sessions/✓ goal/plan.md"; let packet = build_classifier_evidence_packet( "obj", ChangesRef::Unavailable, &[], Some(Path::new(plan)), None, "resp", ); assert!(packet.contains(&format!("PLAN_FILE: {plan}\n"))); } #[test] fn evidence_packet_handles_empty_inputs() { let packet = build_classifier_evidence_packet("", ChangesRef::Unavailable, &[], None, None, ""); assert!(packet.contains("OBJECTIVE:\n")); assert!(packet.contains("CHANGES_FILE: (unavailable)")); assert!(packet.contains("CHANGED_FILES:\n(none captured)")); assert!(packet.contains("PLAN_FILE: (unavailable)")); assert!(packet.contains("PLAN_CHANGES: (none)")); assert!(packet.contains("FINAL_RESPONSE:\n")); } #[test] fn extract_final_response_skips_tool_call_only_items() { // Tool-call-only items (populated `tool_calls`, empty // `content`) must be skipped so each skeptic sees the // prior assistant text turn, not a blank. let items = vec![ assistant("I will now complete the goal."), assistant_tool_call_only(), ]; assert_eq!( extract_final_response(&items), Some("I will now complete the goal.".to_string()) ); } #[test] fn extract_final_response_handles_empty_chat() { assert_eq!(extract_final_response(&[]), None); assert_eq!(extract_final_response(&[user("hi")]), None); } #[test] fn compose_verifier_final_response_first_round_sends_and_persists() { // First round: the summary IS the full deliverable — sent and persisted. let current = "Full deliverable: built A, B, C; all 14 tests pass."; let composed = compose_verifier_final_response(None, current.to_string()); assert_eq!(composed.to_send, current); assert_eq!(composed.to_persist.as_deref(), Some(current)); } #[test] fn compose_verifier_final_response_first_round_blank_is_not_persisted() { // A blank round-1 summary must not be frozen as the anchor, so the // first round with real content can still claim it. for blank in ["", " ", "\n\t \n"] { let composed = compose_verifier_final_response(None, blank.to_string()); assert_eq!(composed.to_send, blank); assert!( composed.to_persist.is_none(), "blank round must not freeze a breadth anchor", ); } } #[test] fn compose_verifier_final_response_reverify_appends_change_note() { // Re-verification: the stored anchor leads (breadth) and the // current message is appended under the round header (recency). let composed = compose_verifier_final_response(Some("FULL round-1 summary"), "fix note".to_string()); assert!(composed.to_send.contains("FULL round-1 summary")); assert!(composed.to_send.contains("fix note")); assert!(composed.to_send.contains("## Changes this round")); // Anchor already stored — re-verification never re-persists. assert!(composed.to_persist.is_none()); } #[test] fn compose_verifier_final_response_reverify_skips_note_when_current_equals_anchor() { // The latest assistant message can re-surface the round-1 summary // (implementer re-completed without new prose); it must NOT be // relabeled as this round's delta. let anchor = "FULL round-1 summary"; for echo in [anchor.to_string(), format!(" {anchor} ")] { let composed = compose_verifier_final_response(Some(anchor), echo); assert_eq!(composed.to_send, anchor); assert!(!composed.to_send.contains("## Changes this round")); assert!(composed.to_persist.is_none()); } } #[test] fn compose_verifier_final_response_reverify_blank_current_omits_note() { // Empty / whitespace-only current must not tack on an empty note; // `to_send` is the bare anchor. for blank in ["", " ", "\n\t \n"] { let composed = compose_verifier_final_response(Some("FULL"), blank.to_string()); assert_eq!(composed.to_send, "FULL"); assert!(!composed.to_send.contains("## Changes this round")); assert!(composed.to_persist.is_none()); } } #[test] fn compose_verifier_final_response_caps_persisted_value() { // Oversized first-round summary: the panel still gets it in full, // but the persisted anchor is bounded. Multibyte chars (`é`, // 2 bytes each) prove the cap is char-boundary-safe. let oversized = "\u{00e9}".repeat(FIRST_FINAL_RESPONSE_MAX_CHARS + 500); let composed = compose_verifier_final_response(None, oversized.clone()); assert_eq!( composed.to_send, oversized, "panel receives the full summary" ); let persisted = composed.to_persist.expect("first round persists"); assert_eq!(persisted.chars().count(), FIRST_FINAL_RESPONSE_MAX_CHARS); // No partial codepoint: each kept char is the 2-byte `é`. assert_eq!(persisted.len(), FIRST_FINAL_RESPONSE_MAX_CHARS * 2); } #[test] fn sanitize_final_response_escapes_system_reminder_close_tag() { let text = "rogue tail"; let out = sanitize_final_response(text); assert!(!out.contains("")); assert!(out.contains("</system-reminder>")); assert!(matches!(out, Cow::Owned(_))); } #[test] fn sanitize_final_response_passes_through_normal_text() { // Benign text must NOT allocate (`Cow::Borrowed`). Load-bearing // assertion: a refactor that adds a blind allocation fails here. let text = "All tests pass. Diff looks clean."; let out = sanitize_final_response(text); assert_eq!(out.as_ref(), text); assert!(matches!(out, Cow::Borrowed(_))); } #[test] fn truncate_diff_under_limit_passes_through() { let small = "abc".to_string(); assert_eq!(truncate_diff(small.clone()), small); } /// Run a `git` subcommand from `cwd`; panic on non-zero exit so /// test setup failures are loud. Used to script the lazy-baseline /// scenarios below. fn git(cwd: &std::path::Path, args: &[&str]) { let output = std::process::Command::new(super::git_bin()) .args(args) .current_dir(cwd) .output() .expect("spawn git"); assert!( output.status.success(), "git {args:?} failed: stderr={}", String::from_utf8_lossy(&output.stderr) ); } /// Like `git`, but pins the commit timestamp via /// `GIT_AUTHOR_DATE` / `GIT_COMMITTER_DATE` so `git log --since` /// filtering is deterministic without `thread::sleep`. fn git_at(cwd: &std::path::Path, args: &[&str], unix_ts: i64) { let date = format!("@{unix_ts} +0000"); let output = std::process::Command::new(super::git_bin()) .args(args) .env("GIT_AUTHOR_DATE", &date) .env("GIT_COMMITTER_DATE", &date) .current_dir(cwd) .output() .expect("spawn git"); assert!( output.status.success(), "git {args:?} failed: stderr={}", String::from_utf8_lossy(&output.stderr) ); } /// Configure a temp repo with a deterministic identity so /// `git commit` works without inheriting the host's gitconfig. fn init_repo(cwd: &std::path::Path) { git(cwd, &["init", "-q", "-b", "main"]); git(cwd, &["config", "user.email", "test@example.com"]); git(cwd, &["config", "user.name", "Test"]); git(cwd, &["config", "commit.gpgsign", "false"]); } #[tokio::test] async fn capture_changes_diff_lazy_baseline_from_initial_commit() { // `git init` exists but no commits — the lazy path returns // `NoBaseline` from `git_oldest_commit_since` and the // capture falls all the way through to the walkdir fallback. let tmp = tempfile::tempdir().unwrap(); init_repo(tmp.path()); let goal_created_at = now_unix_seconds() - 60; tokio::fs::write(tmp.path().join("hello.txt"), b"hi\n") .await .unwrap(); // Bump the mtime forward so the walkdir filter accepts it // even on filesystems with one-second mtime granularity. let later = SystemTime::now() + Duration::from_secs(30); filetime::set_file_mtime( tmp.path().join("hello.txt"), filetime::FileTime::from_system_time(later), ) .unwrap(); let diff = capture_changes_diff(None, tmp.path(), goal_created_at) .await .expect("walkdir fallback must produce a diff") .diff; assert!( diff.contains("+++ b/hello.txt"), "expected synthetic header for hello.txt; got {diff}" ); assert!(diff.contains("+hi"), "expected the file body; got {diff}"); } #[tokio::test] async fn capture_changes_diff_lazy_baseline_when_repo_created_during_goal() { // The goal was created BEFORE the repo was initialised; after // creation the agent ran `git init` + `git commit`. The lazy // path must succeed via the empty-tree synthetic parent // because the only commit is the initial one. let tmp = tempfile::tempdir().unwrap(); let goal_created_at = now_unix_seconds() - 120; init_repo(tmp.path()); tokio::fs::write(tmp.path().join("a.txt"), b"alpha\n") .await .unwrap(); git(tmp.path(), &["add", "."]); git(tmp.path(), &["commit", "-q", "-m", "initial"]); let diff = capture_changes_diff(None, tmp.path(), goal_created_at) .await .expect("lazy baseline must succeed via --root") .diff; // `index 0000000..` is git-only — walkdir's synthetic // header has no blob hash to embed. Distinguishes lazy-git // recovery from a silent walkdir fall-through. assert!( diff.contains("index 0000000"), "lazy git output must carry the real `index 0000000..` line; got {diff}" ); assert!( diff.contains("+alpha"), "lazy `git diff` must include initial-commit additions; got {diff}" ); } #[tokio::test] async fn capture_changes_diff_lazy_baseline_with_existing_history() { // Pre-creation commits must be excluded; only the // post-creation commit lands in the diff. let tmp = tempfile::tempdir().unwrap(); init_repo(tmp.path()); tokio::fs::write(tmp.path().join("pre.txt"), b"pre-existing\n") .await .unwrap(); // Fixed timestamps via GIT_AUTHOR_DATE / GIT_COMMITTER_DATE // so `git log --since` filtering is deterministic. let pre_goal_ts: i64 = 1_700_000_000; let goal_created_at: i64 = 1_700_000_100; let post_goal_ts: i64 = 1_700_000_200; git_at(tmp.path(), &["add", "."], pre_goal_ts); git_at(tmp.path(), &["commit", "-q", "-m", "pre-goal"], pre_goal_ts); tokio::fs::write(tmp.path().join("post.txt"), b"post-goal\n") .await .unwrap(); git_at(tmp.path(), &["add", "."], post_goal_ts); git_at( tmp.path(), &["commit", "-q", "-m", "post-goal"], post_goal_ts, ); let diff = capture_changes_diff(None, tmp.path(), goal_created_at) .await .expect("lazy baseline must succeed via parent..HEAD") .diff; assert!( diff.contains("post.txt"), "diff must include post-creation commit; got {diff}", ); assert!( !diff.contains("pre.txt"), "diff must NOT include pre-creation commit; got {diff}", ); } #[tokio::test] async fn capture_changes_diff_layer1_fails_falls_through_to_lazy() { // Stale recorded baseline → Layer 1 fails; a post-creation // commit IS present so Layer 2 (lazy git) recovers. The // git-only `index 0000000` token distinguishes Layer 2 from // Layer 3. let tmp = tempfile::tempdir().unwrap(); let goal_created_at = now_unix_seconds() - 60; init_repo(tmp.path()); tokio::fs::write(tmp.path().join("recovered.txt"), b"recovered\n") .await .unwrap(); git(tmp.path(), &["add", "."]); git(tmp.path(), &["commit", "-q", "-m", "post-goal"]); // Stale baseline — a SHA that is structurally valid but // does not exist in this repo's object database. let stale_baseline = "deadbeefcafef00d1234567890abcdef12345678"; let diff = capture_changes_diff(Some(stale_baseline), tmp.path(), goal_created_at) .await .expect("layer-1 failure must cascade to lazy git") .diff; assert!( diff.contains("index 0000000"), "Layer 2 (real git) must produce an `index` line; got {diff}", ); assert!(diff.contains("recovered.txt")); } #[tokio::test] async fn capture_changes_diff_layer1_and_layer2_fail_falls_through_to_walkdir() { // Both git layers fail (no git repo + stale baseline); // Layer 3 (walkdir) must produce the synthetic diff. Absence // of `index 0000000` pins which layer recovered. let tmp = tempfile::tempdir().unwrap(); let goal_created_at = now_unix_seconds() - 60; // No `git init` — `lazy_git_baseline_diff` returns // `NoBaseline` and drops through to walkdir. tokio::fs::write(tmp.path().join("from_walkdir.txt"), b"walkdir-only\n") .await .unwrap(); let later = SystemTime::now() + Duration::from_secs(30); filetime::set_file_mtime( tmp.path().join("from_walkdir.txt"), filetime::FileTime::from_system_time(later), ) .unwrap(); // Layer 1 still tries `git diff` against the stale baseline // (the file doesn't know there's no repo); it fails and // cascades through Layer 2 → Layer 3. let stale_baseline = "deadbeefcafef00d1234567890abcdef12345678"; let diff = capture_changes_diff(Some(stale_baseline), tmp.path(), goal_created_at) .await .expect("layer-3 walkdir must succeed when both git layers fail") .diff; assert!( !diff.contains("index 0000000"), "walkdir output MUST NOT carry the git `index` line; got {diff}", ); assert!( diff.contains("from_walkdir.txt"), "walkdir output must surface the modified file; got {diff}", ); assert!( diff.contains("+walkdir-only"), "walkdir output must include the file body; got {diff}", ); } #[tokio::test] async fn capture_changes_diff_walkdir_emits_parseable_unified_diff() { // Synthetic walkdir output must round-trip through standard // unified-diff parsers — pin every header line. let tmp = tempfile::tempdir().unwrap(); let goal_created_at = now_unix_seconds() - 60; tokio::fs::write(tmp.path().join("parseable.txt"), b"line one\nline two\n") .await .unwrap(); let later = SystemTime::now() + Duration::from_secs(30); filetime::set_file_mtime( tmp.path().join("parseable.txt"), filetime::FileTime::from_system_time(later), ) .unwrap(); let diff = capture_changes_diff(None, tmp.path(), goal_created_at) .await .expect("walkdir fallback must succeed") .diff; assert!(diff.contains("diff --git a/parseable.txt b/parseable.txt")); assert!(diff.contains("new file mode 100644")); assert!(diff.contains("--- /dev/null")); assert!(diff.contains("+++ b/parseable.txt")); assert!(diff.contains("@@ -0,0 +1,2 @@")); assert!(diff.contains("+line one")); assert!(diff.contains("+line two")); } #[tokio::test] async fn capture_changes_diff_walkdir_empty_when_no_modifications() { // No files newer than threshold → `WalkdirEmpty` so the // runner surfaces `(unavailable)`. let tmp = tempfile::tempdir().unwrap(); let goal_created_at = now_unix_seconds() + 1_000_000; tokio::fs::write(tmp.path().join("old.txt"), b"old\n") .await .unwrap(); let err = capture_changes_diff(None, tmp.path(), goal_created_at) .await .expect_err("must error with WalkdirEmpty"); assert!(matches!(err, ChangesCaptureError::WalkdirEmpty)); } #[tokio::test] async fn capture_changes_diff_walkdir_renders_binary_marker_for_nul_byte_file() { // NUL-byte heuristic renders the binary marker instead of // raw bytes. let tmp = tempfile::tempdir().unwrap(); let goal_created_at = now_unix_seconds() - 60; tokio::fs::write(tmp.path().join("bin.dat"), b"hello\0world\n") .await .unwrap(); let later = SystemTime::now() + Duration::from_secs(30); filetime::set_file_mtime( tmp.path().join("bin.dat"), filetime::FileTime::from_system_time(later), ) .unwrap(); let diff = capture_changes_diff(None, tmp.path(), goal_created_at) .await .expect("walkdir fallback must succeed") .diff; assert!( diff.contains("Binary file b/bin.dat differs"), "binary marker missing; got {diff}", ); assert!( !diff.contains("+hello"), "binary file must not have its content rendered as +-lines; got {diff}", ); } #[tokio::test] async fn capture_changes_diff_walkdir_includes_file_at_threshold_mtime() { // Boundary: mtime == threshold is INCLUDED (`<` not `<=`). let tmp = tempfile::tempdir().unwrap(); // arbitrary fixed timestamp let goal_created_at: i64 = 1_700_000_000; tokio::fs::write(tmp.path().join("at_threshold.txt"), b"at\n") .await .unwrap(); tokio::fs::write(tmp.path().join("after.txt"), b"after\n") .await .unwrap(); filetime::set_file_mtime( tmp.path().join("at_threshold.txt"), filetime::FileTime::from_unix_time(goal_created_at, 0), ) .unwrap(); filetime::set_file_mtime( tmp.path().join("after.txt"), filetime::FileTime::from_unix_time(goal_created_at + 1, 0), ) .unwrap(); let diff = capture_changes_diff(None, tmp.path(), goal_created_at) .await .expect("walkdir fallback must succeed") .diff; assert!( diff.contains("at_threshold.txt"), "file with mtime == threshold must be INCLUDED; got {diff}", ); assert!(diff.contains("after.txt")); } #[tokio::test] async fn capture_changes_diff_walkdir_skips_all_well_known_directories() { let tmp = tempfile::tempdir().unwrap(); let goal_created_at = now_unix_seconds() - 60; for sub in kigi_file_utils::SKIP_DIR_NAMES { let dir = tmp.path().join(sub); tokio::fs::create_dir_all(&dir).await.unwrap(); tokio::fs::write(dir.join("blob.bin"), b"skipped\n") .await .unwrap(); let later = SystemTime::now() + Duration::from_secs(30); filetime::set_file_mtime( dir.join("blob.bin"), filetime::FileTime::from_system_time(later), ) .unwrap(); } tokio::fs::write(tmp.path().join("real.txt"), b"keeper\n") .await .unwrap(); let later = SystemTime::now() + Duration::from_secs(30); filetime::set_file_mtime( tmp.path().join("real.txt"), filetime::FileTime::from_system_time(later), ) .unwrap(); let diff = capture_changes_diff(None, tmp.path(), goal_created_at) .await .expect("walkdir fallback must succeed") .diff; assert!(diff.contains("real.txt")); for sub in kigi_file_utils::SKIP_DIR_NAMES { assert!( !diff.contains(&format!("b/{sub}/blob.bin")), "walkdir must skip {sub}/; diff was: {diff}" ); } } #[tokio::test] async fn derive_empty_tree_sha_matches_canonical_sha1() { // Pins the canonical SHA-1 empty-tree hash on a default repo. let tmp = tempfile::tempdir().unwrap(); init_repo(tmp.path()); let sha = derive_empty_tree_sha(tmp.path()).await; assert_eq!(sha, EMPTY_TREE_SHA1); } #[tokio::test] async fn derive_empty_tree_sha_caches_successful_result() { // Two calls return identical strings (proxy for cache hit). let tmp = tempfile::tempdir().unwrap(); init_repo(tmp.path()); let a = derive_empty_tree_sha(tmp.path()).await; let b = derive_empty_tree_sha(tmp.path()).await; assert_eq!(a, b); } #[test] fn utf8_truncate_boundary_walks_back_to_valid_char_boundary() { // A 3-byte CJK codepoint at the cap edge must be truncated // to its start. // "hello中" = 8 bytes let buf = b"hello\xe4\xb8\xad"; assert_eq!(utf8_truncate_boundary(buf, 6), 5); assert_eq!(utf8_truncate_boundary(buf, 7), 5); assert_eq!(utf8_truncate_boundary(buf, 8), 8); assert_eq!(utf8_truncate_boundary(buf, 100), 8); assert_eq!(utf8_truncate_boundary(buf, 5), 5); // Pure ASCII: any cap is a boundary. let ascii = b"abcdefg"; assert_eq!(utf8_truncate_boundary(ascii, 3), 3); } #[test] fn truncate_diff_passes_through_at_exact_max_bytes() { let payload = "x".repeat(GOAL_CLASSIFIER_DIFF_MAX_BYTES); let out = truncate_diff(payload.clone()); assert_eq!(out, payload, "exactly-at-cap input must not be truncated"); } #[test] fn truncate_diff_truncates_at_max_plus_one() { // 1 byte over the cap: content drops, marker is appended. let payload_len = GOAL_CLASSIFIER_DIFF_MAX_BYTES + 1; let payload = "x".repeat(payload_len); let out = truncate_diff(payload); assert!(out.contains("diff truncated")); let marker_start = out.find("\n... (diff truncated").expect("marker present"); assert!( marker_start < payload_len, "content portion ({marker_start}) must be shorter than payload ({payload_len})", ); } #[tokio::test] async fn capture_changes_diff_walkdir_fallback_when_not_a_git_repo() { // No `git init` at all — capture must reach the walkdir layer // and synthesise diff hunks from mtime > goal_created_at files. let tmp = tempfile::tempdir().unwrap(); let goal_created_at = now_unix_seconds() - 60; tokio::fs::write(tmp.path().join("note.md"), b"# notes\n") .await .unwrap(); let later = SystemTime::now() + Duration::from_secs(30); filetime::set_file_mtime( tmp.path().join("note.md"), filetime::FileTime::from_system_time(later), ) .unwrap(); let diff = capture_changes_diff(None, tmp.path(), goal_created_at) .await .expect("walkdir fallback must succeed") .diff; assert!(diff.contains("+++ b/note.md")); assert!(diff.contains("+# notes")); } #[tokio::test] async fn capture_changes_diff_truncates_per_file_at_walkdir_cap() { // One file > 64 KiB but total < 256 KiB: per-file marker // appears, global marker does not. let tmp = tempfile::tempdir().unwrap(); let goal_created_at = now_unix_seconds() - 60; let payload = "x".repeat(WALKDIR_PER_FILE_MAX_BYTES + 4_096); tokio::fs::write(tmp.path().join("big.txt"), payload.as_bytes()) .await .unwrap(); let later = SystemTime::now() + Duration::from_secs(30); filetime::set_file_mtime( tmp.path().join("big.txt"), filetime::FileTime::from_system_time(later), ) .unwrap(); let diff = capture_changes_diff(None, tmp.path(), goal_created_at) .await .expect("walkdir fallback must succeed") .diff; assert!( diff.contains(&format!( "file truncated at {WALKDIR_PER_FILE_MAX_BYTES} bytes" )), "per-file truncation marker missing; first 200B = {:?}", &diff[..diff.len().min(200)], ); } #[tokio::test] async fn capture_changes_diff_truncates_global_at_max_bytes() { // Many small files summing to > 256 KiB so the global cap // fires; per-file cap is irrelevant. let tmp = tempfile::tempdir().unwrap(); let goal_created_at = now_unix_seconds() - 60; // 30 × 12 KiB = ~360 KiB, comfortably over 256 KiB. let per_file_payload = "x".repeat(12 * 1024); for i in 0..30 { let path = tmp.path().join(format!("file_{i}.txt")); tokio::fs::write(&path, per_file_payload.as_bytes()) .await .unwrap(); let later = SystemTime::now() + Duration::from_secs(30); filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(later)).unwrap(); } let diff = capture_changes_diff(None, tmp.path(), goal_created_at) .await .expect("walkdir fallback must succeed") .diff; assert!( diff.contains("diff truncated"), "global truncation marker missing; last 200B = {:?}", &diff[diff.len().saturating_sub(200)..], ); assert!( diff.len() <= GOAL_CLASSIFIER_DIFF_MAX_BYTES + 256, "output must respect the global cap (with marker slack); got {} bytes", diff.len(), ); } #[tokio::test] async fn changed_files_complete_when_git_diff_exceeds_byte_cap() { // The big file's hunks blow the byte cap, pushing the later small // file's header past it — its path must still be listed. let tmp = tempfile::tempdir().unwrap(); init_repo(tmp.path()); tokio::fs::write(tmp.path().join("aa_big.txt"), b"seed\n") .await .unwrap(); tokio::fs::write(tmp.path().join("zz_tail.txt"), b"seed\n") .await .unwrap(); git(tmp.path(), &["add", "."]); git(tmp.path(), &["commit", "-q", "-m", "baseline"]); let baseline = { let out = std::process::Command::new(super::git_bin()) .args(["rev-parse", "HEAD"]) .current_dir(tmp.path()) .output() .unwrap(); String::from_utf8_lossy(&out.stdout).trim().to_string() }; // > 256 KiB of changed lines in the alphabetically-first file. let big_body: String = (0..40_000).map(|i| format!("line {i}\n")).collect(); tokio::fs::write(tmp.path().join("aa_big.txt"), big_body.as_bytes()) .await .unwrap(); tokio::fs::write(tmp.path().join("zz_tail.txt"), b"changed\n") .await .unwrap(); git(tmp.path(), &["add", "."]); tokio::fs::write(tmp.path().join("never_added.txt"), b"untracked\n") .await .unwrap(); let captured = capture_changes_diff(Some(&baseline), tmp.path(), now_unix_seconds()) .await .expect("layer-1 git diff must succeed"); assert!( captured.diff.contains("diff truncated"), "test premise: the diff must exceed the byte cap", ); assert!( captured.diff.contains("untracked file(s)"), "the untracked note is appended AFTER truncation so the cap can't elide it", ); assert!( captured .changed_files .contains(&"never_added.txt".to_string()), ); assert!( !captured.diff.contains("zz_tail.txt"), "test premise: the tail file's header must fall past the cap", ); assert!( captured.changed_files.contains(&"zz_tail.txt".to_string()), "changed_files must come from the FULL diff, not the truncated one; got {:?}", captured.changed_files, ); } #[tokio::test] async fn untracked_files_appear_in_changed_files_for_git_layers() { // `git diff ` omits never-added files entirely; the // capture must surface them via `git ls-files --others` so // skeptics can see files the goal created. let tmp = tempfile::tempdir().unwrap(); init_repo(tmp.path()); tokio::fs::write(tmp.path().join("tracked.txt"), b"seed\n") .await .unwrap(); git(tmp.path(), &["add", "."]); git(tmp.path(), &["commit", "-q", "-m", "baseline"]); let baseline = { let out = std::process::Command::new(super::git_bin()) .args(["rev-parse", "HEAD"]) .current_dir(tmp.path()) .output() .unwrap(); String::from_utf8_lossy(&out.stdout).trim().to_string() }; tokio::fs::write(tmp.path().join("tracked.txt"), b"changed\n") .await .unwrap(); tokio::fs::write(tmp.path().join("brand_new.txt"), b"created by goal\n") .await .unwrap(); // Unicode + space: `-z` must deliver the path verbatim, not // octal-escaped-and-quoted. tokio::fs::write(tmp.path().join("日本 notes.txt"), b"unicode\n") .await .unwrap(); let captured = capture_changes_diff(Some(&baseline), tmp.path(), now_unix_seconds()) .await .expect("layer-1 git diff must succeed"); assert!( captured .changed_files .contains(&"brand_new.txt".to_string()), "untracked file must be listed in changed_files; got {:?}", captured.changed_files, ); assert!( captured .changed_files .contains(&"日本 notes.txt".to_string()), "unicode/space untracked path must be verbatim; got {:?}", captured.changed_files, ); assert!( captured.changed_files.contains(&"tracked.txt".to_string()), "tracked modification must still be listed", ); assert!( captured.diff.contains("untracked file(s)"), "patch must carry the untracked-files note", ); } #[tokio::test] async fn changed_files_ignore_the_host_diff_prefix_config() { // Both keys rewrite the `diff --git a/ b/` header the parser keys on. for key in ["diff.mnemonicprefix", "diff.noprefix"] { let tmp = tempfile::tempdir().unwrap(); init_repo(tmp.path()); git(tmp.path(), &["config", key, "true"]); tokio::fs::write(tmp.path().join("tracked.txt"), b"seed\n") .await .unwrap(); let goal_created_at: i64 = 1_700_000_100; git_at(tmp.path(), &["add", "."], goal_created_at + 100); git_at( tmp.path(), &["commit", "-q", "-m", "post-goal"], goal_created_at + 100, ); let lazy = capture_changes_diff(None, tmp.path(), goal_created_at) .await .expect("lazy baseline must succeed"); assert_eq!(lazy.changed_files, ["tracked.txt"], "{key}: lazy baseline"); tokio::fs::write(tmp.path().join("tracked.txt"), b"changed\n") .await .unwrap(); let explicit = capture_changes_diff(Some("HEAD"), tmp.path(), goal_created_at) .await .expect("layer-1 git diff must succeed"); assert_eq!( explicit.changed_files, ["tracked.txt"], "{key}: explicit baseline" ); } } }