Skip to main content

sley_worktree/
lib.rs

1#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
2#![allow(
3    clippy::collapsible_if,
4    clippy::if_same_then_else,
5    clippy::ptr_arg,
6    clippy::too_many_arguments
7)]
8
9use sley_config::GitConfig;
10use sley_core::{
11    BString, GitError, MissingObjectContext, MissingObjectKind, ObjectFormat, ObjectId, RepoPath,
12    Result,
13};
14use sley_index::{
15    BorrowedIndex, CacheTree, Index, IndexEntry, IndexEntryRef, SPARSE_DIR_MODE, SplitIndexLink,
16    Stage, UntrackedCache, UntrackedCacheDir, UntrackedCacheOidStat, UntrackedCacheStatData,
17};
18use sley_object::{Commit, EncodedObject, ObjectType, Tree, TreeEntry, tree_entry_object_type};
19use sley_odb::{FileObjectDatabase, ObjectPresenceChecker, ObjectReader, ObjectWriter};
20use sley_refs::{FileRefStore, RefTarget, RefUpdate, ReflogEntry, branch_ref_name};
21use std::borrow::Cow;
22use std::cell::{Cell, RefCell};
23use std::cmp::Ordering;
24use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
25use std::io::{Read, Write};
26use std::ops::Range;
27use std::path::{Path, PathBuf};
28use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
29use std::sync::{Mutex, OnceLock};
30use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
31use std::{env, fs};
32
33// --- Mechanical module split (wave-47) -------------------------------------
34// The former single 21.8k-line lib.rs is partitioned into contiguous
35// submodules along its existing function-cluster seams. Each submodule pulls
36// the crate-root scope in via `use super::*` and is re-exported below so every
37// `sley_worktree::X` path (public API and intra-crate) resolves unchanged.
38// This is a pure code move: no function body was altered.
39mod attributes;
40mod checkout;
41mod filter;
42mod ignore;
43mod index;
44mod index_io;
45mod move_remove;
46mod status;
47mod types_admin;
48
49// `attributes` and `index_io` hold only crate-internal helpers (no `pub`
50// items), so they are reached via direct `use crate::{attributes,index_io}::*`
51// at their call sites (including the test module) rather than a public glob
52// re-export, which would re-export nothing and warn.
53pub use checkout::*;
54pub use filter::*;
55pub use ignore::*;
56pub use index::*;
57pub use index_io::{StatCleanFilterValidator, fill_index_entry_stat_cache};
58pub use move_remove::*;
59pub use status::*;
60pub use types_admin::*;
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use crate::attributes::*;
66    use crate::index_io::*;
67    use sley_odb::ObjectReader;
68    use std::sync::atomic::{AtomicU64, Ordering};
69
70    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
71
72    fn short_status(
73        worktree_root: impl AsRef<Path>,
74        git_dir: impl AsRef<Path>,
75        format: ObjectFormat,
76    ) -> Result<Vec<ShortStatusEntry>> {
77        let mut entries = Vec::new();
78        stream_short_status(worktree_root, git_dir, format, |entry| {
79            entries.push(entry.to_owned_entry());
80            Ok(StreamControl::Continue)
81        })?;
82        Ok(entries)
83    }
84
85    #[test]
86    fn atomic_metadata_writer_writes_and_reports_stat() {
87        let root = temp_root();
88        let path = root.join(".git").join("HEAD");
89
90        let result = write_metadata_file_atomic(
91            &path,
92            b"ref: refs/heads/main\n",
93            AtomicMetadataWriteOptions::default(),
94        )
95        .expect("write metadata");
96
97        assert_eq!(
98            fs::read(&path).expect("read metadata"),
99            b"ref: refs/heads/main\n"
100        );
101        assert_eq!(result.path, path);
102        assert_eq!(result.len, b"ref: refs/heads/main\n".len() as u64);
103        assert!(result.mtime.is_some());
104        assert!(!path.with_file_name("HEAD.lock").exists());
105        fs::remove_dir_all(root).expect("test operation should succeed");
106    }
107
108    #[test]
109    fn atomic_metadata_writer_existing_lock_preserves_original() {
110        let root = temp_root();
111        let git_dir = root.join(".git");
112        fs::create_dir_all(&git_dir).expect("create git dir");
113        let path = git_dir.join("HEAD");
114        let lock = git_dir.join("HEAD.lock");
115        fs::write(&path, b"ref: refs/heads/main\n").expect("write original");
116        fs::write(&lock, b"held\n").expect("write lock");
117
118        let err = write_metadata_file_atomic(
119            &path,
120            b"ref: refs/heads/other\n",
121            AtomicMetadataWriteOptions::default(),
122        )
123        .expect_err("held lock must fail");
124
125        assert!(matches!(err, GitError::Transaction(_)));
126        assert_eq!(
127            fs::read(&path).expect("read original"),
128            b"ref: refs/heads/main\n"
129        );
130        assert_eq!(fs::read(&lock).expect("read lock"), b"held\n");
131        fs::remove_dir_all(root).expect("test operation should succeed");
132    }
133
134    // --- `ls-files --eol` stat/attr helpers (mirror convert.c) ---------------
135
136    #[test]
137    fn convert_stats_ascii_classifies_eol_content() {
138        assert_eq!(convert_stats_ascii(b""), "none");
139        assert_eq!(convert_stats_ascii(b"abc"), "none");
140        assert_eq!(convert_stats_ascii(b"a\nb\n"), "lf");
141        assert_eq!(convert_stats_ascii(b"a\r\nb\r\n"), "crlf");
142        assert_eq!(convert_stats_ascii(b"a\r\nb\n"), "mixed");
143        // A lone CR makes the content binary (-text), matching git.
144        assert_eq!(convert_stats_ascii(b"a\rb"), "-text");
145        // A NUL byte is binary.
146        assert_eq!(convert_stats_ascii(b"a\0b\n"), "-text");
147        // A trailing ^Z (EOF) is not counted as non-printable.
148        assert_eq!(convert_stats_ascii(b"abc\n\x1a"), "lf");
149    }
150
151    fn attr_check(name: &[u8], state: Option<AttributeState>) -> AttributeCheck {
152        AttributeCheck {
153            attribute: name.to_vec(),
154            state,
155        }
156    }
157
158    #[test]
159    fn convert_attr_ascii_matches_git_attr_action() {
160        // No attributes at all: empty attr field.
161        assert_eq!(convert_attr_ascii(&[]), "");
162        // text (set) -> "text"; -text (unset) -> "-text".
163        assert_eq!(
164            convert_attr_ascii(&[attr_check(b"text", Some(AttributeState::Set))]),
165            "text"
166        );
167        assert_eq!(
168            convert_attr_ascii(&[attr_check(b"text", Some(AttributeState::Unset))]),
169            "-text"
170        );
171        // text=auto -> "text=auto"; with eol=crlf/lf the AUTO variants.
172        assert_eq!(
173            convert_attr_ascii(&[attr_check(
174                b"text",
175                Some(AttributeState::Value(b"auto".to_vec()))
176            )]),
177            "text=auto"
178        );
179        assert_eq!(
180            convert_attr_ascii(&[
181                attr_check(b"text", Some(AttributeState::Value(b"auto".to_vec()))),
182                attr_check(b"eol", Some(AttributeState::Value(b"crlf".to_vec()))),
183            ]),
184            "text=auto eol=crlf"
185        );
186        assert_eq!(
187            convert_attr_ascii(&[
188                attr_check(b"text", Some(AttributeState::Value(b"auto".to_vec()))),
189                attr_check(b"eol", Some(AttributeState::Value(b"lf".to_vec()))),
190            ]),
191            "text=auto eol=lf"
192        );
193        // eol=crlf/lf alone (no text) forces text + the eol direction.
194        assert_eq!(
195            convert_attr_ascii(&[attr_check(
196                b"eol",
197                Some(AttributeState::Value(b"crlf".to_vec()))
198            )]),
199            "text eol=crlf"
200        );
201        assert_eq!(
202            convert_attr_ascii(&[attr_check(
203                b"eol",
204                Some(AttributeState::Value(b"lf".to_vec()))
205            )]),
206            "text eol=lf"
207        );
208        // -text overrides any eol attribute (binary wins).
209        assert_eq!(
210            convert_attr_ascii(&[
211                attr_check(b"text", Some(AttributeState::Unset)),
212                attr_check(b"eol", Some(AttributeState::Value(b"crlf".to_vec()))),
213            ]),
214            "-text"
215        );
216    }
217
218    #[test]
219    fn smudge_safety_guard_skips_irreversible_autocrlf() {
220        // text=auto eol=crlf (AUTO_CRLF): convert pure-LF, but leave content
221        // alone when it already has a CR or CRLF, or is binary.
222        let auto = ContentFilterPlan {
223            text: TextDecision::Auto,
224            eol: EolConversion::Crlf,
225            ident: false,
226            driver: None,
227            encoding: WtEncoding::None,
228        };
229        assert!(auto.will_convert_lf_to_crlf(b"a\nb\n"));
230        assert!(!auto.will_convert_lf_to_crlf(b"a\r\nb\n")); // has CRLF
231        assert!(!auto.will_convert_lf_to_crlf(b"a\nb\rc")); // lone CR (binary)
232        assert!(!auto.will_convert_lf_to_crlf(b"abc")); // no naked LF
233
234        // text eol=crlf (TEXT_CRLF): no safety guard — always convert naked LF
235        // even when a CR/CRLF is already present.
236        let text = ContentFilterPlan {
237            text: TextDecision::Text,
238            eol: EolConversion::Crlf,
239            ident: false,
240            driver: None,
241            encoding: WtEncoding::None,
242        };
243        assert!(text.will_convert_lf_to_crlf(b"a\r\nb\nc\n"));
244        assert!(!text.will_convert_lf_to_crlf(b"a\r\nb\r\n")); // no naked LF
245    }
246
247    /// Build an in-memory ignore matcher from raw `.gitignore` lines (no disk).
248    fn ignore_matcher(patterns: &[&[u8]]) -> IgnoreMatcher {
249        let mut matcher = IgnoreMatcher::default();
250        let owned: Vec<Vec<u8>> = patterns.iter().map(|p| p.to_vec()).collect();
251        matcher.extend_patterns(&owned);
252        matcher
253    }
254
255    #[test]
256    fn ignore_match_kind_fast_paths_match_the_wildcard_engine() {
257        // Literal: exact basename anywhere; not a superstring.
258        let matcher = ignore_matcher(&[b"Pods"]);
259        assert!(matcher.is_ignored(b"a/b/Pods", true));
260        assert!(matcher.is_ignored(b"Pods", false));
261        assert!(!matcher.is_ignored(b"Pods_not", false));
262        assert!(matches!(
263            classify_ignore_pattern(b"Pods"),
264            MatchKind::Literal
265        ));
266
267        // Suffix `*.log`: basename ending in `.log` at any depth.
268        let matcher = ignore_matcher(&[b"*.log"]);
269        assert!(matcher.is_ignored(b"x.log", false));
270        assert!(matcher.is_ignored(b"a/b/x.log", false));
271        assert!(matcher.is_ignored(b".log", false));
272        assert!(!matcher.is_ignored(b"x.logx", false));
273        assert!(matches!(
274            classify_ignore_pattern(b"*.log"),
275            MatchKind::Suffix
276        ));
277
278        // Prefix `build*`: basename starting with `build`.
279        let matcher = ignore_matcher(&[b"build*"]);
280        assert!(matcher.is_ignored(b"buildfoo", false));
281        assert!(matcher.is_ignored(b"a/build", false));
282        assert!(!matcher.is_ignored(b"xbuild", false));
283        assert!(matches!(
284            classify_ignore_pattern(b"build*"),
285            MatchKind::Prefix
286        ));
287    }
288
289    #[test]
290    fn ignore_anchored_suffix_does_not_cross_slash() {
291        // `/*.log` is anchored: matches `.log` files only at the matcher base,
292        // never in a subdirectory — the slash guard in `match_segment`.
293        let matcher = ignore_matcher(&[b"/*.log"]);
294        assert!(matcher.is_ignored(b"x.log", false));
295        assert!(!matcher.is_ignored(b"sub/x.log", false));
296
297        // Anchored literal likewise only matches at root.
298        let matcher = ignore_matcher(&[b"/foo"]);
299        assert!(matcher.is_ignored(b"foo", false));
300        assert!(!matcher.is_ignored(b"a/foo", false));
301    }
302
303    #[test]
304    fn ignore_anchored_directory_glob_matches_root_directory() {
305        let matcher = ignore_matcher(&[b"/tmp-*/"]);
306        assert!(matcher.is_ignored(b"tmp-info-only", true));
307        assert!(matcher.is_ignored(b"tmp-info-only/file.txt", false));
308        assert!(!matcher.is_ignored(b"nested/tmp-info-only", true));
309        assert!(!matcher.is_ignored(b"tmp-info-only", false));
310    }
311
312    #[test]
313    fn ignore_negated_directory_glob_does_not_reinclude_files() {
314        // t0008-ignores "directories and ** matches": a negated directory-only
315        // pattern re-includes *directories* but never the *files* inside them
316        // (git: re-including a dir with `!dir/` still needs an explicit
317        // `!dir/*` to reach its files). Verified against git 2.54 check-ignore:
318        //   data/file              -> data/**           (ignored)
319        //   data/data1/file1       -> data/**           (ignored, NOT !data/**/)
320        //   data/data1/file1.txt   -> !data/**/*.txt    (re-included)
321        //   data/data1   (dir)     -> !data/**/         (re-included)
322        let matcher = ignore_matcher(&[b"data/**", b"!data/**/", b"!data/**/*.txt"]);
323        // Files stay ignored: `!data/**/` must not win the file leaf scan.
324        assert!(matcher.is_ignored(b"data/file", false));
325        assert!(matcher.is_ignored(b"data/data1/file1", false));
326        assert!(matcher.is_ignored(b"data/data2/file2", false));
327        // `.txt` files are re-included by the explicit non-dir negation.
328        assert!(!matcher.is_ignored(b"data/data1/file1.txt", false));
329        assert!(!matcher.is_ignored(b"data/data2/file2.txt", false));
330        // Directories ARE re-included by `!data/**/` (the directory-glob gain
331        // from `fix: match git status ignored directory globs`).
332        assert!(!matcher.is_ignored(b"data/data1", true));
333        assert!(!matcher.is_ignored(b"data/data2", true));
334    }
335
336    #[test]
337    fn ignore_double_star_prefix_collapses_to_basename() {
338        // `**/X` ≡ `X` for slash-free X (verified against `git check-ignore`).
339        let matcher = ignore_matcher(&[b"**/Pods"]);
340        assert!(matcher.is_ignored(b"a/b/Pods", true));
341        assert!(matcher.is_ignored(b"Pods", true));
342        assert!(!matcher.is_ignored(b"Pods_not", false));
343
344        let matcher = ignore_matcher(&[b"**/*.jks"]);
345        assert!(matcher.is_ignored(b"x.jks", false));
346        assert!(matcher.is_ignored(b"a/deep/y.jks", false));
347        assert!(!matcher.is_ignored(b"x.jksx", false));
348
349        // `**/A/B` keeps a slash in the tail, so it stays a real glob and must
350        // match the trailing path at any depth.
351        let matcher = ignore_matcher(&[b"**/Flutter/ephemeral"]);
352        assert!(matcher.is_ignored(b"Flutter/ephemeral", true));
353        assert!(matcher.is_ignored(b"a/Flutter/ephemeral", true));
354        assert!(!matcher.is_ignored(b"Flutter/other", true));
355        assert!(matches!(
356            classify_ignore_pattern(b"**/Flutter/ephemeral"),
357            MatchKind::PathSuffix
358        ));
359    }
360
361    #[test]
362    fn ignore_slash_glob_literal_basename_bucket_preserves_matches() {
363        let matcher = ignore_matcher(&[b"**/android/**/GeneratedPluginRegistrant.java"]);
364        assert!(
365            matcher
366                .buckets
367                .glob_path_literal_basename
368                .contains_key(b"GeneratedPluginRegistrant.java".as_slice())
369        );
370        assert!(matcher.is_ignored(
371            b"packages/app/android/src/GeneratedPluginRegistrant.java",
372            false
373        ));
374        assert!(matcher.is_ignored(
375            b"android/app/src/main/java/io/flutter/GeneratedPluginRegistrant.java",
376            false
377        ));
378        assert!(!matcher.is_ignored(b"android/app/src/main/java/io/flutter/Other.java", false));
379
380        let matcher = ignore_matcher(&[b"**/ios/**/Pods/"]);
381        assert!(
382            matcher
383                .buckets
384                .glob_directory_literal_basename
385                .contains_key(b"Pods".as_slice())
386        );
387        assert!(matcher.is_ignored(b"ios/Runner/Pods", true));
388        assert!(matcher.is_ignored(b"dev/app/ios/Runner/Pods/Manifest.lock", false));
389        assert!(!matcher.is_ignored(b"dev/app/ios/Runner/Podfile", false));
390
391        let matcher = ignore_matcher(&[b"**/ios/**/*.mode1v3"]);
392        assert!(
393            !matcher.buckets.glob_path_suffix_basename.is_empty(),
394            "suffix-final slash glob should be prefiltered by basename suffix"
395        );
396        assert!(matcher.is_ignored(b"apps/ios/Runner/default.mode1v3", false));
397        assert!(!matcher.is_ignored(b"apps/ios/Runner/default.mode2v3", false));
398
399        let matcher = ignore_matcher(&[b"**/ios/Runner/GeneratedPluginRegistrant.*"]);
400        assert!(
401            !matcher.buckets.glob_path_prefix_basename.is_empty(),
402            "prefix-final slash glob should be prefiltered by basename prefix"
403        );
404        assert!(matcher.is_ignored(b"apps/ios/Runner/GeneratedPluginRegistrant.swift", false));
405        assert!(!matcher.is_ignored(
406            b"apps/ios/Runner/OtherGeneratedPluginRegistrant.swift",
407            false
408        ));
409
410        let matcher = ignore_matcher(&[b"ios/Scenarios/*.framework/"]);
411        assert!(
412            !matcher.buckets.glob_directory_suffix_basename.is_empty(),
413            "directory suffix-final slash glob should be prefiltered by directory component"
414        );
415        assert!(matcher.is_ignored(b"ios/Scenarios/App.framework", true));
416        assert!(matcher.is_ignored(b"ios/Scenarios/App.framework/Info.plist", false));
417        assert!(!matcher.is_ignored(b"ios/Scenarios/App.xcframework/Info.plist", false));
418    }
419
420    #[test]
421    fn ignore_complex_globs_still_use_the_engine() {
422        let matcher = ignore_matcher(&[b"*.[Cc]ache"]);
423        assert!(matcher.is_ignored(b"x.cache", false));
424        assert!(matcher.is_ignored(b"x.Cache", false));
425        assert!(!matcher.is_ignored(b"x.xache", false));
426        assert!(matches!(
427            classify_ignore_pattern(b"*.[Cc]ache"),
428            MatchKind::Glob
429        ));
430
431        let matcher = ignore_matcher(&[b"Icon?"]);
432        assert!(matcher.is_ignored(b"IconA", false));
433        assert!(!matcher.is_ignored(b"Icon", false));
434        assert!(!matcher.is_ignored(b"IconAB", false));
435
436        // Multi-star is not a simple prefix/suffix.
437        assert!(matches!(
438            classify_ignore_pattern(b"app.*.symbols"),
439            MatchKind::Glob
440        ));
441        assert!(matches!(classify_ignore_pattern(b"a*b*c"), MatchKind::Glob));
442
443        let matcher = ignore_matcher(&[b".vscode/*", b"dev/devicelab/ABresults*.json"]);
444        assert!(matcher.is_ignored(b".vscode/settings.json", false));
445        assert!(!matcher.is_ignored(b"pkg/.vscode/settings.json", false));
446        assert!(matcher.is_ignored(b"dev/devicelab/ABresults-1.json", false));
447        assert!(!matcher.is_ignored(b"dev/devicelab/results-1.json", false));
448    }
449
450    #[test]
451    fn ignore_negation_still_applies_after_fast_paths() {
452        // Last match wins: a negated literal un-ignores a suffix-matched file.
453        let matcher = ignore_matcher(&[b"*.log", b"!keep.log"]);
454        assert!(matcher.is_ignored(b"a/x.log", false));
455        assert!(!matcher.is_ignored(b"a/keep.log", false));
456    }
457
458    #[test]
459    fn read_expected_object_missing_blob_exposes_oid_and_kind() {
460        let root = temp_root();
461        let git_dir = root.join(".git");
462        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
463        let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
464        let missing = ObjectId::empty_blob(ObjectFormat::Sha1);
465
466        let err = read_expected_object(&db, &missing, ObjectType::Blob)
467            .expect_err("missing blob should error");
468        let kind = err.not_found_kind().expect("typed not found");
469        assert_eq!(kind.object_id(), Some(missing));
470        assert_eq!(kind.missing_object_kind(), Some(MissingObjectKind::Blob));
471        assert_eq!(
472            kind.missing_object_context(),
473            Some(MissingObjectContext::WorktreeMaterialize)
474        );
475        fs::remove_dir_all(root).expect("test operation should succeed");
476    }
477
478    #[test]
479    fn update_index_adds_file_entry_and_blob() {
480        let root = temp_root();
481        let git_dir = root.join(".git");
482        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
483        fs::write(root.join("hello.txt"), b"hello\n").expect("test operation should succeed");
484        let result = add_paths_to_index(
485            &root,
486            &git_dir,
487            ObjectFormat::Sha1,
488            &[PathBuf::from("hello.txt")],
489        )
490        .expect("test operation should succeed");
491        assert_eq!(result.entries, 1);
492        let index = Index::parse_v2_sha1(
493            &fs::read(repository_index_path(git_dir)).expect("test operation should succeed"),
494        )
495        .expect("test operation should succeed");
496        assert_eq!(index.entries[0].path, b"hello.txt");
497        fs::remove_dir_all(root).expect("test operation should succeed");
498    }
499
500    #[test]
501    fn update_index_and_write_tree_support_sha256() {
502        let root = temp_root();
503        let git_dir = root.join(".git");
504        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
505        fs::write(root.join("hello.txt"), b"hello\n").expect("test operation should succeed");
506        let result = add_paths_to_index(
507            &root,
508            &git_dir,
509            ObjectFormat::Sha256,
510            &[PathBuf::from("hello.txt")],
511        )
512        .expect("test operation should succeed");
513        assert_eq!(result.entries, 1);
514
515        let index = Index::parse(
516            &fs::read(repository_index_path(&git_dir)).expect("test operation should succeed"),
517            ObjectFormat::Sha256,
518        )
519        .expect("test operation should succeed");
520        assert_eq!(index.entries[0].path, b"hello.txt");
521        assert_eq!(index.entries[0].oid.format(), ObjectFormat::Sha256);
522
523        let tree_oid = write_tree_from_index(&git_dir, ObjectFormat::Sha256)
524            .expect("test operation should succeed");
525        assert_eq!(tree_oid.format(), ObjectFormat::Sha256);
526        let odb = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha256);
527        let tree = odb
528            .read_object(&tree_oid)
529            .expect("test operation should succeed");
530        assert_eq!(tree.object_type, ObjectType::Tree);
531        fs::remove_dir_all(root).expect("test operation should succeed");
532    }
533
534    #[test]
535    fn write_tree_from_index_writes_nested_tree_objects() {
536        let root = temp_root();
537        let git_dir = root.join(".git");
538        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
539        fs::create_dir_all(root.join("src")).expect("test operation should succeed");
540        fs::write(root.join("README.md"), b"readme\n").expect("test operation should succeed");
541        fs::write(root.join("src").join("lib.rs"), b"pub fn demo() {}\n")
542            .expect("test operation should succeed");
543        let result = add_paths_to_index(
544            &root,
545            &git_dir,
546            ObjectFormat::Sha1,
547            &[PathBuf::from("README.md"), PathBuf::from("src/lib.rs")],
548        )
549        .expect("test operation should succeed");
550        assert_eq!(result.entries, 2);
551        let tree_oid = write_tree_from_index(&git_dir, ObjectFormat::Sha1)
552            .expect("test operation should succeed");
553        let odb = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
554        let tree = odb
555            .read_object(&tree_oid)
556            .expect("test operation should succeed");
557        assert_eq!(tree.object_type, ObjectType::Tree);
558        fs::remove_dir_all(root).expect("test operation should succeed");
559    }
560
561    #[test]
562    fn write_tree_from_index_expands_empty_primary_split_index() {
563        let root = temp_root();
564        let git_dir = root.join(".git");
565        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
566        fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
567        add_paths_to_index(
568            &root,
569            &git_dir,
570            ObjectFormat::Sha1,
571            &[PathBuf::from("f.txt")],
572        )
573        .expect("test operation should succeed");
574        let expected = write_tree_from_index(&git_dir, ObjectFormat::Sha1)
575            .expect("test operation should succeed");
576
577        enable_split_index(&git_dir, ObjectFormat::Sha1).expect("test operation should succeed");
578        let primary = read_index(&git_dir);
579        assert!(
580            primary.entries.is_empty(),
581            "fixture should put all entries in the shared index"
582        );
583        assert!(
584            primary
585                .split_index_link(ObjectFormat::Sha1)
586                .expect("test operation should succeed")
587                .is_some(),
588            "fixture should write a split-index link extension"
589        );
590
591        let actual = write_tree_from_index(&git_dir, ObjectFormat::Sha1)
592            .expect("test operation should succeed");
593        assert_eq!(actual, expected);
594
595        fs::remove_dir_all(root).expect("test operation should succeed");
596    }
597
598    #[test]
599    fn short_status_reports_added_and_untracked_paths() {
600        let root = temp_root();
601        let git_dir = root.join(".git");
602        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
603        fs::write(root.join("hello.txt"), b"hello\n").expect("test operation should succeed");
604        fs::write(root.join("extra.txt"), b"extra\n").expect("test operation should succeed");
605        add_paths_to_index(
606            &root,
607            &git_dir,
608            ObjectFormat::Sha1,
609            &[PathBuf::from("hello.txt")],
610        )
611        .expect("test operation should succeed");
612        let status = short_status(&root, &git_dir, ObjectFormat::Sha1)
613            .expect("test operation should succeed");
614        assert_eq!(
615            status
616                .iter()
617                .map(ShortStatusEntry::line)
618                .collect::<Vec<_>>(),
619            vec!["A  hello.txt", "?? extra.txt"]
620        );
621        fs::remove_dir_all(root).expect("test operation should succeed");
622    }
623
624    #[test]
625    fn borrowed_untracked_frontier_preserves_directory_ignore_scopes() {
626        let root = temp_root();
627        let git_dir = root.join(".git");
628        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
629        fs::write(root.join("tracked.txt"), b"tracked\n").expect("test operation should succeed");
630        build_commit(&root, &git_dir, &["tracked.txt"]);
631
632        fs::create_dir_all(root.join("alpha").join("nested"))
633            .expect("test operation should succeed");
634        fs::write(root.join("alpha").join(".gitignore"), b"blocked.txt\n")
635            .expect("test operation should succeed");
636        fs::write(
637            root.join("alpha").join("nested").join("blocked.txt"),
638            b"ignored\n",
639        )
640        .expect("test operation should succeed");
641        fs::write(
642            root.join("alpha").join("nested").join("visible.txt"),
643            b"visible\n",
644        )
645        .expect("test operation should succeed");
646
647        fs::create_dir_all(root.join("beta").join("nested"))
648            .expect("test operation should succeed");
649        fs::write(
650            root.join("beta").join("nested").join("blocked.txt"),
651            b"visible\n",
652        )
653        .expect("test operation should succeed");
654
655        let index_bytes = read_borrowed_index_bytes(&repository_index_path(&git_dir))
656            .expect("test operation should succeed");
657        let borrowed = BorrowedIndex::parse(index_bytes.as_ref(), ObjectFormat::Sha1)
658            .expect("test operation should succeed");
659        let mut ignores =
660            IgnoreMatcher::from_worktree_base(&root).expect("test operation should succeed");
661        let paths = status_untracked_paths_from_borrowed_index(
662            &root,
663            &git_dir,
664            &borrowed,
665            &mut ignores,
666            StatusUntrackedMode::All,
667            None,
668        )
669        .expect("test operation should succeed");
670
671        assert_eq!(
672            paths,
673            vec![
674                b"alpha/.gitignore".to_vec(),
675                b"alpha/nested/visible.txt".to_vec(),
676                b"beta/nested/blocked.txt".to_vec(),
677            ]
678        );
679        fs::remove_dir_all(root).expect("test operation should succeed");
680    }
681
682    #[test]
683    fn worktree_root_is_none_for_bare_repository() {
684        // A bare git_dir (basename `.git`) with `core.bare = true` must resolve to
685        // `Ok(None)` rather than falling through to the "parent of .git" case.
686        let root = temp_root();
687        let git_dir = root.join(".git");
688        fs::create_dir_all(&git_dir).expect("create bare git dir");
689        // Hermetic minimal config — do not depend on host gitconfig.
690        fs::write(git_dir.join("config"), b"[core]\n\tbare = true\n").expect("write bare config");
691
692        assert_eq!(
693            worktree_root_for_git_dir(&git_dir).expect("resolve bare worktree root"),
694            None,
695            "a bare repository has no working tree"
696        );
697
698        fs::remove_dir_all(root).expect("test operation should succeed");
699    }
700
701    #[test]
702    fn worktree_root_is_parent_for_non_bare_dot_git() {
703        // A non-bare `.git` directory (no core.bare / core.bare = false) still
704        // resolves to its parent — the ordinary non-bare layout.
705        let root = temp_root();
706        let work = root.join("work");
707        let git_dir = work.join(".git");
708        fs::create_dir_all(&git_dir).expect("create non-bare git dir");
709        fs::write(git_dir.join("config"), b"[core]\n\tbare = false\n")
710            .expect("write non-bare config");
711
712        assert_eq!(
713            worktree_root_for_git_dir(&git_dir).expect("resolve non-bare worktree root"),
714            Some(work.clone()),
715            "a non-bare .git dir resolves to its parent"
716        );
717
718        fs::remove_dir_all(root).expect("test operation should succeed");
719    }
720
721    fn temp_root() -> PathBuf {
722        let path = std::env::temp_dir().join(format!(
723            "sley-worktree-{}-{}",
724            std::process::id(),
725            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
726        ));
727        fs::create_dir_all(&path).expect("test operation should succeed");
728        path
729    }
730
731    fn index_entry_for<'a>(index: &'a Index, path: &[u8]) -> &'a IndexEntry {
732        index
733            .entries
734            .iter()
735            .find(|entry| entry.path == path)
736            .unwrap_or_else(|| panic!("missing index entry for {}", String::from_utf8_lossy(path)))
737    }
738
739    fn read_index(git_dir: &Path) -> Index {
740        Index::parse(
741            &fs::read(repository_index_path(git_dir)).expect("test operation should succeed"),
742            ObjectFormat::Sha1,
743        )
744        .expect("test operation should succeed")
745    }
746
747    /// Stages `paths` from the worktree, writes their tree, wraps it in a commit
748    /// object, and points `refs/heads/main` + `HEAD` at it. Returns the commit
749    /// id. After this call the index reflects the committed tree.
750    fn build_commit(root: &Path, git_dir: &Path, paths: &[&str]) -> ObjectId {
751        let path_bufs = paths.iter().map(PathBuf::from).collect::<Vec<_>>();
752        add_paths_to_index(root, git_dir, ObjectFormat::Sha1, &path_bufs)
753            .expect("test operation should succeed");
754        let tree = write_tree_from_index(git_dir, ObjectFormat::Sha1)
755            .expect("test operation should succeed");
756        let mut body = Vec::new();
757        body.extend_from_slice(format!("tree {tree}\n").as_bytes());
758        body.extend_from_slice(b"author Test <test@example.com> 0 +0000\n");
759        body.extend_from_slice(b"committer Test <test@example.com> 0 +0000\n");
760        body.extend_from_slice(b"\n");
761        body.extend_from_slice(b"sparse fixture\n");
762        let odb = FileObjectDatabase::from_git_dir(git_dir, ObjectFormat::Sha1);
763        let commit = odb
764            .write_object(EncodedObject::new(ObjectType::Commit, body))
765            .expect("test operation should succeed");
766        let refs = FileRefStore::new(git_dir, ObjectFormat::Sha1);
767        let mut tx = refs.transaction();
768        tx.update(RefUpdate {
769            name: "refs/heads/main".into(),
770            expected: None,
771            new: RefTarget::Direct(commit),
772            reflog: None,
773        });
774        tx.update(RefUpdate {
775            name: "HEAD".into(),
776            expected: None,
777            new: RefTarget::Symbolic("refs/heads/main".into()),
778            reflog: None,
779        });
780        tx.commit().expect("test operation should succeed");
781        commit
782    }
783
784    fn full_sparse(patterns: &[&[u8]]) -> SparseCheckout {
785        SparseCheckout {
786            patterns: patterns.iter().map(|pattern| pattern.to_vec()).collect(),
787            sparse_index: false,
788        }
789    }
790
791    #[test]
792    fn apply_sparse_checkout_full_mode_skips_out_of_cone_paths() {
793        let root = temp_root();
794        let git_dir = root.join(".git");
795        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
796        fs::create_dir_all(root.join("in")).expect("test operation should succeed");
797        fs::create_dir_all(root.join("out")).expect("test operation should succeed");
798        fs::write(root.join("in").join("keep.txt"), b"keep\n")
799            .expect("test operation should succeed");
800        fs::write(root.join("out").join("drop.txt"), b"drop\n")
801            .expect("test operation should succeed");
802        fs::write(root.join("top.txt"), b"top\n").expect("test operation should succeed");
803        build_commit(&root, &git_dir, &["in/keep.txt", "out/drop.txt", "top.txt"]);
804
805        // Full (non-cone) pattern: keep only the `in/` subtree.
806        let sparse = full_sparse(&[b"/in/"]);
807        let result = apply_sparse_checkout_with_mode(
808            &root,
809            &git_dir,
810            ObjectFormat::Sha1,
811            &sparse,
812            SparseCheckoutMode::Full,
813        )
814        .expect("test operation should succeed");
815
816        assert!(root.join("in").join("keep.txt").exists());
817        assert!(!root.join("out").join("drop.txt").exists());
818        assert!(!root.join("top.txt").exists());
819        assert!(result.materialized.contains(&b"in/keep.txt".to_vec()));
820        assert!(result.skipped.contains(&b"out/drop.txt".to_vec()));
821        assert!(result.skipped.contains(&b"top.txt".to_vec()));
822
823        let index = read_index(&git_dir);
824        assert!(!index_entry_skip_worktree(index_entry_for(
825            &index,
826            b"in/keep.txt"
827        )));
828        assert!(index_entry_skip_worktree(index_entry_for(
829            &index,
830            b"out/drop.txt"
831        )));
832        assert!(index_entry_skip_worktree(index_entry_for(
833            &index, b"top.txt"
834        )));
835        // Out-of-cone entries are preserved in the index, just not on disk.
836        assert_eq!(index.entries.len(), 3);
837        fs::remove_dir_all(root).expect("test operation should succeed");
838    }
839
840    #[test]
841    fn apply_sparse_checkout_toggle_rematerializes() {
842        let root = temp_root();
843        let git_dir = root.join(".git");
844        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
845        fs::create_dir_all(root.join("a")).expect("test operation should succeed");
846        fs::create_dir_all(root.join("b")).expect("test operation should succeed");
847        fs::write(root.join("a").join("file.txt"), b"a\n").expect("test operation should succeed");
848        fs::write(root.join("b").join("file.txt"), b"b\n").expect("test operation should succeed");
849        build_commit(&root, &git_dir, &["a/file.txt", "b/file.txt"]);
850
851        // First narrow to `a/`.
852        apply_sparse_checkout_with_mode(
853            &root,
854            &git_dir,
855            ObjectFormat::Sha1,
856            &full_sparse(&[b"/a/"]),
857            SparseCheckoutMode::Full,
858        )
859        .expect("test operation should succeed");
860        assert!(root.join("a").join("file.txt").exists());
861        assert!(!root.join("b").join("file.txt").exists());
862        let index = read_index(&git_dir);
863        assert!(index_entry_skip_worktree(index_entry_for(
864            &index,
865            b"b/file.txt"
866        )));
867
868        // Now switch the cone to `b/`: `a/` must leave, `b/` must come back with
869        // the correct content, and the skip-worktree bits must flip.
870        apply_sparse_checkout_with_mode(
871            &root,
872            &git_dir,
873            ObjectFormat::Sha1,
874            &full_sparse(&[b"/b/"]),
875            SparseCheckoutMode::Full,
876        )
877        .expect("test operation should succeed");
878        assert!(!root.join("a").join("file.txt").exists());
879        assert!(root.join("b").join("file.txt").exists());
880        assert_eq!(
881            fs::read(root.join("b").join("file.txt")).expect("test operation should succeed"),
882            b"b\n"
883        );
884        let index = read_index(&git_dir);
885        assert!(index_entry_skip_worktree(index_entry_for(
886            &index,
887            b"a/file.txt"
888        )));
889        assert!(!index_entry_skip_worktree(index_entry_for(
890            &index,
891            b"b/file.txt"
892        )));
893        fs::remove_dir_all(root).expect("test operation should succeed");
894    }
895
896    #[test]
897    fn apply_sparse_checkout_cone_mode_matches_directory_prefixes() {
898        let root = temp_root();
899        let git_dir = root.join(".git");
900        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
901        fs::create_dir_all(root.join("kept").join("nested"))
902            .expect("test operation should succeed");
903        fs::create_dir_all(root.join("other")).expect("test operation should succeed");
904        fs::write(root.join("kept").join("a.txt"), b"a\n").expect("test operation should succeed");
905        fs::write(root.join("kept").join("nested").join("b.txt"), b"b\n")
906            .expect("test operation should succeed");
907        fs::write(root.join("other").join("c.txt"), b"c\n").expect("test operation should succeed");
908        fs::write(root.join("root.txt"), b"r\n").expect("test operation should succeed");
909        build_commit(
910            &root,
911            &git_dir,
912            &["kept/a.txt", "kept/nested/b.txt", "other/c.txt", "root.txt"],
913        );
914
915        // Standard cone patterns: top-level files plus the whole `kept/` tree.
916        let sparse = SparseCheckout {
917            patterns: vec![b"/*".to_vec(), b"!/*/".to_vec(), b"/kept/".to_vec()],
918            sparse_index: false,
919        };
920        // Auto mode should detect cone shape on its own.
921        assert!(patterns_are_cone(&sparse.patterns));
922        apply_sparse_checkout(&root, &git_dir, ObjectFormat::Sha1, &sparse)
923            .expect("test operation should succeed");
924
925        assert!(root.join("root.txt").exists());
926        assert!(root.join("kept").join("a.txt").exists());
927        assert!(root.join("kept").join("nested").join("b.txt").exists());
928        assert!(!root.join("other").join("c.txt").exists());
929
930        let index = read_index(&git_dir);
931        assert!(!index_entry_skip_worktree(index_entry_for(
932            &index,
933            b"root.txt"
934        )));
935        assert!(!index_entry_skip_worktree(index_entry_for(
936            &index,
937            b"kept/a.txt"
938        )));
939        assert!(!index_entry_skip_worktree(index_entry_for(
940            &index,
941            b"kept/nested/b.txt"
942        )));
943        assert!(index_entry_skip_worktree(index_entry_for(
944            &index,
945            b"other/c.txt"
946        )));
947        fs::remove_dir_all(root).expect("test operation should succeed");
948    }
949
950    #[test]
951    fn apply_sparse_checkout_cone_parent_guards_keep_only_direct_files() {
952        let sparse = SparseCheckout {
953            patterns: vec![
954                b"/*".to_vec(),
955                b"!/*/".to_vec(),
956                b"/deep/".to_vec(),
957                b"!/deep/*/".to_vec(),
958                b"/deep/kept/".to_vec(),
959            ],
960            sparse_index: false,
961        };
962
963        assert!(path_in_sparse_checkout(
964            b"deep/file.txt",
965            &sparse,
966            SparseCheckoutMode::Cone
967        ));
968        assert!(path_in_sparse_checkout(
969            b"deep/kept/file.txt",
970            &sparse,
971            SparseCheckoutMode::Cone
972        ));
973        assert!(!path_in_sparse_checkout(
974            b"deep/dropped/file.txt",
975            &sparse,
976            SparseCheckoutMode::Cone
977        ));
978    }
979
980    #[test]
981    fn full_sparse_checkout_later_ancestor_exclusion_wins() {
982        let sparse = SparseCheckout {
983            patterns: vec![
984                b"a".to_vec(),
985                b"!/x".to_vec(),
986                b"y/".to_vec(),
987                b"!x/y/z".to_vec(),
988            ],
989            sparse_index: false,
990        };
991
992        assert!(path_in_sparse_checkout(
993            b"a",
994            &sparse,
995            SparseCheckoutMode::Full
996        ));
997        assert!(path_in_sparse_checkout(
998            b"x/y/keep",
999            &sparse,
1000            SparseCheckoutMode::Full
1001        ));
1002        assert!(!path_in_sparse_checkout(
1003            b"x/y/z/new-a",
1004            &sparse,
1005            SparseCheckoutMode::Full
1006        ));
1007    }
1008
1009    #[test]
1010    fn apply_sparse_checkout_honors_preexisting_skip_worktree_via_idempotence() {
1011        let root = temp_root();
1012        let git_dir = root.join(".git");
1013        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
1014        fs::create_dir_all(root.join("in")).expect("test operation should succeed");
1015        fs::create_dir_all(root.join("out")).expect("test operation should succeed");
1016        fs::write(root.join("in").join("keep.txt"), b"keep\n")
1017            .expect("test operation should succeed");
1018        fs::write(root.join("out").join("drop.txt"), b"drop\n")
1019            .expect("test operation should succeed");
1020        build_commit(&root, &git_dir, &["in/keep.txt", "out/drop.txt"]);
1021
1022        let sparse = full_sparse(&[b"/in/"]);
1023        apply_sparse_checkout_with_mode(
1024            &root,
1025            &git_dir,
1026            ObjectFormat::Sha1,
1027            &sparse,
1028            SparseCheckoutMode::Full,
1029        )
1030        .expect("test operation should succeed");
1031        assert!(!root.join("out").join("drop.txt").exists());
1032
1033        // Re-applying the same spec is a no-op: the already-skipped file stays
1034        // absent and the bit stays set (we do not resurrect it).
1035        let result = apply_sparse_checkout_with_mode(
1036            &root,
1037            &git_dir,
1038            ObjectFormat::Sha1,
1039            &sparse,
1040            SparseCheckoutMode::Full,
1041        )
1042        .expect("test operation should succeed");
1043        assert!(!root.join("out").join("drop.txt").exists());
1044        assert!(root.join("in").join("keep.txt").exists());
1045        assert!(result.skipped.contains(&b"out/drop.txt".to_vec()));
1046        let index = read_index(&git_dir);
1047        assert!(index_entry_skip_worktree(index_entry_for(
1048            &index,
1049            b"out/drop.txt"
1050        )));
1051        fs::remove_dir_all(root).expect("test operation should succeed");
1052    }
1053
1054    #[test]
1055    fn checkout_detached_sparse_only_writes_in_cone_paths() {
1056        let root = temp_root();
1057        let git_dir = root.join(".git");
1058        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
1059        fs::create_dir_all(root.join("keep")).expect("test operation should succeed");
1060        fs::create_dir_all(root.join("skip")).expect("test operation should succeed");
1061        fs::write(root.join("keep").join("a.txt"), b"a\n").expect("test operation should succeed");
1062        fs::write(root.join("skip").join("b.txt"), b"b\n").expect("test operation should succeed");
1063        let commit = build_commit(&root, &git_dir, &["keep/a.txt", "skip/b.txt"]);
1064
1065        // The worktree is clean and matches the commit. A sparse checkout must
1066        // keep the in-cone file and evict the out-of-cone one.
1067        let sparse = full_sparse(&[b"/keep/"]);
1068        let result = checkout_detached_sparse(
1069            &root,
1070            &git_dir,
1071            ObjectFormat::Sha1,
1072            &commit,
1073            b"Test <test@example.com> 0 +0000".to_vec(),
1074            b"checkout".to_vec(),
1075            &sparse,
1076        )
1077        .expect("test operation should succeed");
1078        assert_eq!(result.files, 2);
1079
1080        assert!(root.join("keep").join("a.txt").exists());
1081        assert_eq!(
1082            fs::read(root.join("keep").join("a.txt")).expect("test operation should succeed"),
1083            b"a\n"
1084        );
1085        assert!(!root.join("skip").join("b.txt").exists());
1086
1087        let index = read_index(&git_dir);
1088        assert_eq!(index.entries.len(), 2);
1089        assert!(!index_entry_skip_worktree(index_entry_for(
1090            &index,
1091            b"keep/a.txt"
1092        )));
1093        let skipped = index_entry_for(&index, b"skip/b.txt");
1094        assert!(index_entry_skip_worktree(skipped));
1095        // The skipped entry still carries the committed blob id and mode.
1096        assert_eq!(skipped.mode, 0o100644);
1097        fs::remove_dir_all(root).expect("test operation should succeed");
1098    }
1099
1100    #[test]
1101    fn restore_from_tree_default_wildcard_matches_subdirectory_file() {
1102        let root = temp_root();
1103        let git_dir = root.join(".git");
1104        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
1105        fs::create_dir_all(root.join("subdir")).expect("test operation should succeed");
1106        fs::write(root.join("subdir").join("file3"), b"file3-1\n")
1107            .expect("test operation should succeed");
1108        let old_commit = build_commit(&root, &git_dir, &["subdir/file3"]);
1109        let db = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
1110        let old_tree = read_commit(&db, ObjectFormat::Sha1, &old_commit)
1111            .expect("test operation should succeed")
1112            .tree;
1113
1114        fs::write(root.join("subdir").join("file3"), b"file3-2\n")
1115            .expect("test operation should succeed");
1116        build_commit(&root, &git_dir, &["subdir/file3"]);
1117
1118        let result = restore_index_and_worktree_paths_from_tree(
1119            &root,
1120            &git_dir,
1121            ObjectFormat::Sha1,
1122            &old_tree,
1123            &[PathBuf::from("*file3")],
1124            false,
1125        )
1126        .expect("wildcard pathspec should select subdir/file3");
1127
1128        assert_eq!(result.restored, 1);
1129        assert_eq!(
1130            fs::read(root.join("subdir").join("file3")).expect("test operation should succeed"),
1131            b"file3-1\n"
1132        );
1133        fs::remove_dir_all(root).expect("test operation should succeed");
1134    }
1135
1136    // ----- content filtering: EOL / autocrlf + clean/smudge drivers -----
1137
1138    /// Build a [`GitConfig`] from raw config text.
1139    fn config_from(text: &str) -> GitConfig {
1140        GitConfig::parse(text.as_bytes()).expect("test operation should succeed")
1141    }
1142
1143    /// Conformance grid for git's `output_eol(crlf_action)` decision table
1144    /// (convert.c) on the smudge side, exercised across the same
1145    /// attr × autocrlf × eol × content matrix as upstream t0027/t0026.
1146    ///
1147    /// Each row asserts the smudge output for a representative content shape.
1148    /// The cases that historically under-converted are the non-`auto` `text`
1149    /// paths (the auto-only safety guard must NOT fire) and the
1150    /// `autocrlf=true overrides core.eol` precedence rows.
1151    #[test]
1152    fn smudge_output_eol_decision_table() {
1153        // Naked-LF-only blob (the canonical "should gain CRLF" case).
1154        const LF: &[u8] = b"a\nb\nc\n";
1155        // Mixed CRLF + naked LF: a non-auto crlf action converts the naked LFs
1156        // to CRLF (whole file becomes CRLF); an auto action leaves it untouched.
1157        const CRLF_MIX_LF: &[u8] = b"a\r\nb\nc\r\n";
1158        // Naked LF plus a lone CR: non-auto converts LFs, keeping the lone CR.
1159        const LF_MIX_CR: &[u8] = b"a\nb\rc\n";
1160
1161        let smudge = |cfg: &str, attrline: Option<&[u8]>, input: &[u8]| -> Vec<u8> {
1162            let config = config_from(cfg);
1163            let checks = match attrline {
1164                Some(line) => {
1165                    let mut matcher = AttributeMatcher::default();
1166                    read_attribute_patterns_from_bytes(line, &mut matcher, &[], b".gitattributes");
1167                    matcher.attributes_for_path(b"f.txt", &filter_attribute_names(), false)
1168                }
1169                None => Vec::new(),
1170            };
1171            apply_smudge_filter_with_attributes(&config, &checks, b"f.txt", input)
1172                .expect("smudge must succeed")
1173        };
1174
1175        // --- attr=text (CRLF_TEXT_*): non-auto, the safety guard must not fire.
1176        // text + eol=crlf => CRLF_TEXT_CRLF: every naked LF gains CR.
1177        let attr_text_crlf: &[u8] = b"*.txt text eol=crlf";
1178        for cfg in [
1179            "[core]\n\tautocrlf = false\n\teol = lf\n",
1180            "[core]\n\tautocrlf = false\n\teol = crlf\n",
1181            "[core]\n\tautocrlf = true\n\teol = lf\n",
1182            "[core]\n\tautocrlf = input\n",
1183        ] {
1184            assert_eq!(
1185                smudge(cfg, Some(attr_text_crlf), LF),
1186                b"a\r\nb\r\nc\r\n",
1187                "text eol=crlf must add CR to naked LF (cfg={cfg:?})"
1188            );
1189            assert_eq!(
1190                smudge(cfg, Some(attr_text_crlf), CRLF_MIX_LF),
1191                b"a\r\nb\r\nc\r\n",
1192                "text eol=crlf must convert mixed content fully (cfg={cfg:?})"
1193            );
1194            assert_eq!(
1195                smudge(cfg, Some(attr_text_crlf), LF_MIX_CR),
1196                b"a\r\nb\rc\r\n",
1197                "text eol=crlf keeps the lone CR but adds CR to naked LF (cfg={cfg:?})"
1198            );
1199        }
1200
1201        // --- attr=text, no eol attr: CRLF_TEXT, resolved by text_eol_is_crlf().
1202        // autocrlf=true wins over core.eol=lf (the precedence fix).
1203        assert_eq!(
1204            smudge(
1205                "[core]\n\tautocrlf = true\n\teol = lf\n",
1206                Some(b"*.txt text"),
1207                LF
1208            ),
1209            b"a\r\nb\r\nc\r\n",
1210            "autocrlf=true must override core.eol=lf for plain text attr"
1211        );
1212        // autocrlf unset, core.eol=crlf => CRLF.
1213        assert_eq!(
1214            smudge("[core]\n\teol = crlf\n", Some(b"*.txt text"), LF),
1215            b"a\r\nb\r\nc\r\n",
1216            "core.eol=crlf adds CR to naked LF for plain text attr"
1217        );
1218        // autocrlf unset, core.eol=lf (and native LF on this host) => no CR.
1219        assert_eq!(
1220            smudge("[core]\n\teol = lf\n", Some(b"*.txt text"), LF),
1221            LF,
1222            "core.eol=lf leaves naked LF untouched on smudge"
1223        );
1224        // text + autocrlf=input => CRLF_TEXT_INPUT: no CR on smudge.
1225        assert_eq!(
1226            smudge("[core]\n\tautocrlf = input\n", Some(b"*.txt text"), LF),
1227            LF,
1228            "autocrlf=input overrides core.eol; no CR on smudge"
1229        );
1230
1231        // --- attr=text=auto (CRLF_AUTO_*): the safety guard DOES fire.
1232        // auto + autocrlf=true + naked-LF-only => convert.
1233        assert_eq!(
1234            smudge("[core]\n\tautocrlf = true\n", Some(b"*.txt text=auto"), LF),
1235            b"a\r\nb\r\nc\r\n",
1236            "text=auto converts a clean naked-LF file"
1237        );
1238        // auto + already has a CR/CRLF => leave untouched (irreversible guard).
1239        assert_eq!(
1240            smudge(
1241                "[core]\n\tautocrlf = true\n",
1242                Some(b"*.txt text=auto"),
1243                CRLF_MIX_LF
1244            ),
1245            CRLF_MIX_LF,
1246            "text=auto must not touch content that already has CRLF"
1247        );
1248        assert_eq!(
1249            smudge(
1250                "[core]\n\tautocrlf = true\n",
1251                Some(b"*.txt text=auto"),
1252                LF_MIX_CR
1253            ),
1254            LF_MIX_CR,
1255            "text=auto must not touch content that already has a lone CR"
1256        );
1257
1258        // --- no attr, autocrlf=true => CRLF_AUTO_CRLF (auto guard applies).
1259        assert_eq!(
1260            smudge("[core]\n\tautocrlf = true\n\teol = lf\n", None, LF),
1261            b"a\r\nb\r\nc\r\n",
1262            "autocrlf=true (no attr) converts clean naked-LF and overrides core.eol=lf"
1263        );
1264        // --- no attr, autocrlf=false => CRLF_BINARY: never convert.
1265        assert_eq!(
1266            smudge("[core]\n\teol = crlf\n", None, LF),
1267            LF,
1268            "no attr + autocrlf=false leaves content untouched even with core.eol=crlf"
1269        );
1270        // --- -text (CRLF_BINARY): never convert regardless of config.
1271        assert_eq!(
1272            smudge("[core]\n\tautocrlf = true\n", Some(b"*.txt -text"), LF),
1273            LF,
1274            "-text is binary: never convert"
1275        );
1276    }
1277
1278    /// Resolve attribute checks against an on-disk `.gitattributes` in `root`.
1279    fn attrs(root: &Path, path: &[u8]) -> Vec<AttributeCheck> {
1280        filter_attribute_checks(root, path).expect("test operation should succeed")
1281    }
1282
1283    #[test]
1284    fn standard_attribute_matcher_matches_per_path_lookup() {
1285        let root = temp_root();
1286        fs::create_dir_all(root.join(".git").join("info")).expect("test operation should succeed");
1287        fs::create_dir_all(root.join("src").join("nested")).expect("test operation should succeed");
1288        fs::write(root.join(".gitattributes"), b"*.rs diff=rust\n")
1289            .expect("test operation should succeed");
1290        fs::write(
1291            root.join("src").join(".gitattributes"),
1292            b"*.rs diff=python\n",
1293        )
1294        .expect("test operation should succeed");
1295        fs::write(
1296            root.join(".git").join("info").join("attributes"),
1297            b"src/nested/*.rs diff=java\n",
1298        )
1299        .expect("test operation should succeed");
1300
1301        let requested = vec![b"diff".to_vec()];
1302        let path = b"src/nested/file.rs";
1303        let per_path = standard_attributes_for_path(&root, path, &requested, false)
1304            .expect("test operation should succeed");
1305        let matcher = StandardAttributeMatcher::from_worktree_root(&root)
1306            .expect("test operation should succeed");
1307        assert_eq!(
1308            matcher.attributes_for_path(path, &requested, false),
1309            per_path
1310        );
1311
1312        fs::remove_dir_all(root).expect("test operation should succeed");
1313    }
1314
1315    #[test]
1316    fn filter_attribute_lookup_reads_only_path_chain() {
1317        let root = temp_root();
1318        fs::create_dir_all(root.join(".git").join("info")).expect("test operation should succeed");
1319        fs::create_dir_all(root.join("src").join("nested")).expect("test operation should succeed");
1320        fs::create_dir_all(root.join("sibling")).expect("test operation should succeed");
1321        fs::write(root.join(".gitattributes"), b"*.txt text\n")
1322            .expect("test operation should succeed");
1323        fs::write(root.join("src").join(".gitattributes"), b"*.txt -text\n")
1324            .expect("test operation should succeed");
1325        fs::write(
1326            root.join("sibling").join(".gitattributes"),
1327            b"*.txt eol=crlf\n",
1328        )
1329        .expect("test operation should succeed");
1330        fs::write(
1331            root.join(".git").join("info").join("attributes"),
1332            b"src/nested/*.txt eol=lf\n",
1333        )
1334        .expect("test operation should succeed");
1335
1336        let path = b"src/nested/file.txt";
1337        let full = standard_attributes_for_path(&root, path, &filter_attribute_names(), false)
1338            .expect("test operation should succeed");
1339        assert_eq!(
1340            filter_attribute_checks(&root, path).expect("attribute checks should load"),
1341            full
1342        );
1343
1344        fs::remove_dir_all(root).expect("test operation should succeed");
1345    }
1346
1347    #[test]
1348    fn crlf_to_lf_collapses_only_pairs() {
1349        assert_eq!(
1350            convert_crlf_to_lf_cow(Cow::Borrowed(b"a\r\nb\r\n")).as_ref(),
1351            b"a\nb\n"
1352        );
1353        // A lone CR (no following LF) is preserved.
1354        assert_eq!(
1355            convert_crlf_to_lf_cow(Cow::Borrowed(b"a\rb")).as_ref(),
1356            b"a\rb"
1357        );
1358        // An already-LF stream is unchanged.
1359        assert!(matches!(
1360            convert_crlf_to_lf_cow(Cow::Borrowed(b"a\nb\n")),
1361            Cow::Borrowed(_)
1362        ));
1363    }
1364
1365    #[test]
1366    fn lf_to_crlf_does_not_double_convert() {
1367        assert_eq!(convert_lf_to_crlf(b"a\nb\n"), b"a\r\nb\r\n");
1368        // Existing CRLF is left intact (no extra CR added).
1369        assert_eq!(convert_lf_to_crlf(b"a\r\nb\r\n"), b"a\r\nb\r\n");
1370    }
1371
1372    #[test]
1373    fn autocrlf_round_trip_clean_then_smudge() {
1374        // autocrlf=true: worktree CRLF -> blob LF on clean, blob LF -> worktree
1375        // CRLF on smudge.
1376        let config = config_from("[core]\n\tautocrlf = true\n");
1377        let checks: Vec<AttributeCheck> = Vec::new();
1378        let worktree = b"line1\r\nline2\r\n";
1379        let blob = apply_clean_filter_with_attributes(&config, &checks, b"file.txt", worktree)
1380            .expect("test operation should succeed");
1381        assert_eq!(blob, b"line1\nline2\n", "clean must normalize CRLF to LF");
1382        let restored = apply_smudge_filter_with_attributes(&config, &checks, b"file.txt", &blob)
1383            .expect("test operation should succeed");
1384        assert_eq!(
1385            restored, worktree,
1386            "smudge must restore CRLF from the LF blob"
1387        );
1388    }
1389
1390    #[test]
1391    fn working_tree_encoding_utf16_smudge_uses_platform_iconv_order() {
1392        let config = config_from("");
1393        let mut matcher = AttributeMatcher::default();
1394        read_attribute_patterns_from_bytes(
1395            b"*.utf16 text working-tree-encoding=utf-16",
1396            &mut matcher,
1397            &[],
1398            b".gitattributes",
1399        );
1400        let checks = matcher.attributes_for_path(b"test.utf16", &filter_attribute_names(), false);
1401
1402        let restored = apply_smudge_filter_with_attributes(&config, &checks, b"test.utf16", b"A\n")
1403            .expect("smudge must encode UTF-16");
1404        let expected =
1405            encode_utf16(b"A\n", ICONV_UTF_DEFAULT_LE, true).expect("valid UTF-8 encodes");
1406
1407        assert_eq!(restored, expected);
1408    }
1409
1410    #[test]
1411    fn working_tree_encoding_smudge_applies_eol_before_utf32() {
1412        let config = config_from("[core]\n\teol = crlf\n");
1413        let mut matcher = AttributeMatcher::default();
1414        read_attribute_patterns_from_bytes(
1415            b"*.utf32 text working-tree-encoding=utf-32",
1416            &mut matcher,
1417            &[],
1418            b".gitattributes",
1419        );
1420        let checks = matcher.attributes_for_path(b"eol.utf32", &filter_attribute_names(), false);
1421
1422        let restored =
1423            apply_smudge_filter_with_attributes(&config, &checks, b"eol.utf32", b"one\ntwo\n")
1424                .expect("smudge must encode UTF-32");
1425        let expected = encode_utf32(b"one\r\ntwo\r\n", ICONV_UTF_DEFAULT_LE, true)
1426            .expect("valid UTF-8 encodes");
1427
1428        assert_eq!(restored, expected);
1429    }
1430
1431    #[test]
1432    fn working_tree_encoding_shift_jis_clean_and_roundtrip_config() {
1433        let mut matcher = AttributeMatcher::default();
1434        read_attribute_patterns_from_bytes(
1435            b"*.shift text working-tree-encoding=SHIFT-JIS",
1436            &mut matcher,
1437            &[],
1438            b".gitattributes",
1439        );
1440        let checks =
1441            matcher.attributes_for_path(b"roundtrip.shift", &filter_attribute_names(), false);
1442        let (shift_jis, _, had_errors) = encoding_rs::SHIFT_JIS.encode("hallo\n");
1443        assert!(!had_errors);
1444
1445        let config = config_from("");
1446        assert!(should_check_roundtrip_encoding(&config, b"SHIFT-JIS"));
1447        let blob =
1448            apply_clean_filter_with_attributes(&config, &checks, b"roundtrip.shift", &shift_jis)
1449                .expect("SHIFT-JIS clean must decode to UTF-8");
1450        assert_eq!(blob, b"hallo\n");
1451
1452        let disabled = config_from("[core]\n\tcheckRoundtripEncoding = garbage\n");
1453        assert!(!should_check_roundtrip_encoding(&disabled, b"SHIFT-JIS"));
1454    }
1455
1456    #[test]
1457    fn working_tree_encoding_missing_bom_is_fatal_when_writing_object() {
1458        let config = config_from("");
1459        let mut matcher = AttributeMatcher::default();
1460        read_attribute_patterns_from_bytes(
1461            b"*.utf16 text working-tree-encoding=utf-16",
1462            &mut matcher,
1463            &[],
1464            b".gitattributes",
1465        );
1466        let checks =
1467            matcher.attributes_for_path(b"nonsense.utf16", &filter_attribute_names(), false);
1468
1469        let err = apply_clean_filter_cow_inner(
1470            &config,
1471            &checks,
1472            b"nonsense.utf16",
1473            b"\0a\0b\0c",
1474            ConvFlags::Off,
1475            SafeCrlfIndexBlob::None,
1476            true,
1477        )
1478        .expect_err("UTF-16 without a BOM must be rejected when writing an object");
1479
1480        assert!(matches!(err, GitError::Exit(128)));
1481    }
1482
1483    #[test]
1484    fn conv_flags_from_config_matches_git_defaults() {
1485        // Unset core.safecrlf defaults to WARN (git's global_conv_flags_eol).
1486        assert_eq!(ConvFlags::from_config(&config_from("")), ConvFlags::Warn);
1487        assert_eq!(
1488            ConvFlags::from_config(&config_from("[core]\n\tsafecrlf = warn\n")),
1489            ConvFlags::Warn
1490        );
1491        assert_eq!(
1492            ConvFlags::from_config(&config_from("[core]\n\tsafecrlf = WARN\n")),
1493            ConvFlags::Warn
1494        );
1495        assert_eq!(
1496            ConvFlags::from_config(&config_from("[core]\n\tsafecrlf = true\n")),
1497            ConvFlags::Die
1498        );
1499        assert_eq!(
1500            ConvFlags::from_config(&config_from("[core]\n\tsafecrlf = false\n")),
1501            ConvFlags::Off
1502        );
1503    }
1504
1505    #[test]
1506    fn safecrlf_warn_does_not_change_clean_bytes() {
1507        // The warning is purely additive: byte output is identical whether
1508        // safecrlf is off or warn.
1509        let config = config_from("[core]\n\tautocrlf = true\n");
1510        let checks: Vec<AttributeCheck> = Vec::new();
1511        let worktree = b"a\nb\nc\n";
1512        let plain = apply_clean_filter_with_attributes(&config, &checks, b"f.txt", worktree)
1513            .expect("clean");
1514        let warned = apply_clean_filter_with_attributes_cow_safecrlf(
1515            &config,
1516            &checks,
1517            b"f.txt",
1518            worktree,
1519            ConvFlags::Warn,
1520            SafeCrlfIndexBlob::None,
1521        )
1522        .expect("clean with safecrlf")
1523        .into_owned();
1524        assert_eq!(plain, warned, "safecrlf must not alter the cleaned bytes");
1525    }
1526
1527    #[test]
1528    fn safecrlf_die_errors_on_lf_to_crlf_round_trip() {
1529        // autocrlf=true on a pure-LF file: checkout would add CRLF, so the
1530        // round-trip is irreversible and safecrlf=true dies (exit 128).
1531        let config = config_from("[core]\n\tautocrlf = true\n");
1532        let checks: Vec<AttributeCheck> = Vec::new();
1533        let err = apply_clean_filter_with_attributes_cow_safecrlf(
1534            &config,
1535            &checks,
1536            b"f.txt",
1537            b"a\nb\n",
1538            ConvFlags::Die,
1539            SafeCrlfIndexBlob::None,
1540        )
1541        .expect_err("die must error");
1542        assert!(matches!(err, GitError::Exit(128)));
1543    }
1544
1545    #[test]
1546    fn safecrlf_die_errors_on_crlf_to_lf_round_trip() {
1547        // autocrlf=input on a CRLF file: clean strips CRLF and checkout never
1548        // restores it, so safecrlf=true dies.
1549        let config = config_from("[core]\n\tautocrlf = input\n");
1550        let checks: Vec<AttributeCheck> = Vec::new();
1551        let err = apply_clean_filter_with_attributes_cow_safecrlf(
1552            &config,
1553            &checks,
1554            b"f.txt",
1555            b"a\r\nb\r\n",
1556            ConvFlags::Die,
1557            SafeCrlfIndexBlob::None,
1558        )
1559        .expect_err("die must error");
1560        assert!(matches!(err, GitError::Exit(128)));
1561    }
1562
1563    #[test]
1564    fn safecrlf_reversible_round_trip_does_not_warn_or_die() {
1565        // A CRLF file under autocrlf=true survives the round trip (clean to LF,
1566        // smudge back to CRLF), so even safecrlf=true is silent.
1567        let config = config_from("[core]\n\tautocrlf = true\n");
1568        let checks: Vec<AttributeCheck> = Vec::new();
1569        let out = apply_clean_filter_with_attributes_cow_safecrlf(
1570            &config,
1571            &checks,
1572            b"f.txt",
1573            b"a\r\nb\r\n",
1574            ConvFlags::Die,
1575            SafeCrlfIndexBlob::None,
1576        )
1577        .expect("reversible round trip must not die");
1578        assert_eq!(out.as_ref(), b"a\nb\n");
1579    }
1580
1581    #[test]
1582    fn safecrlf_binary_content_is_silent() {
1583        // autocrlf=true with NUL-containing (binary) content: no conversion and
1584        // no warning/die, mirroring git's early-return in crlf_to_git.
1585        let config = config_from("[core]\n\tautocrlf = true\n");
1586        let checks: Vec<AttributeCheck> = Vec::new();
1587        let body: &[u8] = b"a\nb\0c\n";
1588        let out = apply_clean_filter_with_attributes_cow_safecrlf(
1589            &config,
1590            &checks,
1591            b"f.bin",
1592            body,
1593            ConvFlags::Die,
1594            SafeCrlfIndexBlob::None,
1595        )
1596        .expect("binary content must not die");
1597        assert_eq!(out.as_ref(), body, "binary content is never converted");
1598    }
1599
1600    #[test]
1601    fn safecrlf_off_is_silent_even_on_irreversible_round_trip() {
1602        let config = config_from("[core]\n\tautocrlf = true\n");
1603        let checks: Vec<AttributeCheck> = Vec::new();
1604        let out = apply_clean_filter_with_attributes_cow_safecrlf(
1605            &config,
1606            &checks,
1607            b"f.txt",
1608            b"a\nb\n",
1609            ConvFlags::Off,
1610            SafeCrlfIndexBlob::None,
1611        )
1612        .expect("safecrlf=off never errors");
1613        // autocrlf=true does not convert on clean (only smudge), so bytes pass through.
1614        assert_eq!(out.as_ref(), b"a\nb\n");
1615    }
1616
1617    #[test]
1618    fn autocrlf_input_normalizes_on_clean_but_not_smudge() {
1619        // autocrlf=input: clean normalizes to LF, smudge leaves LF as-is.
1620        let config = config_from("[core]\n\tautocrlf = input\n");
1621        let checks: Vec<AttributeCheck> = Vec::new();
1622        let blob = apply_clean_filter_with_attributes(&config, &checks, b"file.txt", b"a\r\nb\r\n")
1623            .expect("test operation should succeed");
1624        assert_eq!(blob, b"a\nb\n");
1625        let smudged = apply_smudge_filter_with_attributes(&config, &checks, b"file.txt", &blob)
1626            .expect("test operation should succeed");
1627        assert_eq!(
1628            smudged, b"a\nb\n",
1629            "input mode must not add carriage returns"
1630        );
1631    }
1632
1633    #[test]
1634    fn eol_crlf_attribute_drives_conversion_without_config() {
1635        // No core.autocrlf; the `eol=crlf` attribute alone forces conversion.
1636        let config = config_from("");
1637        let checks = vec![AttributeCheck {
1638            attribute: b"eol".to_vec(),
1639            state: Some(AttributeState::Value(b"crlf".to_vec())),
1640        }];
1641        let blob = apply_clean_filter_with_attributes(&config, &checks, b"a.txt", b"x\r\ny\r\n")
1642            .expect("test operation should succeed");
1643        assert_eq!(blob, b"x\ny\n");
1644        let smudged = apply_smudge_filter_with_attributes(&config, &checks, b"a.txt", &blob)
1645            .expect("test operation should succeed");
1646        assert_eq!(smudged, b"x\r\ny\r\n");
1647    }
1648
1649    #[test]
1650    fn binary_attribute_disables_eol_conversion() {
1651        // `-text` (binary) must leave CRLF/NUL content untouched in both
1652        // directions even when autocrlf=true.
1653        let config = config_from("[core]\n\tautocrlf = true\n");
1654        let checks = vec![AttributeCheck {
1655            attribute: b"text".to_vec(),
1656            state: Some(AttributeState::Unset),
1657        }];
1658        let content = b"\x00\x01\r\n\x02\r\n".to_vec();
1659        let blob = apply_clean_filter_with_attributes(&config, &checks, b"data.bin", &content)
1660            .expect("test operation should succeed");
1661        assert_eq!(blob, content, "binary file must not be CRLF-normalized");
1662        let smudged = apply_smudge_filter_with_attributes(&config, &checks, b"data.bin", &blob)
1663            .expect("test operation should succeed");
1664        assert_eq!(
1665            smudged, content,
1666            "binary file must not gain carriage returns"
1667        );
1668    }
1669
1670    #[test]
1671    fn autocrlf_auto_skips_binary_looking_content() {
1672        // text=auto (via autocrlf) must not convert content that contains NUL.
1673        let config = config_from("[core]\n\tautocrlf = true\n");
1674        let checks: Vec<AttributeCheck> = Vec::new();
1675        let content = b"a\r\n\x00b\r\n".to_vec();
1676        let blob = apply_clean_filter_with_attributes(&config, &checks, b"f", &content)
1677            .expect("test operation should succeed");
1678        assert_eq!(blob, content, "binary-looking content stays untouched");
1679    }
1680
1681    #[test]
1682    fn autocrlf_via_add_and_checkout_round_trips() {
1683        // End-to-end: a CRLF worktree file is stored as an LF blob by the
1684        // filtered add path, and restored as CRLF by the filtered checkout.
1685        let root = temp_root();
1686        let git_dir = root.join(".git");
1687        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
1688        let config = config_from("[core]\n\tautocrlf = true\n");
1689
1690        fs::write(root.join("crlf.txt"), b"alpha\r\nbeta\r\n")
1691            .expect("test operation should succeed");
1692        add_paths_to_index_filtered(
1693            &root,
1694            &git_dir,
1695            ObjectFormat::Sha1,
1696            &[PathBuf::from("crlf.txt")],
1697            &config,
1698        )
1699        .expect("test operation should succeed");
1700
1701        // The stored blob must be LF-normalized.
1702        let index = read_index(&git_dir);
1703        let entry = index_entry_for(&index, b"crlf.txt");
1704        let odb = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
1705        let blob = odb
1706            .read_object(&entry.oid)
1707            .expect("test operation should succeed");
1708        assert_eq!(blob.body, b"alpha\nbeta\n");
1709
1710        // Commit and point HEAD at it, then re-checkout with smudge filtering.
1711        let tree = write_tree_from_index(&git_dir, ObjectFormat::Sha1)
1712            .expect("test operation should succeed");
1713        let mut body = Vec::new();
1714        body.extend_from_slice(format!("tree {tree}\n").as_bytes());
1715        body.extend_from_slice(b"author T <t@e> 0 +0000\ncommitter T <t@e> 0 +0000\n\nm\n");
1716        let odb = FileObjectDatabase::from_git_dir(&git_dir, ObjectFormat::Sha1);
1717        let commit = odb
1718            .write_object(EncodedObject::new(ObjectType::Commit, body))
1719            .expect("test operation should succeed");
1720        let refs = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
1721        let mut tx = refs.transaction();
1722        tx.update(RefUpdate {
1723            name: "HEAD".into(),
1724            expected: None,
1725            new: RefTarget::Direct(commit),
1726            reflog: None,
1727        });
1728        tx.commit().expect("test operation should succeed");
1729
1730        // Make the worktree match the committed (LF) blob so the tree is clean
1731        // for checkout; `short_status`/`worktree_entries` compare by content
1732        // hash and are not filter-aware. Checkout will then smudge it to CRLF.
1733        fs::write(root.join("crlf.txt"), b"alpha\nbeta\n").expect("test operation should succeed");
1734        checkout_detached_filtered(
1735            &root,
1736            &git_dir,
1737            ObjectFormat::Sha1,
1738            &commit,
1739            b"T <t@e> 0 +0000".to_vec(),
1740            b"co".to_vec(),
1741            &config,
1742        )
1743        .expect("test operation should succeed");
1744        assert_eq!(
1745            fs::read(root.join("crlf.txt")).expect("test operation should succeed"),
1746            b"alpha\r\nbeta\r\n",
1747            "checkout must restore CRLF line endings"
1748        );
1749        fs::remove_dir_all(root).expect("test operation should succeed");
1750    }
1751
1752    #[test]
1753    fn driver_filter_clean_and_smudge_transform_both_directions() {
1754        // filter=case: clean upper-cases (worktree -> blob), smudge lower-cases
1755        // (blob -> worktree).
1756        let config =
1757            config_from("[filter \"case\"]\n\tclean = tr a-z A-Z\n\tsmudge = tr A-Z a-z\n");
1758        let checks = vec![AttributeCheck {
1759            attribute: b"filter".to_vec(),
1760            state: Some(AttributeState::Value(b"case".to_vec())),
1761        }];
1762        let blob = apply_clean_filter_with_attributes(&config, &checks, b"f.txt", b"Hello World")
1763            .expect("test operation should succeed");
1764        assert_eq!(blob, b"HELLO WORLD", "clean driver must upper-case");
1765        let worktree =
1766            apply_smudge_filter_with_attributes(&config, &checks, b"f.txt", b"HELLO WORLD")
1767                .expect("test operation should succeed");
1768        assert_eq!(worktree, b"hello world", "smudge driver must lower-case");
1769    }
1770
1771    #[test]
1772    fn driver_filter_resolved_from_gitattributes_file() {
1773        // The filter name is read from a real `.gitattributes`, the commands from
1774        // config; exercises the public worktree-rooted entry points.
1775        let root = temp_root();
1776        let git_dir = root.join(".git");
1777        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
1778        fs::write(root.join(".gitattributes"), b"*.dat filter=rot\n")
1779            .expect("test operation should succeed");
1780        let config =
1781            config_from("[filter \"rot\"]\n\tclean = sed s/a/b/g\n\tsmudge = sed s/b/a/g\n");
1782        // Clean reads attributes from the live worktree `.gitattributes`.
1783        let blob = apply_clean_filter(&root, &git_dir, &config, b"x.dat", b"banana")
1784            .expect("test operation should succeed");
1785        assert_eq!(blob, b"bbnbnb");
1786        // Smudge reads attributes from the index (the worktree file may not
1787        // exist yet during checkout), so stage `.gitattributes` first.
1788        add_paths_to_index(
1789            &root,
1790            &git_dir,
1791            ObjectFormat::Sha1,
1792            &[PathBuf::from(".gitattributes")],
1793        )
1794        .expect("test operation should succeed");
1795        let smudged = apply_smudge_filter(
1796            &root,
1797            &git_dir,
1798            ObjectFormat::Sha1,
1799            &config,
1800            b"x.dat",
1801            &blob,
1802        )
1803        .expect("test operation should succeed");
1804        // sed s/b/a/g is not a perfect inverse, but verifies the smudge command
1805        // ran on the blob bytes.
1806        assert_eq!(smudged, b"aanana");
1807        fs::remove_dir_all(root).expect("test operation should succeed");
1808    }
1809
1810    #[test]
1811    fn required_filter_failure_is_fatal() {
1812        // A required filter whose command fails must surface an error.
1813        let config = config_from("[filter \"boom\"]\n\tclean = false\n\trequired = true\n");
1814        let checks = vec![AttributeCheck {
1815            attribute: b"filter".to_vec(),
1816            state: Some(AttributeState::Value(b"boom".to_vec())),
1817        }];
1818        let err = apply_clean_filter_with_attributes(&config, &checks, b"f", b"data")
1819            .expect_err("required filter failure must error");
1820        assert!(matches!(err, GitError::Command(_)), "got {err:?}");
1821    }
1822
1823    #[test]
1824    fn required_filter_missing_command_is_fatal() {
1825        // required=true but no clean command for this direction is also fatal.
1826        let config = config_from("[filter \"need\"]\n\tsmudge = cat\n\trequired = true\n");
1827        let checks = vec![AttributeCheck {
1828            attribute: b"filter".to_vec(),
1829            state: Some(AttributeState::Value(b"need".to_vec())),
1830        }];
1831        let err = apply_clean_filter_with_attributes(&config, &checks, b"f", b"data")
1832            .expect_err("required filter without a clean command must error");
1833        assert!(matches!(err, GitError::Exit(128)), "got {err:?}");
1834    }
1835
1836    #[test]
1837    fn non_required_filter_failure_passes_through() {
1838        // A non-required filter that fails must pass the content through
1839        // unchanged rather than erroring.
1840        let config = config_from("[filter \"opt\"]\n\tclean = false\n");
1841        let checks = vec![AttributeCheck {
1842            attribute: b"filter".to_vec(),
1843            state: Some(AttributeState::Value(b"opt".to_vec())),
1844        }];
1845        let out = apply_clean_filter_with_attributes(&config, &checks, b"f", b"keepme")
1846            .expect("test operation should succeed");
1847        assert_eq!(
1848            out, b"keepme",
1849            "optional filter failure passes content through"
1850        );
1851    }
1852
1853    #[test]
1854    fn filter_with_no_command_is_noop() {
1855        // filter=name with no configured commands and not required is ignored.
1856        let config = config_from("");
1857        let checks = vec![AttributeCheck {
1858            attribute: b"filter".to_vec(),
1859            state: Some(AttributeState::Value(b"ghost".to_vec())),
1860        }];
1861        let out = apply_clean_filter_with_attributes(&config, &checks, b"f", b"unchanged")
1862            .expect("test operation should succeed");
1863        assert_eq!(out, b"unchanged");
1864    }
1865
1866    #[test]
1867    fn driver_and_eol_compose_on_clean_and_smudge() {
1868        // filter=case + autocrlf=true: clean runs the driver then CRLF->LF;
1869        // smudge runs LF->CRLF then the driver.
1870        let config = config_from(
1871            "[core]\n\tautocrlf = true\n[filter \"case\"]\n\tclean = tr a-z A-Z\n\tsmudge = tr A-Z a-z\n",
1872        );
1873        let checks = vec![
1874            AttributeCheck {
1875                attribute: b"filter".to_vec(),
1876                state: Some(AttributeState::Value(b"case".to_vec())),
1877            },
1878            AttributeCheck {
1879                attribute: b"text".to_vec(),
1880                state: Some(AttributeState::Set),
1881            },
1882        ];
1883        let blob = apply_clean_filter_with_attributes(&config, &checks, b"f.txt", b"ab\r\ncd\r\n")
1884            .expect("test operation should succeed");
1885        assert_eq!(blob, b"AB\nCD\n", "clean: upper-case then CRLF->LF");
1886        let worktree = apply_smudge_filter_with_attributes(&config, &checks, b"f.txt", &blob)
1887            .expect("test operation should succeed");
1888        assert_eq!(
1889            worktree, b"ab\r\ncd\r\n",
1890            "smudge: LF->CRLF then lower-case"
1891        );
1892    }
1893
1894    #[test]
1895    fn attrs_helper_reads_filter_from_disk() {
1896        let root = temp_root();
1897        fs::write(root.join(".gitattributes"), b"*.txt text\n*.bin -text\n")
1898            .expect("test operation should succeed");
1899        let text = attrs(&root, b"a.txt");
1900        assert!(
1901            text.iter()
1902                .any(|c| c.attribute == b"text" && c.state == Some(AttributeState::Set))
1903        );
1904        let bin = attrs(&root, b"a.bin");
1905        assert!(
1906            bin.iter()
1907                .any(|c| c.attribute == b"text" && c.state == Some(AttributeState::Unset))
1908        );
1909        fs::remove_dir_all(root).expect("test operation should succeed");
1910    }
1911
1912    /// Builds a stat cache holding a single stage-0 entry whose size+mtime match
1913    /// `file`'s real metadata, with the index-file mtime placed strictly after
1914    /// the entry mtime so the entry reads as non-racy by default. The entry's oid
1915    /// is `oid` and its mode is `mode`.
1916    fn stat_cache_for(file: &Path, oid: ObjectId, mode: u32) -> (IndexStatCache, IndexEntry) {
1917        let metadata = fs::metadata(file).expect("test operation should succeed");
1918        let mut entry = index_entry_from_metadata(b"f.txt".to_vec(), oid, &metadata);
1919        entry.mode = mode;
1920        let index_mtime = Some((u64::from(entry.mtime_seconds) + 10, 0));
1921        let mut entries = HashMap::new();
1922        entries.insert(entry.path.as_bytes().to_vec(), entry.clone());
1923        (
1924            IndexStatCache {
1925                entries,
1926                index_mtime,
1927            },
1928            entry,
1929        )
1930    }
1931
1932    #[test]
1933    fn reuse_tracked_entry_only_reuses_clean_non_racy_match() {
1934        let root = temp_root();
1935        fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
1936        let file = root.join("f.txt");
1937        let metadata = fs::metadata(&file).expect("test operation should succeed");
1938        let real_mode = file_mode(&metadata);
1939        let oid = EncodedObject::new(ObjectType::Blob, b"hello\n".to_vec())
1940            .object_id(ObjectFormat::Sha1)
1941            .expect("test operation should succeed");
1942
1943        // Clean, non-racy, matching stat + mode -> reuse the cached oid.
1944        let (cache, _) = stat_cache_for(&file, oid, real_mode);
1945        let reused = cache.reuse_tracked_entry(b"f.txt", &metadata);
1946        assert_eq!(
1947            reused,
1948            Some(TrackedEntry {
1949                mode: real_mode,
1950                oid,
1951            }),
1952            "a clean non-racy stat+mode match must reuse the staged oid"
1953        );
1954
1955        // No stage-0 entry for the path -> must hash.
1956        assert_eq!(
1957            cache.reuse_tracked_entry(b"other.txt", &metadata),
1958            None,
1959            "a path with no cached entry must fall through to hashing"
1960        );
1961
1962        // Size differs from the file -> must hash.
1963        let (mut size_cache, mut shrunk) = stat_cache_for(&file, oid, real_mode);
1964        shrunk.size = shrunk.size.saturating_sub(1);
1965        size_cache.entries.insert(shrunk.path.to_vec(), shrunk);
1966        assert_eq!(
1967            size_cache.reuse_tracked_entry(b"f.txt", &metadata),
1968            None,
1969            "a size mismatch must fall through to hashing"
1970        );
1971
1972        // Mode differs (e.g. a chmod that did not move mtime) -> must hash.
1973        let (mode_cache, _) = stat_cache_for(&file, oid, 0o100755);
1974        assert_eq!(
1975            mode_cache.reuse_tracked_entry(b"f.txt", &metadata),
1976            None,
1977            "a mode mismatch must fall through to hashing"
1978        );
1979
1980        // Racily clean (index mtime not strictly after the entry mtime) -> hash.
1981        let (mut racy_cache, entry) = stat_cache_for(&file, oid, real_mode);
1982        racy_cache.index_mtime = Some((
1983            u64::from(entry.mtime_seconds),
1984            u64::from(entry.mtime_nanoseconds),
1985        ));
1986        assert_eq!(
1987            racy_cache.reuse_tracked_entry(b"f.txt", &metadata),
1988            None,
1989            "a racily-clean entry must always be re-hashed"
1990        );
1991
1992        // Unknown index mtime is treated as racy -> hash.
1993        let (mut unknown_cache, _) = stat_cache_for(
1994            &file,
1995            EncodedObject::new(ObjectType::Blob, b"hello\n".to_vec())
1996                .object_id(ObjectFormat::Sha1)
1997                .expect("test operation should succeed"),
1998            real_mode,
1999        );
2000        unknown_cache.index_mtime = None;
2001        assert_eq!(
2002            unknown_cache.reuse_tracked_entry(b"f.txt", &metadata),
2003            None,
2004            "an unknown index mtime must be treated conservatively as racy"
2005        );
2006
2007        fs::remove_dir_all(root).expect("test operation should succeed");
2008    }
2009
2010    #[test]
2011    fn index_stat_probe_cache_serves_many_paths_from_one_index_parse() {
2012        let root = temp_root();
2013        let git_dir = root.join(".git");
2014        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
2015        fs::write(root.join("a.txt"), b"alpha\n").expect("test operation should succeed");
2016        fs::write(root.join("b.txt"), b"bravo\n").expect("test operation should succeed");
2017        build_commit(&root, &git_dir, &["a.txt", "b.txt"]);
2018
2019        let cache = IndexStatProbeCache::from_repository_index(&git_dir, ObjectFormat::Sha1)
2020            .expect("probe cache");
2021        assert_eq!(cache.len(), 2);
2022        assert!(cache.contains_git_path(b"a.txt"));
2023        assert!(cache.contains_git_path(b"b.txt"));
2024        let a = cache.probe_for_git_path(b"a.txt").expect("a probe");
2025        let b = cache.probe_for_git_path(b"b.txt").expect("b probe");
2026        assert_eq!(a.entry().path, b"a.txt");
2027        assert_eq!(b.entry().path, b"b.txt");
2028        assert_eq!(a.index_mtime(), cache.index_mtime());
2029        assert_eq!(b.index_mtime(), cache.index_mtime());
2030        assert!(
2031            cache.probe_for_git_path(b"missing.txt").is_none(),
2032            "missing paths should not allocate probes"
2033        );
2034
2035        let one_shot =
2036            IndexStatProbe::from_repository_index(&git_dir, ObjectFormat::Sha1, b"a.txt")
2037                .expect("legacy one-shot probe")
2038                .expect("a probe");
2039        assert_eq!(one_shot.entry().path, b"a.txt");
2040        assert_eq!(one_shot.index_mtime(), cache.index_mtime());
2041
2042        fs::remove_dir_all(root).expect("test operation should succeed");
2043    }
2044
2045    #[test]
2046    fn short_status_detects_same_length_content_change() {
2047        let root = temp_root();
2048        let git_dir = root.join(".git");
2049        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
2050        fs::write(root.join("f.txt"), b"aaaa\n").expect("test operation should succeed");
2051        build_commit(&root, &git_dir, &["f.txt"]);
2052        // Overwrite with the SAME byte length but different content. Right after
2053        // staging the entry is racily clean (index mtime >= entry mtime), so the
2054        // stat shortcut must not be trusted and the change must surface as M.
2055        fs::write(root.join("f.txt"), b"bbbb\n").expect("test operation should succeed");
2056        let status = short_status(&root, &git_dir, ObjectFormat::Sha1)
2057            .expect("test operation should succeed");
2058        assert_eq!(
2059            status
2060                .iter()
2061                .map(ShortStatusEntry::line)
2062                .collect::<Vec<_>>(),
2063            vec![" M f.txt"],
2064            "a same-length content change must be reported modified"
2065        );
2066        fs::remove_dir_all(root).expect("test operation should succeed");
2067    }
2068
2069    #[test]
2070    fn short_status_clean_after_byte_identical_rewrite() {
2071        let root = temp_root();
2072        let git_dir = root.join(".git");
2073        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
2074        fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
2075        build_commit(&root, &git_dir, &["f.txt"]);
2076        // Rewrite with byte-identical content; the mtime moves so the stat
2077        // shortcut declines to reuse and the fallback hash proves it clean.
2078        std::thread::sleep(std::time::Duration::from_millis(20));
2079        fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
2080        let status = short_status(&root, &git_dir, ObjectFormat::Sha1)
2081            .expect("test operation should succeed");
2082        assert!(
2083            status.is_empty(),
2084            "a byte-identical rewrite must be clean via the fallback hash, got {status:?}"
2085        );
2086        fs::remove_dir_all(root).expect("test operation should succeed");
2087    }
2088
2089    #[test]
2090    fn short_status_trusts_stat_cache_and_skips_rehash() {
2091        let root = temp_root();
2092        let git_dir = root.join(".git");
2093        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
2094        fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
2095        build_commit(&root, &git_dir, &["f.txt"]);
2096
2097        // Plant a BOGUS oid in the stage-0 entry while preserving its size+mtime,
2098        // so a real re-hash of the (unchanged) worktree file would NOT match it.
2099        let index_path = repository_index_path(&git_dir);
2100        let mut index = read_index(&git_dir);
2101        let bogus = ObjectId::from_hex(ObjectFormat::Sha1, &"0".repeat(40))
2102            .expect("test operation should succeed");
2103        let real_oid = index_entry_for(&index, b"f.txt").oid;
2104        assert_ne!(
2105            real_oid, bogus,
2106            "fixture oid should differ from the bogus oid"
2107        );
2108        index
2109            .entries
2110            .iter_mut()
2111            .find(|entry| entry.path == b"f.txt")
2112            .expect("test operation should succeed")
2113            .oid = bogus.clone();
2114        fs::write(
2115            &index_path,
2116            index
2117                .write(ObjectFormat::Sha1)
2118                .expect("test operation should succeed"),
2119        )
2120        .expect("test operation should succeed");
2121
2122        // Make the index file STRICTLY newer than the entry mtime (non-racy) by
2123        // waiting past one-second filesystem granularity and rewriting it, so the
2124        // racy-clean guard does not force a re-hash.
2125        std::thread::sleep(std::time::Duration::from_millis(1100));
2126        fs::write(
2127            &index_path,
2128            fs::read(&index_path).expect("test operation should succeed"),
2129        )
2130        .expect("test operation should succeed");
2131
2132        // The file is unchanged on disk, so a trusted stat reuses the bogus index
2133        // oid for the worktree entry: worktree-oid == index-oid == bogus, so the
2134        // WORKTREE column is clean. Had status re-hashed the file, the real oid
2135        // would differ from the bogus index oid and the worktree column would be
2136        // 'M'. (The index-vs-HEAD column is 'M' because we corrupted the index
2137        // oid away from HEAD; that is expected and not what this test asserts.)
2138        let status = short_status(&root, &git_dir, ObjectFormat::Sha1)
2139            .expect("test operation should succeed");
2140        let entry = status
2141            .iter()
2142            .find(|entry| entry.path == b"f.txt")
2143            .expect("f.txt should appear (its index oid now differs from HEAD)");
2144        assert_eq!(
2145            entry.worktree, b' ',
2146            "non-racy stat match must trust the cached oid (no re-hash); worktree column was {}",
2147            entry.worktree as char
2148        );
2149        assert_eq!(
2150            entry.index_oid.as_ref(),
2151            Some(&bogus),
2152            "the worktree entry must have reused the planted bogus index oid, not the real hash"
2153        );
2154
2155        fs::remove_dir_all(root).expect("test operation should succeed");
2156    }
2157
2158    #[test]
2159    fn worktree_entry_state_detects_same_size_content_change() {
2160        let root = temp_root();
2161        let git_dir = root.join(".git");
2162        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
2163        fs::write(root.join("f.txt"), b"aaaa\n").expect("test operation should succeed");
2164        build_commit(&root, &git_dir, &["f.txt"]);
2165        let index = read_index(&git_dir);
2166        let entry = index_entry_for(&index, b"f.txt").clone();
2167        let probe = IndexStatProbe::from_index_entry_and_index_path(
2168            entry.clone(),
2169            repository_index_path(&git_dir),
2170        );
2171
2172        fs::write(root.join("f.txt"), b"bbbb\n").expect("test operation should succeed");
2173        let state = worktree_entry_state(
2174            &root,
2175            &git_dir,
2176            ObjectFormat::Sha1,
2177            Path::new("f.txt"),
2178            &entry.oid,
2179            entry.mode,
2180            Some(&probe),
2181        )
2182        .expect("test operation should succeed");
2183        assert_eq!(state, WorktreeEntryState::Modified);
2184
2185        fs::remove_dir_all(root).expect("test operation should succeed");
2186    }
2187
2188    #[test]
2189    fn worktree_entry_state_reports_deleted_for_missing_and_parent_not_directory() {
2190        let root = temp_root();
2191        let git_dir = root.join(".git");
2192        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
2193        fs::create_dir_all(root.join("dir")).expect("test operation should succeed");
2194        fs::write(root.join("dir").join("f.txt"), b"hello\n")
2195            .expect("test operation should succeed");
2196        build_commit(&root, &git_dir, &["dir/f.txt"]);
2197        let index = read_index(&git_dir);
2198        let entry = index_entry_for(&index, b"dir/f.txt").clone();
2199
2200        fs::remove_file(root.join("dir").join("f.txt")).expect("test operation should succeed");
2201        let missing = worktree_entry_state_by_git_path(
2202            &root,
2203            &git_dir,
2204            ObjectFormat::Sha1,
2205            b"dir/f.txt",
2206            &entry.oid,
2207            entry.mode,
2208            None,
2209        )
2210        .expect("test operation should succeed");
2211        assert_eq!(missing, WorktreeEntryState::Deleted);
2212
2213        fs::remove_dir(root.join("dir")).expect("test operation should succeed");
2214        fs::write(root.join("dir"), b"not a directory").expect("test operation should succeed");
2215        let parent_not_directory = worktree_entry_state_by_git_path(
2216            &root,
2217            &git_dir,
2218            ObjectFormat::Sha1,
2219            b"dir/f.txt",
2220            &entry.oid,
2221            entry.mode,
2222            None,
2223        )
2224        .expect("test operation should succeed");
2225        assert_eq!(parent_not_directory, WorktreeEntryState::Deleted);
2226
2227        fs::remove_dir_all(root).expect("test operation should succeed");
2228    }
2229
2230    #[test]
2231    fn worktree_entry_state_trusts_clean_non_racy_probe() {
2232        let root = temp_root();
2233        let git_dir = root.join(".git");
2234        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
2235        fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
2236        build_commit(&root, &git_dir, &["f.txt"]);
2237        let index_path = repository_index_path(&git_dir);
2238        let mut index = read_index(&git_dir);
2239        let bogus = ObjectId::from_hex(ObjectFormat::Sha1, &"1".repeat(40))
2240            .expect("test operation should succeed");
2241        index
2242            .entries
2243            .iter_mut()
2244            .find(|entry| entry.path == b"f.txt")
2245            .expect("test operation should succeed")
2246            .oid = bogus;
2247        fs::write(
2248            &index_path,
2249            index
2250                .write(ObjectFormat::Sha1)
2251                .expect("test operation should succeed"),
2252        )
2253        .expect("test operation should succeed");
2254        std::thread::sleep(std::time::Duration::from_millis(1100));
2255        fs::write(
2256            &index_path,
2257            fs::read(&index_path).expect("test operation should succeed"),
2258        )
2259        .expect("test operation should succeed");
2260        let index = read_index(&git_dir);
2261        let entry = index_entry_for(&index, b"f.txt").clone();
2262        let probe = IndexStatProbe::from_index_entry_and_index_path(
2263            entry.clone(),
2264            repository_index_path(&git_dir),
2265        );
2266
2267        let state = worktree_entry_state(
2268            &root,
2269            &git_dir,
2270            ObjectFormat::Sha1,
2271            Path::new("f.txt"),
2272            &entry.oid,
2273            entry.mode,
2274            Some(&probe),
2275        )
2276        .expect("test operation should succeed");
2277        assert_eq!(
2278            state,
2279            WorktreeEntryState::Clean,
2280            "a non-racy stat match must be enough to prove this path clean"
2281        );
2282
2283        fs::remove_dir_all(root).expect("test operation should succeed");
2284    }
2285
2286    #[test]
2287    fn worktree_entry_state_rehashes_racy_probe() {
2288        let root = temp_root();
2289        let git_dir = root.join(".git");
2290        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
2291        fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
2292        build_commit(&root, &git_dir, &["f.txt"]);
2293        let index = read_index(&git_dir);
2294        let mut entry = index_entry_for(&index, b"f.txt").clone();
2295        entry.oid = ObjectId::from_hex(ObjectFormat::Sha1, &"2".repeat(40))
2296            .expect("test operation should succeed");
2297        let probe = IndexStatProbe::from_index_entry(
2298            entry.clone(),
2299            Some((
2300                u64::from(entry.mtime_seconds),
2301                u64::from(entry.mtime_nanoseconds),
2302            )),
2303        );
2304
2305        let state = worktree_entry_state(
2306            &root,
2307            &git_dir,
2308            ObjectFormat::Sha1,
2309            Path::new("f.txt"),
2310            &entry.oid,
2311            entry.mode,
2312            Some(&probe),
2313        )
2314        .expect("test operation should succeed");
2315        assert_eq!(
2316            state,
2317            WorktreeEntryState::Modified,
2318            "a racily-clean stat match must fall through to hashing"
2319        );
2320
2321        fs::remove_dir_all(root).expect("test operation should succeed");
2322    }
2323
2324    #[cfg(unix)]
2325    #[test]
2326    fn worktree_entry_state_detects_chmod_only_change() {
2327        use std::os::unix::fs::PermissionsExt;
2328
2329        let root = temp_root();
2330        let git_dir = root.join(".git");
2331        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
2332        fs::write(root.join("f.txt"), b"hello\n").expect("test operation should succeed");
2333        build_commit(&root, &git_dir, &["f.txt"]);
2334        let index = read_index(&git_dir);
2335        let entry = index_entry_for(&index, b"f.txt").clone();
2336
2337        let file = root.join("f.txt");
2338        let mut permissions = fs::metadata(&file)
2339            .expect("test operation should succeed")
2340            .permissions();
2341        permissions.set_mode(permissions.mode() | 0o111);
2342        fs::set_permissions(&file, permissions).expect("test operation should succeed");
2343        let state = worktree_entry_state(
2344            &root,
2345            &git_dir,
2346            ObjectFormat::Sha1,
2347            Path::new("f.txt"),
2348            &entry.oid,
2349            entry.mode,
2350            None,
2351        )
2352        .expect("test operation should succeed");
2353        assert_eq!(state, WorktreeEntryState::Modified);
2354
2355        fs::remove_dir_all(root).expect("test operation should succeed");
2356    }
2357
2358    #[cfg(unix)]
2359    #[test]
2360    fn worktree_entry_state_detects_symlink_target_change() {
2361        use std::os::unix::fs::symlink;
2362
2363        let root = temp_root();
2364        let git_dir = root.join(".git");
2365        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
2366        symlink("one", root.join("link")).expect("test operation should succeed");
2367        build_commit(&root, &git_dir, &["link"]);
2368        let index = read_index(&git_dir);
2369        let entry = index_entry_for(&index, b"link").clone();
2370
2371        fs::remove_file(root.join("link")).expect("test operation should succeed");
2372        symlink("two", root.join("link")).expect("test operation should succeed");
2373        let state = worktree_entry_state(
2374            &root,
2375            &git_dir,
2376            ObjectFormat::Sha1,
2377            Path::new("link"),
2378            &entry.oid,
2379            entry.mode,
2380            None,
2381        )
2382        .expect("test operation should succeed");
2383        assert_eq!(state, WorktreeEntryState::Modified);
2384
2385        fs::remove_dir_all(root).expect("test operation should succeed");
2386    }
2387
2388    #[test]
2389    fn worktree_entry_state_treats_present_unpopulated_gitlink_directory_as_clean() {
2390        let root = temp_root();
2391        let git_dir = root.join(".git");
2392        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
2393        fs::create_dir_all(root.join("submodule")).expect("test operation should succeed");
2394        let oid = ObjectId::from_hex(ObjectFormat::Sha1, &"3".repeat(40))
2395            .expect("test operation should succeed");
2396
2397        let state = worktree_entry_state(
2398            &root,
2399            &git_dir,
2400            ObjectFormat::Sha1,
2401            Path::new("submodule"),
2402            &oid,
2403            sley_index::GITLINK_MODE,
2404            None,
2405        )
2406        .expect("test operation should succeed");
2407        assert_eq!(state, WorktreeEntryState::Clean);
2408
2409        fs::remove_dir_all(root).expect("test operation should succeed");
2410    }
2411
2412    #[test]
2413    fn short_status_empty_on_unborn_repository() {
2414        let root = temp_root();
2415        let git_dir = root.join(".git");
2416        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
2417        fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n")
2418            .expect("test operation should succeed");
2419        let status = short_status(&root, &git_dir, ObjectFormat::Sha1)
2420            .expect("test operation should succeed");
2421        assert!(
2422            status.is_empty(),
2423            "an unborn repository with an empty worktree must be clean, got {status:?}"
2424        );
2425        fs::remove_dir_all(root).expect("test operation should succeed");
2426    }
2427
2428    #[test]
2429    fn untracked_paths_skips_embedded_git_internals() {
2430        let root = temp_root();
2431        let git_dir = root.join(".git");
2432        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
2433        fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n")
2434            .expect("test operation should succeed");
2435        let nested = root.join("not-a-submodule");
2436        fs::create_dir_all(nested.join(".git")).expect("test operation should succeed");
2437        fs::write(nested.join(".git/HEAD"), "ref: refs/heads/main\n")
2438            .expect("test operation should succeed");
2439        fs::write(nested.join("file.txt"), b"inside\n").expect("test operation should succeed");
2440        let paths = untracked_paths(&root, &git_dir, ObjectFormat::Sha1)
2441            .expect("test operation should succeed");
2442        assert!(
2443            paths.iter().any(|path| path == b"not-a-submodule/"),
2444            "embedded repository directory should be listed, got {paths:?}"
2445        );
2446        assert!(
2447            !paths
2448                .iter()
2449                .any(|path| path.starts_with(b"not-a-submodule/.git")),
2450            "embedded .git internals must not be listed, got {paths:?}"
2451        );
2452        fs::remove_dir_all(root).expect("test operation should succeed");
2453    }
2454
2455    #[cfg(unix)]
2456    #[test]
2457    fn untracked_paths_lists_symlink() {
2458        use std::os::unix::fs::symlink;
2459
2460        let root = temp_root();
2461        let git_dir = root.join(".git");
2462        fs::create_dir_all(git_dir.join("objects")).expect("test operation should succeed");
2463        fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n")
2464            .expect("test operation should succeed");
2465        fs::write(root.join("target.txt"), b"target\n").expect("test operation should succeed");
2466        symlink(root.join("target.txt"), root.join("path1")).expect("create symlink");
2467        let paths = untracked_paths(&root, &git_dir, ObjectFormat::Sha1)
2468            .expect("test operation should succeed");
2469        assert!(
2470            paths.contains(&b"path1".to_vec()),
2471            "untracked symlink must be listed, got {paths:?}"
2472        );
2473        fs::remove_dir_all(root).expect("test operation should succeed");
2474    }
2475}