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