diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63e8afe..1564b64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ cargo build cargo test ``` -All 95 tests should pass (33 unit + 40 integration + 16 GPG integration + 6 cross-compatibility). They cover: +All 112 tests should pass (34 unit + 56 integration + 16 GPG integration + 6 cross-compatibility). They cover: - AES-256-CTR encryption/decryption round-trips - HMAC-SHA1 known-answer vectors - Key file TLV serialization/deserialization @@ -34,6 +34,8 @@ All 95 tests should pass (33 unit + 40 integration + 16 GPG integration + 6 cros - Key name validation - Full E2E: init → encrypt → lock → unlock (integration) - Status, export-key, quiet mode, error messages (integration) +- Status: default focused output (tracked + untracked filter-marked only), `(untracked)` suffix on untracked filter files to distinguish prospective vs actual encryption, `-a/--all` includes non-filter files, `-e` only files with encrypted blob, `-u` only WARNING files needing re-encryption, WARNING + summary for filter-marked files with plaintext blob, named-key filter, filenames with spaces, clear error outside a git repo, works without `gitveil init`, `-f` skips files deleted from the working tree, gitignored files are excluded (integration) +- Status: `has_git_crypt_filter` recognizes default and named-key filters (unit) - Edge cases: empty files, binary files, multi-key lock (integration) - Pipe deadlock regression: many-file and large-blob status, unlock, lock (integration) - Global config: XDG resolution, keyring path save/load/remove, permissions (unit) diff --git a/README.md b/README.md index f86348a..62bea21 100644 --- a/README.md +++ b/README.md @@ -285,19 +285,27 @@ Omit the output file to write to stdout. ### `gitveil status` -Show encryption status of tracked files. +Show the encryption status of files in the repository. ``` -gitveil status [-e] [-u] [-f] +gitveil status [-e] [-u] [-a | --all] [-f | --fix] ``` | Option | Description | |--------|-------------| -| `-e` | Show only encrypted files | -| `-u` | Show only unencrypted files | -| `-f, --fix` | Re-encrypt files that should be encrypted but aren't | +| _(none)_ | List files marked for encryption (the actionable set), tracked + untracked | +| `-e` | Show only files whose committed blob is encrypted | +| `-u` | Show only files marked for encryption whose blob is plaintext (the set needing re-encryption — pair with `-f` to fix) | +| `-a, --all` | Include files **without** the git-crypt filter too (verbose `git-crypt`-style listing) | +| `-f, --fix` | Re-stage tracked files whose committed blob is plaintext but should be encrypted (skips files deleted from the working tree; never auto-adds untracked files) | -The status command uses batched subprocesses (3 total, regardless of repo size) instead of spawning one per file. On a Unity project with ~4,000 files it completes in ~130 ms vs ~65 seconds for git-crypt -- roughly 500x faster. +By default, status is focused on files governed by a git-crypt filter — for a large repo this is the actionable subset. Tracked **and** untracked filter-matched files are shown. Use `-a/--all` for the full `git-crypt`-style listing that also includes non-filter files. + +When a filter-marked file's committed blob is plaintext (typically because it was staged before `.gitattributes` took effect), `*** WARNING ***` is appended to its line and a summary at the end suggests `gitveil status -f`. Untracked filter-marked files appear with an `(untracked)` suffix to make it clear that the file on disk is still plaintext — it'll be encrypted on staging. + +Works without `gitveil init` -- the command is informational and can be used to audit filter coverage before initializing. If filter-marked files were committed without init, status surfaces them as WARNINGs. + +Performance: at most one `git ls-files` per category, one batched `git check-attr`, and one batched `git cat-file` regardless of repo size. On a Unity project with ~4,000 files it completes in ~130 ms vs ~65 seconds for git-crypt -- roughly 500x faster. ## Named Keys diff --git a/src/cli.rs b/src/cli.rs index f96d3f3..b93dbbe 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -85,17 +85,23 @@ pub enum Commands { output_file: Option, }, - /// Display the encryption status of tracked files + /// Display the encryption status of files in the repository Status { - /// Show only encrypted files + /// Show only files whose blob is encrypted #[arg(short = 'e')] encrypted_only: bool, - /// Show only unencrypted files + /// Show only files marked for encryption whose blob is plaintext + /// (the set needing re-encryption — pair with -f to fix) #[arg(short = 'u')] unencrypted_only: bool, - /// Re-encrypt files that should be encrypted but aren't + /// Show every file, including files without the git-crypt filter + /// (verbose git-crypt-style listing) + #[arg(short = 'a', long = "all")] + all: bool, + + /// Re-stage files that should be encrypted but aren't #[arg(short = 'f', long)] fix: bool, }, diff --git a/src/commands/status.rs b/src/commands/status.rs index b9a6100..a887f6b 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::io::{BufRead, BufReader, Read, Write}; use std::process::{Command, Stdio}; use std::thread; @@ -6,87 +7,248 @@ use colored::Colorize; use crate::constants::{ENCRYPTED_FILE_HEADER, ENCRYPTED_FILE_HEADER_LEN}; use crate::error::GitVeilError; +use crate::git::repo::find_git_dir; -/// Display the encryption status of tracked files. -/// -/// Performance: uses only 3 subprocesses regardless of repo size: -/// 1. `git ls-files` — list all tracked files -/// 2. `git check-attr -z --stdin` — batch-check filter attributes -/// 3. `git cat-file --batch` — batch-check blob headers for encryption -pub fn status(encrypted_only: bool, unencrypted_only: bool, fix: bool) -> Result<(), GitVeilError> { - // Get all tracked files - let ls_output = Command::new("git") - .args(["ls-files"]) - .output() - .map_err(|e| GitVeilError::Git(format!("failed to run git ls-files: {}", e)))?; - - if !ls_output.status.success() { - return Err(GitVeilError::Git("git ls-files failed".into())); - } - - let all_files_str = String::from_utf8_lossy(&ls_output.stdout); - let all_files: Vec<&str> = all_files_str.lines().filter(|l| !l.is_empty()).collect(); - - if all_files.is_empty() { - return Ok(()); - } - - // Batch check attributes using -z --stdin (NUL-delimited, single subprocess) - let git_crypt_files = get_git_crypt_files(&all_files)?; +/// A file from `git ls-files` along with whether it is tracked. +struct FileEntry { + path: String, + tracked: bool, +} - if git_crypt_files.is_empty() { +/// Display the encryption status of files in the repository. +/// +/// By default the output is focused on files governed by a git-crypt +/// filter — for a large repo this is the actionable subset. A WARNING +/// suffix is appended when a filter-marked tracked file's index blob is +/// plaintext (typically staged before `.gitattributes` was in effect), +/// and a summary points at `-f`. Pass `--all` to also list files without +/// the filter (git-crypt-style verbose output). +/// +/// Flags: +/// - `-e`: only files whose blob is encrypted +/// - `-u`: only files marked for encryption whose blob is plaintext +/// (the WARNING set — pair with `-f` to re-stage them) +/// - `-a`/`--all`: include files without the git-crypt filter too +/// - `-f`: re-stage WARNING files so the clean filter encrypts them +/// +/// Performance: at most one `git ls-files` per category (tracked/untracked), +/// one batched `git check-attr -z --stdin`, and one batched `git cat-file +/// --batch` regardless of repo size. +pub fn status( + encrypted_only: bool, + unencrypted_only: bool, + all: bool, + fix: bool, +) -> Result<(), GitVeilError> { + // Validate up-front that we're inside a git work tree. Without this the + // failure mode is a confusing "git ls-files failed" message instead of + // the clean NotAGitRepo error. + find_git_dir()?; + + let files = list_all_files()?; + if files.is_empty() { return Ok(()); } - // Batch check which blobs are actually encrypted (single subprocess) - let encrypted_flags = batch_check_blobs_encrypted(&git_crypt_files)?; - - let mut files_to_fix = Vec::new(); - - for (file, is_encrypted) in git_crypt_files.iter().zip(encrypted_flags.iter()) { - if *is_encrypted { - if !unencrypted_only { - println!(" {} {}", "encrypted:".green(), file); - } - } else { - if !encrypted_only { - println!("{} {}", "not encrypted:".yellow(), file); - } - if fix { - files_to_fix.push(file.clone()); + // Map every file (tracked or not) to its filter value, if any. + let filter_map = batch_check_filters(&files)?; + + // Blob-check only tracked files that have a git-crypt filter. Untracked + // files have no blob in the index (cat-file would return "missing"), and + // non-filter files don't need the check. + let tracked_filter_paths: Vec = files + .iter() + .filter(|f| { + f.tracked + && filter_map + .get(&f.path) + .map(|v| has_git_crypt_filter(v)) + .unwrap_or(false) + }) + .map(|f| f.path.clone()) + .collect(); + + let blob_encrypted = if tracked_filter_paths.is_empty() { + Vec::new() + } else { + batch_check_blobs_encrypted(&tracked_filter_paths)? + }; + + let blob_status: HashMap<&str, bool> = tracked_filter_paths + .iter() + .map(|s| s.as_str()) + .zip(blob_encrypted.iter().copied()) + .collect(); + + // Display selectors. WARNING files are always collected (used by -f + // and the summary) even when not printed, so `-e -f` still re-stages + // them — the suppression applies to *display only*. + let show_warning_lines = !encrypted_only; + let show_encrypted_lines = !unencrypted_only; + let show_non_filter_lines = all && !encrypted_only && !unencrypted_only; + + let mut warning_files: Vec = Vec::new(); + + for file in &files { + let has_filter = filter_map + .get(&file.path) + .map(|v| has_git_crypt_filter(v)) + .unwrap_or(false); + + if has_filter { + // Untracked files have no blob, so they never produce a + // WARNING — only the staged/committed blob can be plaintext. + let plain_blob = + file.tracked && !blob_status.get(file.path.as_str()).copied().unwrap_or(true); + + if plain_blob { + warning_files.push(file.path.clone()); + if show_warning_lines { + println!( + " {} {} {}", + "encrypted:".green(), + file.path, + "*** WARNING: staged/committed version is NOT ENCRYPTED! ***" + .red() + .bold(), + ); + } + } else if show_encrypted_lines { + if file.tracked { + println!(" {} {}", "encrypted:".green(), file.path); + } else { + // Untracked filter file: there's no blob yet, so the + // file on disk is still plaintext. The "(untracked)" + // suffix makes it clear that the encrypted state is + // prospective — it'll happen on staging. + println!( + " {} {} {}", + "encrypted:".green(), + file.path, + "(untracked)".dimmed(), + ); + } } + } else if show_non_filter_lines { + println!("not encrypted: {}", file.path); } } - if fix && !files_to_fix.is_empty() { + if !warning_files.is_empty() { + eprintln!(); eprintln!( - "{} {} file(s)...", - "Fixing".cyan().bold(), - files_to_fix.len() + "{} one or more files is marked for encryption via .gitattributes", + "Warning:".yellow().bold(), ); - for file in &files_to_fix { - let status = Command::new("git") - .args(["add", "--", file]) - .status() - .map_err(|e| GitVeilError::Git(format!("failed to stage {}: {}", file, e)))?; - - if !status.success() { - eprintln!("{} failed to stage {}", "warning:".yellow().bold(), file); + eprintln!("but was staged and/or committed before the .gitattributes file"); + eprintln!("was in effect."); + if !fix { + eprintln!( + "Run '{}' to stage an encrypted version.", + "gitveil status -f".bold(), + ); + } + + if fix { + eprintln!(); + eprintln!( + "{} {} file(s)...", + "Fixing".cyan().bold(), + warning_files.len(), + ); + let mut fixed = 0usize; + for file in &warning_files { + // A warning file may have been deleted from the working tree + // (still tracked, plaintext blob, no longer on disk). In that + // case `git add` would stage the *deletion* — the opposite of + // re-encrypting. Skip such files with a clear diagnostic. + if !std::path::Path::new(file).exists() { + eprintln!( + "{} skipping {}: file no longer exists in the working \ + tree (use `git rm` to remove, or restore the file to \ + re-encrypt it)", + "warning:".yellow().bold(), + file, + ); + continue; + } + let st = Command::new("git") + .args(["add", "--"]) + .arg(file) + .status() + .map_err(|e| GitVeilError::Git(format!("failed to stage {}: {}", file, e)))?; + if !st.success() { + eprintln!("{} failed to stage {}", "warning:".yellow().bold(), file); + continue; + } + fixed += 1; } + eprintln!( + "{} re-staged {} of {} file(s). Run '{}' to save the \ + re-encrypted blobs.", + "Done.".green().bold(), + fixed, + warning_files.len(), + "git commit".bold(), + ); } - eprintln!( - "{} Run '{}' to save the re-encrypted files.", - "Done.".green().bold(), - "git commit".bold() - ); } Ok(()) } -/// Batch-check which files have a git-crypt filter attribute. -/// Uses NUL-delimited output (-z) to handle filenames with special characters. -fn get_git_crypt_files(files: &[&str]) -> Result, GitVeilError> { +/// True when the file's filter attribute is a gitveil/git-crypt filter. +/// Recognizes both the default `git-crypt` and named-key `git-crypt-`. +fn has_git_crypt_filter(value: &str) -> bool { + value.starts_with("git-crypt") +} + +/// List tracked + untracked files (excluding gitignored), ordered to match +/// git-crypt: untracked first, then tracked, each alphabetical. +fn list_all_files() -> Result, GitVeilError> { + let untracked = ls_files_nul(&["ls-files", "-z", "--others", "--exclude-standard"])?; + let tracked = ls_files_nul(&["ls-files", "-z"])?; + + let mut result: Vec = untracked + .into_iter() + .map(|path| FileEntry { + path, + tracked: false, + }) + .collect(); + result.extend(tracked.into_iter().map(|path| FileEntry { + path, + tracked: true, + })); + Ok(result) +} + +/// Run a `git ls-files` variant with `-z` and parse its NUL-delimited output. +/// Using NUL is essential for filenames containing whitespace or other +/// special characters. +fn ls_files_nul(args: &[&str]) -> Result, GitVeilError> { + let out = Command::new("git") + .args(args) + .output() + .map_err(|e| GitVeilError::Git(format!("failed to run git {:?}: {}", args, e)))?; + + if !out.status.success() { + return Err(GitVeilError::Git(format!("git {:?} failed", args))); + } + + let mut files = Vec::new(); + for chunk in out.stdout.split(|&b| b == 0) { + if chunk.is_empty() { + continue; + } + files.push(String::from_utf8_lossy(chunk).into_owned()); + } + Ok(files) +} + +/// Batch-resolve the `filter` attribute for every file. Returns a map from +/// path → filter value when the value is not the literal `unspecified`. +fn batch_check_filters(files: &[FileEntry]) -> Result, GitVeilError> { let mut child = Command::new("git") .args(["check-attr", "-z", "filter", "--stdin"]) .stdin(Stdio::piped()) @@ -100,46 +262,41 @@ fn get_git_crypt_files(files: &[&str]) -> Result, GitVeilError> { .take() .ok_or_else(|| GitVeilError::Git("failed to open check-attr stdin".into()))?; - // Write paths on a separate thread to avoid pipe deadlock when the - // number of files is large enough to overflow the OS pipe buffer. - let paths: Vec = files.iter().map(|f| f.to_string()).collect(); - let writer_thread = thread::spawn(move || { + // Writer thread prevents pipe deadlock when the number of files exceeds + // the OS pipe buffer (~64 KB on Linux, smaller on Windows). + let paths: Vec = files.iter().map(|f| f.path.clone()).collect(); + let writer = thread::spawn(move || { let mut stdin = stdin; - for file in &paths { - if write!(stdin, "{}\0", file).is_err() { + for path in &paths { + if write!(stdin, "{}\0", path).is_err() { break; } } }); - let output = child + let out = child .wait_with_output() .map_err(|e| GitVeilError::Git(format!("failed to wait for git check-attr: {}", e)))?; - let _ = writer_thread.join(); + let _ = writer.join(); - if !output.status.success() { + if !out.status.success() { return Err(GitVeilError::Git("git check-attr -z --stdin failed".into())); } - // NUL-delimited output format: path\0attr\0value\0 (repeating triplets) - let fields: Vec<&[u8]> = output.stdout.split(|&b| b == 0).collect(); - let mut result = Vec::new(); - - // Process in triplets: (path, attribute_name, value) + // NUL-delimited triplets: path\0attr\0value\0 + let fields: Vec<&[u8]> = out.stdout.split(|&b| b == 0).collect(); + let mut map = HashMap::new(); let mut i = 0; while i + 2 < fields.len() { - let path = String::from_utf8_lossy(fields[i]); - let value = String::from_utf8_lossy(fields[i + 2]); - - if value.starts_with("git-crypt") { - result.push(path.to_string()); + let path = String::from_utf8_lossy(fields[i]).into_owned(); + let value = String::from_utf8_lossy(fields[i + 2]).into_owned(); + if value != "unspecified" && value != "unset" && !value.is_empty() { + map.insert(path, value); } - i += 3; } - - Ok(result) + Ok(map) } /// Batch-check whether blobs in the index are encrypted using a single @@ -171,9 +328,8 @@ fn batch_check_blobs_encrypted(files: &[String]) -> Result, GitVeilErr .take() .ok_or_else(|| GitVeilError::Git("failed to open cat-file stdout".into()))?; - // Write queries on a separate thread to avoid pipe deadlock. - // If we wrote all queries before reading, the stdout pipe could fill up - // (e.g. large blobs), blocking cat-file, which in turn blocks our writes. + // Writer thread prevents pipe deadlock: with large blobs, reading must + // proceed concurrently with writing. let queries: Vec = files.iter().map(|f| format!(":{}", f)).collect(); let writer_thread = thread::spawn(move || { let mut stdin = stdin; @@ -182,14 +338,12 @@ fn batch_check_blobs_encrypted(files: &[String]) -> Result, GitVeilErr break; } } - // stdin is dropped here, signaling EOF to cat-file }); let mut reader = BufReader::new(stdout); let mut results = Vec::with_capacity(files.len()); for _ in files { - // Read the response header line: " blob \n" or ": missing\n" let mut header_line = String::new(); reader .read_line(&mut header_line) @@ -198,12 +352,10 @@ fn batch_check_blobs_encrypted(files: &[String]) -> Result, GitVeilErr let header_line = header_line.trim_end_matches('\n'); if header_line.ends_with(" missing") { - // File not in index results.push(false); continue; } - // Parse " blob " let size: usize = header_line .rsplit_once(' ') .and_then(|(_, s)| s.parse().ok()) @@ -212,14 +364,11 @@ fn batch_check_blobs_encrypted(files: &[String]) -> Result, GitVeilErr })?; if size < ENCRYPTED_FILE_HEADER_LEN { - // Too small to contain the header — not encrypted - // Drain the content + trailing newline drain_bytes(&mut reader, size + 1)?; results.push(false); continue; } - // Read just the header bytes we need let mut header_buf = [0u8; ENCRYPTED_FILE_HEADER_LEN]; reader .read_exact(&mut header_buf) @@ -227,26 +376,35 @@ fn batch_check_blobs_encrypted(files: &[String]) -> Result, GitVeilErr let is_encrypted = header_buf == ENCRYPTED_FILE_HEADER; - // Drain the remaining blob bytes + trailing newline let remaining = size - ENCRYPTED_FILE_HEADER_LEN + 1; drain_bytes(&mut reader, remaining)?; results.push(is_encrypted); } - // Wait for the writer thread to finish let _ = writer_thread.join(); - - // Wait for cat-file to exit let _ = child.wait(); Ok(results) } -/// Drain `count` bytes from a reader by copying to sink. -/// Avoids allocating a buffer for large blobs. fn drain_bytes(reader: &mut impl Read, count: usize) -> Result<(), GitVeilError> { std::io::copy(&mut reader.take(count as u64), &mut std::io::sink()) .map_err(|e| GitVeilError::Git(format!("failed to drain cat-file output: {}", e)))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn has_git_crypt_filter_recognizes_default_and_named() { + assert!(has_git_crypt_filter("git-crypt")); + assert!(has_git_crypt_filter("git-crypt-backend")); + assert!(has_git_crypt_filter("git-crypt-team-xyz")); + assert!(!has_git_crypt_filter("unspecified")); + assert!(!has_git_crypt_filter("lfs")); + assert!(!has_git_crypt_filter("")); + } +} diff --git a/src/main.rs b/src/main.rs index e9d941b..1044051 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,8 +58,9 @@ fn main() { Commands::Status { encrypted_only, unencrypted_only, + all, fix, - } => commands::status::status(encrypted_only, unencrypted_only, fix), + } => commands::status::status(encrypted_only, unencrypted_only, all, fix), Commands::RmGpgUser { key_name, diff --git a/tests/integration.rs b/tests/integration.rs index e5a1fcf..940a7ee 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -246,11 +246,23 @@ fn test_status_shows_encrypted_files() { assert_success(&out, "status"); let stdout = String::from_utf8_lossy(&out.stdout); - assert!(stdout.contains("a.secret"), "status should list a.secret"); - assert!(stdout.contains("b.secret"), "status should list b.secret"); + // Default output is focused on filter-marked files. + assert!( + stdout.contains("encrypted:") && stdout.contains("a.secret"), + "should list a.secret as encrypted, got: {}", + stdout + ); + assert!(stdout.contains("b.secret"), "should list b.secret"); + // Non-filter files are NOT shown by default (use --all for that). assert!( !stdout.contains("public.txt"), - "status should NOT list public.txt" + "default output should not list non-filter public.txt, got: {}", + stdout, + ); + assert!( + !stdout.contains("README"), + "default output should not list non-filter README, got: {}", + stdout, ); } @@ -571,7 +583,7 @@ fn test_status_many_files_no_deadlock() { assert_success(&out, "status with many files"); let stdout = String::from_utf8_lossy(&out.stdout); - // All 200 secret files should appear + // All 200 secret files should appear under "encrypted:" assert!( stdout.contains("secret-0000.txt"), "should list first encrypted file" @@ -580,10 +592,10 @@ fn test_status_many_files_no_deadlock() { stdout.contains("secret-0199.txt"), "should list last encrypted file" ); - // Plain files should not appear (they don't have the filter) + // Plain files (no filter) are excluded from the default focused output. assert!( !stdout.contains("plain-0000.txt"), - "should not list plain files" + "default output should not list non-filter plain files" ); } @@ -741,6 +753,544 @@ fn test_lock_many_files_no_deadlock() { ); } +// ─── Status: tracked / untracked / warning coverage ──────────── + +#[test] +fn test_status_shows_untracked_filter_matched_file() { + // gitveil status used to ignore untracked files entirely; git-crypt + // shows them so the user sees what *will* be encrypted on staging. + // We annotate the entry with "(untracked)" so the user can tell the + // file isn't actually encrypted yet — it just *will be* on staging. + let dir = make_test_repo(); + assert_success(&gitveil(dir.path(), &["init"]), "init"); + fs::write( + dir.path().join(".gitattributes"), + "*.secret filter=git-crypt diff=git-crypt\n", + ) + .unwrap(); + fs::write(dir.path().join("new.secret"), "fresh untracked secret\n").unwrap(); + + let out = gitveil(dir.path(), &["status"]); + assert_success(&out, "status"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("encrypted:") && stdout.contains("new.secret"), + "untracked filter-matched file should appear as encrypted, got: {}", + stdout, + ); + assert!( + stdout.contains("(untracked)"), + "untracked filter file must be marked '(untracked)' to distinguish \ + it from a file whose committed blob is already encrypted, got: {}", + stdout, + ); + // Untracked: no blob to verify, so no WARNING. + assert!( + !stdout.contains("WARNING"), + "untracked file should not produce a WARNING, got: {}", + stdout, + ); +} + +#[test] +fn test_status_tracked_filter_file_has_no_untracked_marker() { + // Negative pairing for the (untracked) marker: a tracked filter file + // whose blob is encrypted must not be tagged "(untracked)". + let dir = make_test_repo(); + assert_success(&gitveil(dir.path(), &["init"]), "init"); + fs::write( + dir.path().join(".gitattributes"), + "*.secret filter=git-crypt diff=git-crypt\n", + ) + .unwrap(); + fs::write(dir.path().join("done.secret"), "fully encrypted\n").unwrap(); + assert_success(&git(dir.path(), &["add", "."]), "git add"); + assert_success(&git(dir.path(), &["commit", "-m", "add"]), "commit"); + + let out = gitveil(dir.path(), &["status"]); + assert_success(&out, "status"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("done.secret"), + "tracked file should appear, got: {}", + stdout, + ); + assert!( + !stdout.contains("(untracked)"), + "tracked filter+encrypted file must not be tagged (untracked), \ + got: {}", + stdout, + ); +} + +#[test] +fn test_status_default_hides_non_filter_files() { + // Default output is focused on filter-matched files. README (tracked, + // no filter) and plain.txt (untracked, no filter) should NOT appear. + let dir = make_test_repo(); + assert_success(&gitveil(dir.path(), &["init"]), "init"); + fs::write(dir.path().join("plain.txt"), "public\n").unwrap(); + + let out = gitveil(dir.path(), &["status"]); + assert_success(&out, "status"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + !stdout.contains("plain.txt"), + "default should hide non-filter plain.txt, got: {}", + stdout, + ); + assert!( + !stdout.contains("README"), + "default should hide non-filter README, got: {}", + stdout, + ); +} + +#[test] +fn test_status_a_flag_lists_non_filter_files() { + // -a / --all surfaces files without the git-crypt filter, matching the + // git-crypt-style verbose listing. + let dir = make_test_repo(); + assert_success(&gitveil(dir.path(), &["init"]), "init"); + fs::write(dir.path().join("plain.txt"), "public\n").unwrap(); + + let out = gitveil(dir.path(), &["status", "-a"]); + assert_success(&out, "status -a"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("not encrypted:") && stdout.contains("plain.txt"), + "-a should list untracked non-filter plain.txt, got: {}", + stdout, + ); + assert!( + stdout.contains("README"), + "-a should list tracked non-filter README, got: {}", + stdout, + ); +} + +#[test] +fn test_status_a_long_flag_alias() { + // --all is the long alias for -a. + let dir = make_test_repo(); + assert_success(&gitveil(dir.path(), &["init"]), "init"); + fs::write(dir.path().join("plain.txt"), "public\n").unwrap(); + + let out = gitveil(dir.path(), &["status", "--all"]); + assert_success(&out, "status --all"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("plain.txt"), + "--all should behave like -a, got: {}", + stdout, + ); +} + +#[test] +fn test_status_warning_for_filter_with_plain_blob() { + // File committed BEFORE the filter was set → its blob is plaintext + // even though the filter now applies → must emit WARNING. + let dir = make_test_repo(); + assert_success(&gitveil(dir.path(), &["init"]), "init"); + + fs::write(dir.path().join("secret.dat"), "plaintext-content\n").unwrap(); + assert_success(&git(dir.path(), &["add", "secret.dat"]), "add pre-filter"); + assert_success( + &git(dir.path(), &["commit", "-m", "commit pre-filter"]), + "commit", + ); + + fs::write( + dir.path().join(".gitattributes"), + "secret.dat filter=git-crypt diff=git-crypt\n", + ) + .unwrap(); + assert_success( + &git(dir.path(), &["add", ".gitattributes"]), + "add .gitattributes", + ); + assert_success( + &git(dir.path(), &["commit", "-m", "add filter"]), + "commit filter", + ); + + let out = gitveil(dir.path(), &["status"]); + assert_success(&out, "status"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stdout.contains("secret.dat") && stdout.contains("WARNING"), + "filter-marked file with plaintext blob must show WARNING, got: {}", + stdout, + ); + assert!( + stderr.contains("Warning") || stderr.contains("warning"), + "should print summary about running -f, got stderr: {}", + stderr, + ); +} + +#[test] +fn test_status_no_warning_when_all_correctly_encrypted() { + let dir = make_test_repo(); + assert_success(&gitveil(dir.path(), &["init"]), "init"); + fs::write( + dir.path().join(".gitattributes"), + "*.secret filter=git-crypt diff=git-crypt\n", + ) + .unwrap(); + fs::write(dir.path().join("a.secret"), "secret-a\n").unwrap(); + assert_success(&git(dir.path(), &["add", "."]), "git add"); + assert_success(&git(dir.path(), &["commit", "-m", "add"]), "commit"); + + let out = gitveil(dir.path(), &["status"]); + assert_success(&out, "status"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stdout.contains("WARNING"), + "no WARNING expected, got: {}", + stdout, + ); + assert!( + !stderr.to_lowercase().contains("warning:"), + "no Warning summary expected, got: {}", + stderr, + ); +} + +#[test] +fn test_status_e_flag_only_encrypted_blobs() { + // -e: only files whose blob is encrypted (i.e., everything is fine). + // WARNING files (filter + plaintext blob) are excluded. + let dir = make_test_repo(); + assert_success(&gitveil(dir.path(), &["init"]), "init"); + + // bad.secret: committed plaintext before filter — its blob is plain. + fs::write(dir.path().join("bad.secret"), "plain\n").unwrap(); + assert_success(&git(dir.path(), &["add", "bad.secret"]), "add bad"); + assert_success(&git(dir.path(), &["commit", "-m", "pre"]), "commit"); + + fs::write( + dir.path().join(".gitattributes"), + "*.secret filter=git-crypt diff=git-crypt\n", + ) + .unwrap(); + fs::write(dir.path().join("good.secret"), "encrypt-me\n").unwrap(); + fs::write(dir.path().join("public.txt"), "public\n").unwrap(); + // Use explicit paths: `git add .` would re-stage bad.secret through + // the now-active clean filter, accidentally erasing the WARNING state. + assert_success( + &git( + dir.path(), + &["add", ".gitattributes", "good.secret", "public.txt"], + ), + "git add new files only", + ); + assert_success(&git(dir.path(), &["commit", "-m", "add"]), "commit"); + + let out = gitveil(dir.path(), &["status", "-e"]); + assert_success(&out, "status -e"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("good.secret"), + "-e should list filter+encrypted good.secret, got: {}", + stdout, + ); + assert!( + !stdout.contains("bad.secret"), + "-e should NOT list filter+plaintext bad.secret (the WARNING set), got: {}", + stdout, + ); + assert!( + !stdout.contains("public.txt") && !stdout.contains("README"), + "-e should NOT list non-filter files, got: {}", + stdout, + ); +} + +#[test] +fn test_status_u_flag_only_warning_files() { + // -u: only files marked for encryption whose blob is plaintext (the + // set that needs re-encryption — pair with -f). Restores the + // pre-PR meaning of -u. + let dir = make_test_repo(); + assert_success(&gitveil(dir.path(), &["init"]), "init"); + + fs::write(dir.path().join("bad.secret"), "plain\n").unwrap(); + assert_success(&git(dir.path(), &["add", "bad.secret"]), "add bad"); + assert_success(&git(dir.path(), &["commit", "-m", "pre"]), "commit"); + + fs::write( + dir.path().join(".gitattributes"), + "*.secret filter=git-crypt diff=git-crypt\n", + ) + .unwrap(); + fs::write(dir.path().join("good.secret"), "encrypt-me\n").unwrap(); + fs::write(dir.path().join("public.txt"), "public\n").unwrap(); + // Use explicit paths: `git add .` would re-stage bad.secret through + // the now-active clean filter, accidentally erasing the WARNING state. + assert_success( + &git( + dir.path(), + &["add", ".gitattributes", "good.secret", "public.txt"], + ), + "git add new files only", + ); + assert_success(&git(dir.path(), &["commit", "-m", "add"]), "commit"); + + let out = gitveil(dir.path(), &["status", "-u"]); + assert_success(&out, "status -u"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("bad.secret") && stdout.contains("WARNING"), + "-u should list bad.secret as a WARNING (filter+plain), got: {}", + stdout, + ); + assert!( + !stdout.contains("good.secret"), + "-u should NOT list good.secret (filter+encrypted), got: {}", + stdout, + ); + assert!( + !stdout.contains("public.txt") && !stdout.contains("README"), + "-u should NOT list non-filter files, got: {}", + stdout, + ); +} + +#[test] +fn test_status_fix_restages_only_warning_files() { + // -f should re-stage tracked files with filter but plaintext blob, + // and must NOT auto-add untracked files (privacy / user intent). + let dir = make_test_repo(); + assert_success(&gitveil(dir.path(), &["init"]), "init"); + + fs::write(dir.path().join("bad.secret"), "should-be-encrypted\n").unwrap(); + assert_success(&git(dir.path(), &["add", "bad.secret"]), "add pre-filter"); + assert_success(&git(dir.path(), &["commit", "-m", "pre-filter"]), "commit"); + + fs::write( + dir.path().join(".gitattributes"), + "*.secret filter=git-crypt diff=git-crypt\n", + ) + .unwrap(); + assert_success(&git(dir.path(), &["add", ".gitattributes"]), "add attrs"); + assert_success(&git(dir.path(), &["commit", "-m", "attrs"]), "commit attrs"); + + // Untracked filter-matched file + fs::write(dir.path().join("new.secret"), "fresh\n").unwrap(); + + let out = gitveil(dir.path(), &["status", "-f"]); + assert_success(&out, "status -f"); + + let staged = git(dir.path(), &["diff", "--cached", "--name-only"]); + let staged_out = String::from_utf8_lossy(&staged.stdout); + // Diagnostic-heavy message — this test has flaked once during local runs + // with `staged_out` empty; capturing full state makes any future flake + // immediately debuggable. + assert!( + staged_out.contains("bad.secret"), + "bad.secret (tracked + filter + plain blob) should be re-staged by -f.\n\ + gitveil status -f stdout:\n{}\n\ + gitveil status -f stderr:\n{}\n\ + git diff --cached --name-only stdout: {:?}\n\ + git diff --cached --name-only stderr: {:?}\n\ + git status --porcelain:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + staged_out, + String::from_utf8_lossy(&staged.stderr), + String::from_utf8_lossy(&git(dir.path(), &["status", "--porcelain"]).stdout), + ); + assert!( + !staged_out.contains("new.secret"), + "untracked new.secret should NOT be auto-staged by -f, got: {}", + staged_out, + ); + + // Re-staged blob should now be encrypted via the clean filter. + let blob = git(dir.path(), &["show", ":bad.secret"]); + assert!( + blob.stdout.starts_with(b"\0GITCRYPT\0"), + "re-staged bad.secret blob should be encrypted now", + ); +} + +#[test] +fn test_status_fix_skips_file_deleted_from_working_tree() { + // A tracked filter-marked file with a plaintext blob whose working-copy + // has been deleted: -f must NOT stage that, because `git add ` + // stages the *deletion* (removing the file from the index). The intent + // of -f is to re-encrypt, not to silently remove tracked files. + let dir = make_test_repo(); + assert_success(&gitveil(dir.path(), &["init"]), "init"); + + fs::write(dir.path().join("bad.secret"), "plaintext\n").unwrap(); + assert_success(&git(dir.path(), &["add", "bad.secret"]), "add bad"); + assert_success(&git(dir.path(), &["commit", "-m", "pre"]), "commit bad"); + + fs::write( + dir.path().join(".gitattributes"), + "*.secret filter=git-crypt diff=git-crypt\n", + ) + .unwrap(); + assert_success(&git(dir.path(), &["add", ".gitattributes"]), "add attrs"); + assert_success(&git(dir.path(), &["commit", "-m", "attrs"]), "commit attrs"); + + // Delete from working tree (still tracked in index). + fs::remove_file(dir.path().join("bad.secret")).unwrap(); + + let out = gitveil(dir.path(), &["status", "-f"]); + assert_success(&out, "status -f on deleted file"); + + let staged = git(dir.path(), &["diff", "--cached", "--name-status"]); + let staged_out = String::from_utf8_lossy(&staged.stdout); + assert!( + !staged_out.contains("bad.secret"), + "-f must NOT stage anything for a file deleted from the working \ + tree (would stage the deletion). Got: {}", + staged_out, + ); + let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase(); + assert!( + stderr.contains("skip") || stderr.contains("deleted") || stderr.contains("no longer"), + "should emit a diagnostic explaining the skip, got stderr: {}", + String::from_utf8_lossy(&out.stderr), + ); +} + +#[test] +fn test_status_excludes_gitignored_files() { + // --exclude-standard makes git ls-files --others skip files matched by + // .gitignore. Regression guard via -a (which would include them + // otherwise — they are non-filter files). + let dir = make_test_repo(); + assert_success(&gitveil(dir.path(), &["init"]), "init"); + + fs::write(dir.path().join(".gitignore"), "ignored.txt\n*.tmp\n").unwrap(); + fs::write(dir.path().join("ignored.txt"), "skip me\n").unwrap(); + fs::write(dir.path().join("draft.tmp"), "skip me\n").unwrap(); + fs::write(dir.path().join("public.txt"), "include me\n").unwrap(); + + let out = gitveil(dir.path(), &["status", "-a"]); + assert_success(&out, "status -a"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("public.txt"), + "non-ignored file should appear with -a, got: {}", + stdout, + ); + assert!( + !stdout.contains("ignored.txt"), + "gitignored file should not appear even with -a, got: {}", + stdout, + ); + assert!( + !stdout.contains("draft.tmp"), + "gitignored pattern file should not appear even with -a, got: {}", + stdout, + ); +} + +#[test] +fn test_status_not_a_git_repo_clear_error() { + let dir = tempfile::tempdir().unwrap(); + let out = gitveil(dir.path(), &["status"]); + assert!( + !out.status.success(), + "status outside any git repo should fail" + ); + let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase(); + assert!( + stderr.contains("not a git repository") || stderr.contains("not in a git"), + "should give clean 'not a git repository' error, got: {}", + stderr, + ); +} + +#[test] +fn test_status_works_without_gitveil_init() { + // Status is informational: it lets users inspect filter coverage even + // before running `gitveil init`. Without init, filter-marked files + // were committed without the clean filter, so their blobs are + // plaintext → WARNING fires, surfacing the misconfiguration. + let dir = make_test_repo(); + fs::write( + dir.path().join(".gitattributes"), + "*.secret filter=git-crypt diff=git-crypt\n", + ) + .unwrap(); + fs::write(dir.path().join("file.secret"), "x\n").unwrap(); + assert_success(&git(dir.path(), &["add", "."]), "git add"); + assert_success(&git(dir.path(), &["commit", "-m", "add"]), "commit"); + + let out = gitveil(dir.path(), &["status"]); + assert_success(&out, "status before gitveil init must still work"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stdout.contains("file.secret") && stdout.contains("WARNING"), + "without init, filter-marked file should appear with WARNING, \ + got stdout: {}", + stdout, + ); + assert!( + stderr.to_lowercase().contains("warning"), + "should print summary, got stderr: {}", + stderr, + ); +} + +#[test] +fn test_status_handles_filename_with_spaces() { + // Regression guard: NUL-delimited parsing means filenames with + // whitespace are preserved as a single entry. Use a filter-matched + // file so it appears in the default focused output. + let dir = make_test_repo(); + assert_success(&gitveil(dir.path(), &["init"]), "init"); + fs::write( + dir.path().join(".gitattributes"), + "*.secret filter=git-crypt diff=git-crypt\n", + ) + .unwrap(); + fs::write(dir.path().join("file with space.secret"), "x\n").unwrap(); + + let out = gitveil(dir.path(), &["status"]); + assert_success(&out, "status"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("file with space.secret"), + "should list the file with spaces in one piece, got: {}", + stdout, + ); +} + +#[test] +fn test_status_named_key_filter() { + // Named keys use filter=git-crypt-. Status must recognize + // these too (not just the default `git-crypt`). + let dir = make_test_repo(); + assert_success(&gitveil(dir.path(), &["init", "-k", "backend"]), "init -k"); + fs::write( + dir.path().join(".gitattributes"), + "*.bsec filter=git-crypt-backend diff=git-crypt-backend\n", + ) + .unwrap(); + fs::write(dir.path().join("api.bsec"), "key\n").unwrap(); + assert_success(&git(dir.path(), &["add", "."]), "git add"); + assert_success(&git(dir.path(), &["commit", "-m", "add"]), "commit"); + + let out = gitveil(dir.path(), &["status"]); + assert_success(&out, "status"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("encrypted:") && stdout.contains("api.bsec"), + "named-key filter should be recognized, got: {}", + stdout, + ); +} + // ─── Config Tests ────────────────────────────────────────────── /// Run gitveil with a custom XDG_CONFIG_HOME for isolated config testing.