Skip to main content

sley_worktree/
lib.rs

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