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