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