diff --git a/Cargo.lock b/Cargo.lock index 2efd9a7cbda..39c40cea82b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1962,6 +1962,7 @@ dependencies = [ "gix-utils", "gix-validate", "hashbrown 0.17.0", + "insta", "itoa", "libc", "memmap2", @@ -3042,6 +3043,7 @@ checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" dependencies = [ "console", "once_cell", + "regex", "similar", "tempfile", ] diff --git a/gix-index/Cargo.toml b/gix-index/Cargo.toml index 61e174af6fa..068cfb27999 100644 --- a/gix-index/Cargo.toml +++ b/gix-index/Cargo.toml @@ -76,6 +76,7 @@ gix-hashtable = { path = "../gix-hashtable" } gix-odb = { path = "../gix-odb" } gix-object = { path = "../gix-object" } filetime = "0.2.27" +insta = { version = "1.46.3", features = ["filters"] } [package.metadata.docs.rs] features = ["sha1", "document-features", "serde"] diff --git a/gix-index/src/decode/mod.rs b/gix-index/src/decode/mod.rs index c26160a20b8..2d24dc9524f 100644 --- a/gix-index/src/decode/mod.rs +++ b/gix-index/src/decode/mod.rs @@ -336,11 +336,11 @@ pub(crate) fn stat(data: &[u8]) -> Option<(entry::Stat, &[u8])> { let (size, data) = read_u32(data)?; Some(( entry::Stat { - mtime: entry::stat::Time { + ctime: entry::stat::Time { secs: ctime_secs, nsecs: ctime_nsecs, }, - ctime: entry::stat::Time { + mtime: entry::stat::Time { secs: mtime_secs, nsecs: mtime_nsecs, }, diff --git a/gix-index/src/extension/mod.rs b/gix-index/src/extension/mod.rs index 764274e7d27..1cf9d4c98a7 100644 --- a/gix-index/src/extension/mod.rs +++ b/gix-index/src/extension/mod.rs @@ -45,7 +45,7 @@ pub struct Link { /// The extension for untracked files. #[allow(dead_code)] -#[derive(Clone)] +#[derive(Debug, Clone)] pub struct UntrackedCache { /// Something identifying the location and machine that this cache is for. /// Should the repository be copied to a different machine, the entire cache can immediately be invalidated. diff --git a/gix-index/src/extension/untracked_cache.rs b/gix-index/src/extension/untracked_cache.rs index 27747e61716..d72e2e55caa 100644 --- a/gix-index/src/extension/untracked_cache.rs +++ b/gix-index/src/extension/untracked_cache.rs @@ -7,8 +7,40 @@ use crate::{ util::{read_u32, split_at_byte_exclusive, var_int}, }; +impl UntrackedCache { + /// Something identifying the location and machine that this cache is for. + pub fn identifier(&self) -> &bstr::BStr { + self.identifier.as_ref() + } + + /// Stat and object id for the `.git/info/exclude` file, if available. + pub fn info_exclude(&self) -> Option<&OidStat> { + self.info_exclude.as_ref() + } + + /// Stat and object id for the `core.excludesfile`, if available. + pub fn excludes_file(&self) -> Option<&OidStat> { + self.excludes_file.as_ref() + } + + /// Usually `.gitignore`. + pub fn exclude_filename_per_dir(&self) -> &bstr::BStr { + self.exclude_filename_per_dir.as_ref() + } + + /// The directory flags Git used while populating the cache. + pub fn dir_flags(&self) -> u32 { + self.dir_flags + } + + /// A list of directories and sub-directories, with `directories[0]` being the root. + pub fn directories(&self) -> &[Directory] { + &self.directories + } +} + /// A structure to track filesystem stat information along with an object id, linking a worktree file with what's in our ODB. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct OidStat { /// The file system stat information pub stat: entry::Stat, @@ -16,8 +48,20 @@ pub struct OidStat { pub id: ObjectId, } +impl OidStat { + /// The file system stat information. + pub fn stat(&self) -> &entry::Stat { + &self.stat + } + + /// The id of the file in our ODB. + pub fn id(&self) -> ObjectId { + self.id + } +} + /// A directory with information about its untracked files, and its sub-directories -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct Directory { /// The directories name, or an empty string if this is the root directory. pub name: BString, @@ -34,10 +78,42 @@ pub struct Directory { pub check_only: bool, } +impl Directory { + /// The directory name, or an empty string if this is the root directory. + /// `/` is always used as path-separator. + pub fn name(&self) -> &bstr::BStr { + self.name.as_ref() + } + + /// Untracked files and directory names. + pub fn untracked_entries(&self) -> &[BString] { + &self.untracked_entries + } + + /// Indices for sub-directories similar to this one. + pub fn sub_directories(&self) -> &[usize] { + &self.sub_directories + } + + /// The directory stat data, if available and valid. + pub fn stat(&self) -> Option<&entry::Stat> { + self.stat.as_ref() + } + + /// The oid of a `.gitignore` file, if it exists. + pub fn exclude_file_oid(&self) -> Option { + self.exclude_file_oid + } + + /// Whether Git marked this directory as check-only. + pub fn check_only(&self) -> bool { + self.check_only + } +} + /// Only used as an indicator pub const SIGNATURE: Signature = *b"UNTR"; -// #[allow(unused)] /// Decode an untracked cache extension from `data`, assuming object hashes are of type `object_hash`. pub fn decode(data: &[u8], object_hash: gix_hash::Kind, alloc_limit_bytes: Option) -> Option { if data.last().is_none_or(|b| *b != 0) { @@ -46,10 +122,29 @@ pub fn decode(data: &[u8], object_hash: gix_hash::Kind, alloc_limit_bytes: Optio let (identifier_len, data) = var_int(data)?; let (identifier, data) = data.split_at_checked(identifier_len.try_into().ok()?)?; + // The on-disk layout matches git's `ondisk_untracked_cache` struct + // https://github.com/git/git/blob/2855562ca6a9c6b0e7bc780b050c1e83c9fcfbd0/dir.c#L3582-L3586 + // https://github.com/git/git/blob/2855562ca6a9c6b0e7bc780b050c1e83c9fcfbd0/dir.c#L3668-L3722 + // info_exclude_stat (36 bytes) + // excludes_file_stat (36 bytes) + // dir_flags ( 4 bytes) + // info_exclude hash (hash_len bytes) + // excludes_file hash (hash_len bytes) + // exclude_per_dir (NUL-terminated) let hash_len = object_hash.len_in_bytes(); - let (info_exclude, data) = decode_oid_stat(data, hash_len)?; - let (excludes_file, data) = decode_oid_stat(data, hash_len)?; + let (info_exclude_stat, data) = crate::decode::stat(data)?; + let (excludes_file_stat, data) = crate::decode::stat(data)?; let (dir_flags, data) = read_u32(data)?; + let (info_exclude_hash, data) = data.split_at_checked(hash_len)?; + let (excludes_file_hash, data) = data.split_at_checked(hash_len)?; + let info_exclude = OidStat { + stat: info_exclude_stat, + id: ObjectId::from_bytes_or_panic(info_exclude_hash), + }; + let excludes_file = OidStat { + stat: excludes_file_stat, + id: ObjectId::from_bytes_or_panic(excludes_file_hash), + }; let (exclude_filename_per_dir, data) = split_at_byte_exclusive(data, 0)?; let (num_directory_blocks, data) = var_int(data)?; @@ -174,15 +269,3 @@ fn decode_directory_block<'a>( data.into() } - -fn decode_oid_stat(data: &[u8], hash_len: usize) -> Option<(OidStat, &[u8])> { - let (stat, data) = crate::decode::stat(data)?; - let (hash, data) = data.split_at_checked(hash_len)?; - Some(( - OidStat { - stat, - id: ObjectId::from_bytes_or_panic(hash), - }, - data, - )) -} diff --git a/gix-index/src/file/mod.rs b/gix-index/src/file/mod.rs index 40332abbd0e..893c18bf8e0 100644 --- a/gix-index/src/file/mod.rs +++ b/gix-index/src/file/mod.rs @@ -21,10 +21,36 @@ mod impls { mod impl_ { use std::fmt::Formatter; - use crate::{File, State}; + use crate::{Entry, File, PathStorageRef, State}; impl std::fmt::Debug for File { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if f.alternate() { + return f + .debug_struct("File") + .field("path", &self.path.display()) + .field("checksum", &self.checksum) + .field("object_hash", &self.state.object_hash) + .field("timestamp", &self.state.timestamp) + .field("version", &self.state.version) + .field( + "entries", + &EntriesDebug { + entries: &self.state.entries, + path_backing: &self.state.path_backing, + }, + ) + .field("path_backing_size_bytes", &self.state.path_backing.len()) + .field("is_sparse", &self.state.is_sparse) + .field("end_of_index_at_decode_time", &self.state.end_of_index_at_decode_time) + .field("offset_table_at_decode_time", &self.state.offset_table_at_decode_time) + .field("tree", &self.state.tree) + .field("has_link", &self.state.link.is_some()) + .field("has_resolve_undo", &self.state.resolve_undo.is_some()) + .field("untracked", &self.state.untracked) + .field("has_fs_monitor", &self.state.fs_monitor.is_some()) + .finish(); + } f.debug_struct("File") .field("path", &self.path.display()) .field("checksum", &self.checksum) @@ -32,6 +58,27 @@ mod impl_ { } } + struct EntriesDebug<'a> { + entries: &'a [Entry], + path_backing: &'a PathStorageRef, + } + + impl std::fmt::Debug for EntriesDebug<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if !f.alternate() { + return f.debug_list().entries(self.entries).finish(); + } + + writeln!(f, "[")?; + for entry in self.entries { + write!(f, " ")?; + entry.fmt_debug(f, Some(self.path_backing))?; + writeln!(f, ",")?; + } + write!(f, "]") + } + } + impl From for State { fn from(f: File) -> Self { f.state diff --git a/gix-index/src/lib.rs b/gix-index/src/lib.rs index f0432775f5c..d29ddccca30 100644 --- a/gix-index/src/lib.rs +++ b/gix-index/src/lib.rs @@ -53,7 +53,7 @@ pub enum Version { } /// An entry in the index, identifying a non-tree item on disk. -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq)] pub struct Entry { /// The filesystem stat information for the file on disk. pub stat: entry::Stat, @@ -165,7 +165,54 @@ pub struct State { mod impls { use std::fmt::{Debug, Formatter}; - use crate::{State, entry::Stage}; + use crate::{Entry, PathStorageRef, State, entry::Stage}; + + impl Entry { + pub(crate) fn fmt_debug(&self, f: &mut Formatter, path_backing: Option<&PathStorageRef>) -> std::fmt::Result { + if f.alternate() { + write!( + f, + "{} {}{:?} mtime: {:?} {} ", + match self.flags.stage() { + Stage::Unconflicted => " ", + Stage::Base => "BASE ", + Stage::Ours => "OURS ", + Stage::Theirs => "THEIRS ", + }, + if self.flags.is_empty() { + "".to_string() + } else { + format!("{:?} ", self.flags) + }, + self.mode, + self.stat.mtime, + self.id, + )?; + return match path_backing { + Some(path_backing) => write!(f, "{}", self.path_in(path_backing)), + None => write!(f, "{:?}", self.path), + }; + } + + let mut entry = f.debug_struct("Entry"); + entry + .field("stat", &self.stat) + .field("id", &self.id) + .field("flags", &self.flags) + .field("mode", &self.mode); + match path_backing { + Some(path_backing) => entry.field("path", &self.path_in(path_backing)), + None => entry.field("path", &self.path), + } + .finish() + } + } + + impl Debug for Entry { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.fmt_debug(f, None) + } + } impl Debug for State { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { diff --git a/gix-index/tests/fixtures/generated-archives/untracked_cache_empty.tar b/gix-index/tests/fixtures/generated-archives/untracked_cache_empty.tar new file mode 100644 index 00000000000..9d30b350794 Binary files /dev/null and b/gix-index/tests/fixtures/generated-archives/untracked_cache_empty.tar differ diff --git a/gix-index/tests/fixtures/generated-archives/untracked_cache_nested.tar b/gix-index/tests/fixtures/generated-archives/untracked_cache_nested.tar new file mode 100644 index 00000000000..5d971ba7f06 Binary files /dev/null and b/gix-index/tests/fixtures/generated-archives/untracked_cache_nested.tar differ diff --git a/gix-index/tests/fixtures/generated-archives/untracked_cache_populated.tar b/gix-index/tests/fixtures/generated-archives/untracked_cache_populated.tar new file mode 100644 index 00000000000..4612434cb3e Binary files /dev/null and b/gix-index/tests/fixtures/generated-archives/untracked_cache_populated.tar differ diff --git a/gix-index/tests/fixtures/make_index/shared.sh b/gix-index/tests/fixtures/make_index/shared.sh new file mode 100755 index 00000000000..bf3d1035607 --- /dev/null +++ b/gix-index/tests/fixtures/make_index/shared.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +UNTRACKED_CACHE_MTIME="2038-01-19 03:14:07.123456789Z" + +seed_untracked_cache_times() { + touch -d "$UNTRACKED_CACHE_MTIME" "$@" +} diff --git a/gix-index/tests/fixtures/make_index/untracked_cache_empty.sh b/gix-index/tests/fixtures/make_index/untracked_cache_empty.sh new file mode 100755 index 00000000000..ba9aa208a9b --- /dev/null +++ b/gix-index/tests/fixtures/make_index/untracked_cache_empty.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/shared.sh" + +git init -q + +mkdir tracked-dir untracked-dir-2 untracked-dir-3 +touch tracked-root-one tracked-root-two untracked-root-file \ + tracked-dir/tracked-file \ + untracked-dir-2/untracked-file-two \ + untracked-dir-3/untracked-file-three +git add tracked-root-one tracked-root-two tracked-dir/tracked-file +: >.git/info/exclude +git update-index --untracked-cache +seed_untracked_cache_times \ + . \ + .git/info/exclude \ + tracked-dir \ + tracked-dir/tracked-file \ + untracked-dir-2 \ + untracked-dir-2/untracked-file-two \ + untracked-dir-3 \ + untracked-dir-3/untracked-file-three \ + tracked-root-one \ + tracked-root-two \ + untracked-root-file diff --git a/gix-index/tests/fixtures/make_index/untracked_cache_nested.sh b/gix-index/tests/fixtures/make_index/untracked_cache_nested.sh new file mode 100755 index 00000000000..a503e5e1fe3 --- /dev/null +++ b/gix-index/tests/fixtures/make_index/untracked_cache_nested.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +. "$(dirname -- "${BASH_SOURCE[0]}")/shared.sh" + +GIT_FORCE_UNTRACKED_CACHE=true +export GIT_FORCE_UNTRACKED_CACHE + +git init -q +git config core.excludesFile "" + +# This fixture extends the populated case with a tracked per-directory +# `.gitignore` and nested untracked directories. The path names are intentionally +# verbose because the test snapshots assert the decoded UNTR directory graph. +mkdir -p tracked-dir-with-ignore/nested-untracked-dir/deep-untracked-dir \ + untracked-dir-2 \ + untracked-dir-3 +touch tracked-root-one tracked-root-two untracked-root-file \ + tracked-dir-with-ignore/tracked-file \ + tracked-dir-with-ignore/visible-untracked-file \ + tracked-dir-with-ignore/nested-untracked-dir/deep-untracked-dir/deep-untracked-file \ + untracked-dir-2/untracked-file-two \ + untracked-dir-3/untracked-file-three +printf "ignored-by-dir-ignore\nalso-ignored-by-dir-ignore\n" >tracked-dir-with-ignore/.gitignore +git add tracked-root-one tracked-root-two tracked-dir-with-ignore/tracked-file tracked-dir-with-ignore/.gitignore +mkdir -p .git/info +: >.git/info/exclude +git update-index --untracked-cache +seed_untracked_cache_times \ + . \ + .git/info/exclude \ + tracked-dir-with-ignore \ + tracked-dir-with-ignore/.gitignore \ + tracked-dir-with-ignore/tracked-file \ + tracked-dir-with-ignore/visible-untracked-file \ + tracked-dir-with-ignore/nested-untracked-dir \ + tracked-dir-with-ignore/nested-untracked-dir/deep-untracked-dir \ + tracked-dir-with-ignore/nested-untracked-dir/deep-untracked-dir/deep-untracked-file \ + untracked-dir-2 \ + untracked-dir-2/untracked-file-two \ + untracked-dir-3 \ + untracked-dir-3/untracked-file-three \ + tracked-root-one \ + tracked-root-two \ + untracked-root-file +git status --porcelain >/dev/null diff --git a/gix-index/tests/fixtures/make_index/untracked_cache_populated.sh b/gix-index/tests/fixtures/make_index/untracked_cache_populated.sh new file mode 100755 index 00000000000..55c8c1b7d36 --- /dev/null +++ b/gix-index/tests/fixtures/make_index/untracked_cache_populated.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +# Reuse the empty UNTR fixture and run status so Git fills the cache with the +# descriptive tracked/untracked path layout and seeded mtimes from that script. +. "$(dirname -- "${BASH_SOURCE[0]}")/untracked_cache_empty.sh" +# This triggers the untracked cache to be refreshed. +git status > /dev/null diff --git a/gix-index/tests/index/file/read.rs b/gix-index/tests/index/file/read.rs index 88c074858bc..83a4bc2c69a 100644 --- a/gix-index/tests/index/file/read.rs +++ b/gix-index/tests/index/file/read.rs @@ -20,23 +20,57 @@ pub(crate) fn loose_file(name: &str) -> gix_index::File { let file = gix_index::File::at(path, gix_hash::Kind::Sha1, false, Default::default()).unwrap(); verify(file) } -pub(crate) fn try_file(name: &str) -> Result { - let file = gix_index::File::at( - crate::fixture_index_path(name), - gix_hash::Kind::Sha1, - false, - Default::default(), - )?; +pub(crate) fn try_file(name: &str, needs_archive: bool) -> Result { + let path = if needs_archive { + crate::fixture_index_path_needs_archive(name) + } else { + crate::fixture_index_path(name) + }; + let file = gix_index::File::at(path, gix_hash::Kind::Sha1, false, Default::default())?; Ok(verify(file)) } pub(crate) fn file(name: &str) -> gix_index::File { - try_file(name).unwrap() + try_file(name, false).unwrap() +} +/// Needed if we have to freeze the fixture if contents depends on filesystem traversal order +/// This is Ok and similar to our manual copies of indices, except that it can be regenerated. +fn file_needs_archive(name: &str) -> gix_index::File { + try_file(name, true).unwrap() } fn file_opt(name: &str, opts: gix_index::decode::Options) -> gix_index::File { let file = gix_index::File::at(crate::fixture_index_path(name), gix_hash::Kind::Sha1, false, opts).unwrap(); verify(file) } +fn with_index_file_snapshot_filters(has_stable_mtimes: bool, run: impl FnOnce()) { + let mut settings = insta::Settings::clone_current(); + let stat_filter = if has_stable_mtimes { + ( + r"(?s)Stat \{\s+mtime: Time \{\s+secs: (\d+),\s+nsecs: (\d+),\s+\},\s+ctime: Time \{\s+secs: \d+,\s+nsecs: \d+,\s+\},\s+dev: \d+,\s+ino: \d+,\s+uid: \d+,\s+gid: \d+,\s+size: \d+,\s+\}", + "Stat { mtime: Time { secs: $1, nsecs: $2 }, ctime: Time { ... }, ... }", + ) + } else { + ( + r"(?s)Stat \{\s+mtime: Time \{\s+secs: \d+,\s+nsecs: \d+,\s+\},\s+ctime: Time \{\s+secs: \d+,\s+nsecs: \d+,\s+\},\s+dev: \d+,\s+ino: \d+,\s+uid: \d+,\s+gid: \d+,\s+size: \d+,\s+\}", + "Stat { ... }", + ) + }; + let mut filters = vec![ + (r#"(path: )"[^"]*""#, r#"$1"[redacted]""#), + (r#"(identifier: )"[^"]*""#, r#"$1"[redacted]""#), + ( + r"(?s)FileTime \{\s+seconds: \d+,\s+nanos: \d+,\s+\}", + "FileTime { ... }", + ), + stat_filter, + ]; + if !has_stable_mtimes { + filters.push((r" mtime: Time \{ secs: \d+, nsecs: \d+ \}", "")); + } + settings.set_filters(filters); + settings.bind(run); +} + #[test] fn v2_with_single_entry_tree_and_eoie_ext() { let file_disallow_threaded_loading = file_opt( @@ -100,28 +134,57 @@ fn v2_empty_skip_hash() { #[test] fn v2_with_multiple_entries_without_eoie_ext() { - let file = file("v2_more_files"); - assert_eq!(file.version(), Version::V2); - - assert_eq!(file.entries().len(), 6); - for (idx, path) in ["a", "b", "c", "d/a", "d/b", "d/c"].iter().enumerate() { - let e = &file.entries()[idx]; - assert_eq!(e.path(&file), path); - assert!(e.flags.is_empty()); - assert_eq!(e.mode, entry::Mode::FILE); - assert_eq!(e.id, hex_to_id("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391")); - } - - let tree = file.tree().unwrap(); - assert_eq!(tree.id, hex_to_id("c9b29c3168d8e677450cc650238b23d9390801fb")); - assert_eq!(tree.num_entries.unwrap_or_default(), 6); - assert!(tree.name.is_empty()); - assert_eq!(tree.children.len(), 1); - - let tree = &tree.children[0]; - assert_eq!(tree.id, hex_to_id("765b32c65d38f04c4f287abda055818ec0f26912")); - assert_eq!(tree.num_entries.unwrap_or_default(), 3); - assert_eq!(tree.name.as_bstr(), "d"); + let file = file_needs_archive("v2_more_files"); + with_index_file_snapshot_filters(true, || { + insta::assert_snapshot!(format!("{file:#?}"), @r#" + File { + path: "[redacted]", + checksum: Some( + Sha1(43bcf12743f506ab5fefaf13f8f5a7eed3d747fe), + ), + object_hash: Sha1, + timestamp: FileTime { ... }, + version: V2, + entries: [ + Mode(FILE) mtime: Time { secs: 1717397605, nsecs: 248416030 } e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 a, + Mode(FILE) mtime: Time { secs: 1717397605, nsecs: 248416030 } e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 b, + Mode(FILE) mtime: Time { secs: 1717397605, nsecs: 248416030 } e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 c, + Mode(FILE) mtime: Time { secs: 1717397605, nsecs: 256416095 } e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 d/a, + Mode(FILE) mtime: Time { secs: 1717397605, nsecs: 256416095 } e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 d/b, + Mode(FILE) mtime: Time { secs: 1717397605, nsecs: 256416095 } e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 d/c, + ], + path_backing_size_bytes: 12, + is_sparse: false, + end_of_index_at_decode_time: false, + offset_table_at_decode_time: false, + tree: Some( + Tree { + name: [], + id: Sha1(c9b29c3168d8e677450cc650238b23d9390801fb), + num_entries: Some( + 6, + ), + children: [ + Tree { + name: [ + 100, + ], + id: Sha1(765b32c65d38f04c4f287abda055818ec0f26912), + num_entries: Some( + 3, + ), + children: [], + }, + ], + }, + ), + has_link: false, + has_resolve_undo: false, + untracked: None, + has_fs_monitor: false, + } + "#); + }); } fn find_shared_index_for(index: impl AsRef) -> PathBuf { @@ -199,6 +262,286 @@ fn untr_extension_with_oids() { assert!(file.untracked().is_some()); } +#[test] +fn untr_extension_empty() { + let file = file_needs_archive("untracked_cache_empty"); + + with_index_file_snapshot_filters(false, || { + insta::assert_debug_snapshot!(&file, @r#" + File { + path: "[redacted]", + checksum: Some( + Sha1(e6e8bff2dab8feaa4cf41fd352248b0fc10acb56), + ), + object_hash: Sha1, + timestamp: FileTime { ... }, + version: V2, + entries: [ + Mode(FILE) e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 tracked-dir/tracked-file, + Mode(FILE) e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 tracked-root-one, + Mode(FILE) e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 tracked-root-two, + ], + path_backing_size_bytes: 56, + is_sparse: false, + end_of_index_at_decode_time: false, + offset_table_at_decode_time: false, + tree: None, + has_link: false, + has_resolve_undo: false, + untracked: Some( + UntrackedCache { + identifier: "[redacted]", + info_exclude: None, + excludes_file: None, + exclude_filename_per_dir: ".gitignore", + dir_flags: 6, + directories: [], + }, + ), + has_fs_monitor: false, + } + "#); + }); +} + +#[test] +fn untr_extension_populated() { + let file = file_needs_archive("untracked_cache_populated"); + + with_index_file_snapshot_filters(true, || { + insta::assert_debug_snapshot!(&file, @r#" + File { + path: "[redacted]", + checksum: Some( + Sha1(dabefe909b6858676ca56f46db0d9a30ad0d2a97), + ), + object_hash: Sha1, + timestamp: FileTime { ... }, + version: V2, + entries: [ + Mode(FILE) mtime: Time { secs: 2147483647, nsecs: 123456789 } e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 tracked-dir/tracked-file, + Mode(FILE) mtime: Time { secs: 2147483647, nsecs: 123456789 } e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 tracked-root-one, + Mode(FILE) mtime: Time { secs: 2147483647, nsecs: 123456789 } e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 tracked-root-two, + ], + path_backing_size_bytes: 56, + is_sparse: false, + end_of_index_at_decode_time: false, + offset_table_at_decode_time: false, + tree: None, + has_link: false, + has_resolve_undo: false, + untracked: Some( + UntrackedCache { + identifier: "[redacted]", + info_exclude: Some( + OidStat { + stat: Stat { mtime: Time { secs: 2147483647, nsecs: 123456789 }, ctime: Time { ... }, ... }, + id: Sha1(e69de29bb2d1d6434b8b29ae775ad8c2e48c5391), + }, + ), + excludes_file: None, + exclude_filename_per_dir: ".gitignore", + dir_flags: 6, + directories: [ + Directory { + name: "", + untracked_entries: [ + "untracked-root-file", + "untracked-dir-3/", + "untracked-dir-2/", + ], + sub_directories: [ + 1, + 2, + 3, + ], + stat: Some( + Stat { mtime: Time { secs: 2147483647, nsecs: 123456789 }, ctime: Time { ... }, ... }, + ), + exclude_file_oid: None, + check_only: false, + }, + Directory { + name: "tracked-dir", + untracked_entries: [], + sub_directories: [], + stat: Some( + Stat { mtime: Time { secs: 2147483647, nsecs: 123456789 }, ctime: Time { ... }, ... }, + ), + exclude_file_oid: None, + check_only: false, + }, + Directory { + name: "untracked-dir-2", + untracked_entries: [ + "untracked-file-two", + ], + sub_directories: [], + stat: Some( + Stat { mtime: Time { secs: 2147483647, nsecs: 123456789 }, ctime: Time { ... }, ... }, + ), + exclude_file_oid: None, + check_only: true, + }, + Directory { + name: "untracked-dir-3", + untracked_entries: [ + "untracked-file-three", + ], + sub_directories: [], + stat: Some( + Stat { mtime: Time { secs: 2147483647, nsecs: 123456789 }, ctime: Time { ... }, ... }, + ), + exclude_file_oid: None, + check_only: true, + }, + ], + }, + ), + has_fs_monitor: false, + } + "#); + }); +} + +/// This mirrors Git's sparse/subdir untracked-cache coverage: a directory can +/// carry its own exclude-file oid, and nested untracked directories are +/// serialized depth-first while root sub-directory indices still point at +/// the corresponding directory records. +#[test] +fn untr_extension_nested() { + let file = file_needs_archive("untracked_cache_nested"); + + with_index_file_snapshot_filters(true, || { + insta::assert_debug_snapshot!(&file, @r#" + File { + path: "[redacted]", + checksum: Some( + Sha1(bf50cd966cc718b67d3a326d01aa111f78901c1e), + ), + object_hash: Sha1, + timestamp: FileTime { ... }, + version: V2, + entries: [ + Mode(FILE) mtime: Time { secs: 2147483647, nsecs: 123456789 } 55535cdccae965cd0ea191aa22df1145a983b2f9 tracked-dir-with-ignore/.gitignore, + Mode(FILE) mtime: Time { secs: 2147483647, nsecs: 123456789 } e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 tracked-dir-with-ignore/tracked-file, + Mode(FILE) mtime: Time { secs: 2147483647, nsecs: 123456789 } e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 tracked-root-one, + Mode(FILE) mtime: Time { secs: 2147483647, nsecs: 123456789 } e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 tracked-root-two, + ], + path_backing_size_bytes: 102, + is_sparse: false, + end_of_index_at_decode_time: false, + offset_table_at_decode_time: false, + tree: None, + has_link: false, + has_resolve_undo: false, + untracked: Some( + UntrackedCache { + identifier: "[redacted]", + info_exclude: Some( + OidStat { + stat: Stat { mtime: Time { secs: 2147483647, nsecs: 123456789 }, ctime: Time { ... }, ... }, + id: Sha1(e69de29bb2d1d6434b8b29ae775ad8c2e48c5391), + }, + ), + excludes_file: None, + exclude_filename_per_dir: ".gitignore", + dir_flags: 6, + directories: [ + Directory { + name: "", + untracked_entries: [ + "untracked-root-file", + "untracked-dir-3/", + "untracked-dir-2/", + ], + sub_directories: [ + 1, + 4, + 5, + ], + stat: Some( + Stat { mtime: Time { secs: 2147483647, nsecs: 123456789 }, ctime: Time { ... }, ... }, + ), + exclude_file_oid: None, + check_only: false, + }, + Directory { + name: "tracked-dir-with-ignore", + untracked_entries: [ + "visible-untracked-file", + "nested-untracked-dir/", + ], + sub_directories: [ + 2, + ], + stat: Some( + Stat { mtime: Time { secs: 2147483647, nsecs: 123456789 }, ctime: Time { ... }, ... }, + ), + exclude_file_oid: Some( + Sha1(55535cdccae965cd0ea191aa22df1145a983b2f9), + ), + check_only: false, + }, + Directory { + name: "nested-untracked-dir", + untracked_entries: [ + "deep-untracked-dir/", + ], + sub_directories: [ + 3, + ], + stat: Some( + Stat { mtime: Time { secs: 2147483647, nsecs: 123456789 }, ctime: Time { ... }, ... }, + ), + exclude_file_oid: None, + check_only: true, + }, + Directory { + name: "deep-untracked-dir", + untracked_entries: [ + "deep-untracked-file", + ], + sub_directories: [], + stat: Some( + Stat { mtime: Time { secs: 2147483647, nsecs: 123456789 }, ctime: Time { ... }, ... }, + ), + exclude_file_oid: None, + check_only: true, + }, + Directory { + name: "untracked-dir-2", + untracked_entries: [ + "untracked-file-two", + ], + sub_directories: [], + stat: Some( + Stat { mtime: Time { secs: 2147483647, nsecs: 123456789 }, ctime: Time { ... }, ... }, + ), + exclude_file_oid: None, + check_only: true, + }, + Directory { + name: "untracked-dir-3", + untracked_entries: [ + "untracked-file-three", + ], + sub_directories: [], + stat: Some( + Stat { mtime: Time { secs: 2147483647, nsecs: 123456789 }, ctime: Time { ... }, ... }, + ), + exclude_file_oid: None, + check_only: true, + }, + ], + }, + ), + has_fs_monitor: false, + } + "#); + }); +} + #[test] fn fsmn_v1() { let file = loose_file("FSMN"); @@ -324,7 +667,7 @@ fn v2_split_index() { #[test] fn v2_split_index_recursion_is_handled_gracefully() { - let err = try_file("v2_split_index_recursive").expect_err("recursion fails gracefully"); + let err = try_file("v2_split_index_recursive", false).expect_err("recursion fails gracefully"); assert!(matches!( err, gix_index::file::init::Error::Decode(gix_index::decode::Error::Verify(_)) diff --git a/gix-index/tests/index/main.rs b/gix-index/tests/index/main.rs index 2e55ea3ae86..8d2c2157cf3 100644 --- a/gix-index/tests/index/main.rs +++ b/gix-index/tests/index/main.rs @@ -22,6 +22,14 @@ pub fn fixture_index_path(name: &str) -> PathBuf { dir.join(".git").join("index") } +pub fn fixture_index_path_needs_archive(name: &str) -> PathBuf { + let dir = gix_testtools::scripted_fixture_read_only_needs_archive( + Path::new("make_index").join(name).with_extension("sh"), + ) + .expect("script works"); + dir.join(".git").join("index") +} + pub fn loose_file_path(name: &str) -> PathBuf { gix_testtools::fixture_path(Path::new("loose_index").join(name).with_extension("git-index")) } diff --git a/tests/tools/src/lib.rs b/tests/tools/src/lib.rs index 9d3141850e9..e492b021b04 100644 --- a/tests/tools/src/lib.rs +++ b/tests/tools/src/lib.rs @@ -530,6 +530,31 @@ pub fn scripted_fixture_read_only(script_name: impl AsRef) -> Result) } +/// Like [`scripted_fixture_read_only()`], but uses a matching existing archive even if +/// `GIX_TEST_IGNORE_ARCHIVES` is set. +/// +/// Use this only for fixtures whose generated contents are not stable across +/// platforms or filesystems and must therefore be frozen by the checked-in +/// archive. +/// +/// CI normally sets `GIX_TEST_IGNORE_ARCHIVES` so fixture scripts are rerun and +/// tracked archives are proven reproducible. This helper is the opt-out for +/// fixtures where rerunning the producer can legitimately change +/// without changing semantics, for example when Git writes entries in filesystem +/// traversal order. +pub fn scripted_fixture_read_only_needs_archive(script_name: impl AsRef) -> Result { + scripted_fixture_read_only_with_args_inner::) -> PostResult, ()>( + script_name, + None::, + None, + ArgsInHash::Yes, + default_excludes(), + None::<(u32, _)>, + true, + ) + .map(|(dir, _)| dir) +} + /// Run the executable at `script_name`, like `make_repo.sh` to produce a writable directory to which /// the tempdir is returned. It will be removed automatically, courtesy of [`tempfile::TempDir`]. /// @@ -598,6 +623,7 @@ where args_in_hash, excludes, post_process.as_mut().map(|(v, f)| (*v, f)), + false, )?; copy_recursively_into_existing_dir(ro_dir, dst.path())?; (dst, _res_ignored) @@ -611,6 +637,7 @@ where args_in_hash, excludes, post_process.as_mut().map(|(v, f)| (*v, f)), + false, )?; (dst, post_result) } @@ -648,6 +675,7 @@ pub fn scripted_fixture_read_only_with_args( ArgsInHash::Yes, default_excludes(), None::<(u32, _)>, + false, ) .map(|(dir, _)| dir) } @@ -674,6 +702,7 @@ pub fn scripted_fixture_read_only_with_args_single_archive( ArgsInHash::No, default_excludes(), None::<(u32, _)>, + false, ) .map(|(dir, _)| dir) } @@ -698,6 +727,7 @@ pub fn scripted_fixture_read_only_with_post( ArgsInHash::Yes, default_excludes(), Some((version, post_process)), + false, ) .map(|(path, opt)| (path, opt.expect("post_process was provided"))) } @@ -718,6 +748,7 @@ pub fn scripted_fixture_read_only_with_args_with_post( ArgsInHash::Yes, default_excludes(), Some((version, post_process)), + false, ) .map(|(path, opt)| (path, opt.expect("post_process was provided"))) } @@ -738,6 +769,7 @@ pub fn scripted_fixture_read_only_with_args_single_archive_with_post( ArgsInHash::No, default_excludes(), Some((version, post_process)), + false, ) .map(|(path, opt)| (path, opt.expect("post_process was provided"))) } @@ -952,6 +984,7 @@ where &archive_name, object_hash, &script_identity, + None, ); let _marker = marker_if_needed(destination_dir, archive_name)?; @@ -960,6 +993,7 @@ where &script_result_directory, script_identity, force_run, + false, excludes, &format!("using Rust closure '{name}'"), make_fixture, @@ -991,26 +1025,32 @@ fn force_and_dir( archive_name: impl AsRef, object_hash: Option, script_identity: &dyn std::fmt::Display, + cache_variant: Option<&str>, ) -> (bool, PathBuf) { destination_dir.map_or_else( || { - let dir = fixture_base.join( + let mut dir = fixture_base.join( Path::new("generated-do-not-edit") .join(archive_name) - .join(object_hash.unwrap_or_else(self::object_hash).to_string()) - .join(format!("{}-{}", script_identity, family_name())), + .join(object_hash.unwrap_or_else(self::object_hash).to_string()), ); + if let Some(cache_variant) = cache_variant { + dir = dir.join(cache_variant); + } + let dir = dir.join(format!("{}-{}", script_identity, family_name())); (false, dir) }, |d| (true, d.to_owned()), ) } +#[expect(clippy::too_many_arguments)] fn run_fixture_generator_with_marker_handling( archive_file_path: &Path, script_result_directory: &Path, script_identity: u32, force_run: bool, + needs_archive: bool, excludes: &dyn IsExcluded, description: &str, make_fixture: F, @@ -1029,7 +1069,12 @@ where })?; } std::fs::create_dir_all(script_result_directory)?; - match extract_archive(archive_file_path, script_result_directory, script_identity) { + match extract_archive( + archive_file_path, + script_result_directory, + script_identity, + needs_archive, + ) { Ok((archive_id, platform)) => { eprintln!( "Extracted fixture from archive '{}' ({}, {:?})", @@ -1082,6 +1127,7 @@ fn scripted_fixture_read_only_with_args_inner( args_in_hash: ArgsInHash, excludes: &dyn IsExcluded, post_process: Option<(u32, F)>, + needs_archive: bool, ) -> Result<(PathBuf, Option)> where F: FnMut(FixtureState<'_>) -> PostResult, @@ -1166,6 +1212,7 @@ where script_basename, Some(object_hash), &script_identity, + needs_archive.then_some("archive"), ); let _marker = marker_if_needed(destination_dir, script_basename)?; @@ -1181,6 +1228,7 @@ where &script_result_directory, script_identity_for_archive, force_run, + needs_archive, excludes, &format!("using script '{}'", script_location.display()), |fixture_state| { @@ -1359,6 +1407,10 @@ fn configure_command<'a, I: IntoIterator, S: AsRef>( .env_remove("GIT_ASKPASS") .env_remove("SSH_ASKPASS") .env("MSYS", msys_for_git_bash_on_windows) + .env( + "XDG_CONFIG_HOME", + script_result_directory.join(".gix-testtools-xdg-config"), + ) .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", NULL_DEVICE) .env("GIT_TERMINAL_PROMPT", "false") @@ -1541,12 +1593,13 @@ fn extract_archive( archive: &Path, destination_dir: &Path, required_script_identity: u32, + needs_archive: bool, ) -> std::io::Result<(u32, Option)> { let archive_buf: Vec = { let mut buf = Vec::new(); #[cfg_attr(feature = "xz", allow(unused_mut))] let mut input_archive = std::fs::File::open(archive)?; - if env::var_os("GIX_TEST_IGNORE_ARCHIVES").is_some() { + if !needs_archive && env::var_os("GIX_TEST_IGNORE_ARCHIVES").is_some() { return Err(std::io::Error::other(format!( "Ignoring archive at '{}' as GIX_TEST_IGNORE_ARCHIVES is set.", archive.display() diff --git a/tests/tools/src/tests.rs b/tests/tools/src/tests.rs index 75150284189..9634ca7c34c 100644 --- a/tests/tools/src/tests.rs +++ b/tests/tools/src/tests.rs @@ -64,6 +64,23 @@ fn configure_command_clears_external_config() { assert_eq!(status, 0, "reading the config should succeed"); } +#[test] +fn configure_command_overrides_xdg_config_home() { + let temp = tempfile::TempDir::new().expect("can create temp dir"); + let mut cmd = std::process::Command::new(GIT_PROGRAM); + cmd.env("XDG_CONFIG_HOME", temp.path().join("external-config")); + configure_command(&mut cmd, gix_hash::Kind::default(), ["--version"], temp.path()); + + let xdg_config_home = cmd + .get_envs() + .find_map(|(key, value)| (key == "XDG_CONFIG_HOME").then_some(value)) + .flatten(); + assert_eq!( + xdg_config_home, + Some(temp.path().join(".gix-testtools-xdg-config").as_os_str()) + ); +} + #[test] #[cfg(windows)] fn bash_program_ok_for_platform() { @@ -237,3 +254,28 @@ fn gitignore_fallback_normalizes_windows_path_separators() { Path::new(r"generated-archives\rust-basic.tar") )); } + +#[test] +fn archive_required_fixtures_use_a_separate_cache_directory() { + // Archive-required fixtures must not share the normal generated fixture + // cache. Otherwise, a previous script run can leave platform-specific + // output behind and make a later archive-required request skip extraction. + // Using different paths makes sure they are actually from the archive if they exist. + let fixture_base = Path::new("tests").join("fixtures"); + let (_, generated_dir) = force_and_dir(None, &fixture_base, "scripted", Some(gix_hash::Kind::Sha1), &1234, None); + let (_, archived_dir) = force_and_dir( + None, + &fixture_base, + "scripted", + Some(gix_hash::Kind::Sha1), + &1234, + Some("archive"), + ); + + assert_ne!(generated_dir, archived_dir); + assert!( + archived_dir + .components() + .any(|component| component.as_os_str() == "archive") + ); +}