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