1use std::path::{Path, PathBuf};
4use std::process::{Command, Output, Stdio};
5use std::sync::OnceLock;
6
7use fallow_types::{
8 output_dead_code::{
9 CircularDependencyFinding, DuplicateExportFinding, DuplicatePropShapeFinding,
10 PropDrillingChainFinding, ReExportCycleFinding, UnlistedDependencyFinding,
11 },
12 results::{AnalysisResults, SecurityFinding},
13};
14use rustc_hash::FxHashSet;
15
16use crate::duplicates::{self, DuplicationReport};
17
18pub use crate::git_env::{AMBIENT_GIT_ENV_VARS, clear_ambient_git_env};
19
20pub type ChangedFilesSpawnHook = fn(&mut std::process::Command) -> std::io::Result<Output>;
23
24static SPAWN_HOOK: OnceLock<ChangedFilesSpawnHook> = OnceLock::new();
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum ChangedFilesError {
29 InvalidRef(String),
31 GitMissing(String),
33 NotARepository,
35 GitFailed(String),
37}
38
39impl ChangedFilesError {
40 #[must_use]
42 pub fn describe(&self) -> String {
43 match self {
44 Self::InvalidRef(err) => format!("invalid git ref: {err}"),
45 Self::GitMissing(err) => format!("failed to run git: {err}"),
46 Self::NotARepository => "not a git repository".to_owned(),
47 Self::GitFailed(stderr) => augment_git_failed(stderr),
48 }
49 }
50
51 #[must_use]
57 pub const fn reason(&self) -> &'static str {
58 match self {
59 Self::InvalidRef(_) => "invalid-ref",
60 Self::GitMissing(_) => "git-missing",
61 Self::NotARepository => "not-a-repository",
62 Self::GitFailed(_) => "git-failed",
63 }
64 }
65
66 #[must_use]
77 pub fn changed_since_message(&self, git_ref: &str) -> String {
78 let cause = self
79 .describe()
80 .split_whitespace()
81 .collect::<Vec<_>>()
82 .join(" ");
83 format!(
84 "--changed-since '{git_ref}' was ignored because {cause}, so this report covers \
85 the whole project instead of the changed files. {}",
86 self.changed_since_remedy()
87 )
88 }
89
90 const fn changed_since_remedy(&self) -> &'static str {
92 match self {
93 Self::InvalidRef(_) => {
94 "Pass a ref git can resolve, such as a branch name or a commit sha."
95 }
96 Self::GitMissing(_) => {
97 "Install git and make it available on PATH, or drop --changed-since."
98 }
99 Self::NotARepository => {
100 "Run fallow from inside the repository, or drop --changed-since."
101 }
102 Self::GitFailed(_) => {
103 "Verify the ref exists in this repository, and check out with full history."
104 }
105 }
106 }
107}
108
109fn augment_git_failed(stderr: &str) -> String {
110 let lower = stderr.to_ascii_lowercase();
111 if lower.contains("not a valid object name")
112 || lower.contains("unknown revision")
113 || lower.contains("ambiguous argument")
114 {
115 format!(
116 "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
117 )
118 } else {
119 stderr.to_owned()
120 }
121}
122
123pub fn set_spawn_hook(hook: ChangedFilesSpawnHook) {
125 let _ = SPAWN_HOOK.set(hook);
126}
127
128pub(crate) fn validate_git_ref(s: &str) -> Result<&str, String> {
130 if s.is_empty() {
131 return Err("git ref cannot be empty".to_string());
132 }
133 if s.starts_with('-') {
134 return Err("git ref cannot start with '-'".to_string());
135 }
136 let mut in_braces = false;
137 for c in s.chars() {
138 match c {
139 '{' => in_braces = true,
140 '}' => in_braces = false,
141 ':' | ' ' if in_braces => {}
142 c if c.is_ascii_alphanumeric()
143 || matches!(c, '.' | '_' | '-' | '/' | '~' | '^' | '@' | '{' | '}') => {}
144 _ => return Err(format!("git ref contains disallowed character: '{c}'")),
145 }
146 }
147 if in_braces {
148 return Err("git ref has unclosed '{'".to_string());
149 }
150 Ok(s)
151}
152
153pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
155 let output = spawn_output(&mut git_command(cwd, &["rev-parse", "--show-toplevel"]))
156 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
157
158 if !output.status.success() {
159 let stderr = String::from_utf8_lossy(&output.stderr);
160 return Err(if stderr.contains("not a git repository") {
161 ChangedFilesError::NotARepository
162 } else {
163 ChangedFilesError::GitFailed(stderr.trim().to_owned())
164 });
165 }
166
167 let raw = String::from_utf8_lossy(&output.stdout);
168 let trimmed = raw.trim();
169 if trimmed.is_empty() {
170 return Err(ChangedFilesError::GitFailed(
171 "git rev-parse --show-toplevel returned empty output".to_owned(),
172 ));
173 }
174
175 let path = PathBuf::from(trimmed);
176 Ok(dunce::canonicalize(&path).unwrap_or(path))
177}
178
179pub fn resolve_git_common_dir(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
181 let output = spawn_output(&mut git_command(
182 cwd,
183 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
184 ))
185 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
186
187 if !output.status.success() {
188 let stderr = String::from_utf8_lossy(&output.stderr);
189 return Err(if stderr.contains("not a git repository") {
190 ChangedFilesError::NotARepository
191 } else {
192 ChangedFilesError::GitFailed(stderr.trim().to_owned())
193 });
194 }
195
196 let raw = String::from_utf8_lossy(&output.stdout);
197 let trimmed = raw.trim();
198 if trimmed.is_empty() {
199 return Err(ChangedFilesError::GitFailed(
200 "git rev-parse --git-common-dir returned empty output".to_owned(),
201 ));
202 }
203
204 let path = PathBuf::from(trimmed);
205 Ok(dunce::canonicalize(&path).unwrap_or(path))
206}
207
208pub fn resolve_git_common_dir_and_toplevel(
222 cwd: &Path,
223) -> Result<(PathBuf, PathBuf), ChangedFilesError> {
224 let output = spawn_output(&mut git_command(
225 cwd,
226 &[
227 "rev-parse",
228 "--path-format=absolute",
229 "--git-common-dir",
230 "--show-toplevel",
231 ],
232 ))
233 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
234
235 if !output.status.success() {
236 let stderr = String::from_utf8_lossy(&output.stderr);
237 return Err(if stderr.contains("not a git repository") {
238 ChangedFilesError::NotARepository
239 } else {
240 ChangedFilesError::GitFailed(stderr.trim().to_owned())
241 });
242 }
243
244 let raw = String::from_utf8_lossy(&output.stdout);
245 let mut lines = raw.lines();
246 let (Some(common), Some(toplevel), None) = (lines.next(), lines.next(), lines.next()) else {
247 return Err(ChangedFilesError::GitFailed(
248 "git rev-parse did not print one common dir and one toplevel".to_owned(),
249 ));
250 };
251 let canonical = |line: &str| {
252 let path = PathBuf::from(line.trim());
253 dunce::canonicalize(&path).unwrap_or(path)
254 };
255 if common.trim().is_empty() || toplevel.trim().is_empty() {
256 return Err(ChangedFilesError::GitFailed(
257 "git rev-parse returned an empty path".to_owned(),
258 ));
259 }
260 Ok((canonical(common), canonical(toplevel)))
261}
262
263fn try_get_changed_files(
265 root: &Path,
266 git_ref: &str,
267) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
268 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
269 let toplevel = resolve_git_toplevel(root)?;
270 try_get_changed_files_with_toplevel(root, &toplevel, git_ref)
271}
272
273pub fn changed_files(root: &Path, git_ref: &str) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
279 try_get_changed_files(root, git_ref)
280}
281
282pub fn try_get_changed_files_with_toplevel(
284 cwd: &Path,
285 toplevel: &Path,
286 git_ref: &str,
287) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
288 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
289
290 let mut files = collect_git_paths(
291 cwd,
292 toplevel,
293 &[
294 "diff",
295 "--name-only",
296 "-z",
297 "--end-of-options",
298 &format!("{git_ref}...HEAD"),
299 ],
300 )?;
301 files.extend(collect_git_paths(
302 cwd,
303 toplevel,
304 &["diff", "--name-only", "-z", "HEAD"],
305 )?);
306 files.extend(collect_git_paths(
307 cwd,
308 toplevel,
309 &[
310 "ls-files",
311 "--full-name",
312 "--others",
313 "--exclude-standard",
314 "-z",
315 ],
316 )?);
317 Ok(files)
318}
319
320#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct RenamedFile {
324 pub from: PathBuf,
326 pub to: PathBuf,
328}
329
330pub fn try_get_renamed_files(
342 root: &Path,
343 git_ref: &str,
344) -> Result<Vec<RenamedFile>, ChangedFilesError> {
345 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
346 let toplevel = resolve_git_toplevel(root)?;
347 let mut renames = collect_git_rename_pairs(
348 root,
349 &toplevel,
350 &[
351 "diff",
352 "--name-status",
353 "-z",
354 "--find-renames",
355 "--end-of-options",
356 &format!("{git_ref}...HEAD"),
357 ],
358 )?;
359 let staged = collect_git_rename_pairs(
360 root,
361 &toplevel,
362 &["diff", "--name-status", "-z", "--find-renames", "HEAD"],
363 )?;
364 for pair in staged {
365 if let Some(chained) = renames.iter_mut().find(|rename| rename.to == pair.from) {
366 chained.to = pair.to;
367 } else {
368 renames.push(pair);
369 }
370 }
371 Ok(renames)
372}
373
374fn collect_git_rename_pairs(
380 cwd: &Path,
381 toplevel: &Path,
382 args: &[&str],
383) -> Result<Vec<RenamedFile>, ChangedFilesError> {
384 let output = spawn_output(&mut git_command(cwd, args))
385 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
386
387 if !output.status.success() {
388 return Err(changed_files_error_from_output(&output));
389 }
390
391 let mut fields = output
392 .stdout
393 .split(|byte| *byte == 0)
394 .filter(|field| !field.is_empty());
395 let mut renames = Vec::new();
396 while let Some(status) = fields.next() {
397 let Some(first_path) = fields.next() else {
398 break;
399 };
400 match status.first() {
401 Some(b'R') => {
402 let Some(second_path) = fields.next() else {
403 break;
404 };
405 renames.push(RenamedFile {
406 from: toplevel.join(git_path_from_bytes(first_path)),
407 to: toplevel.join(git_path_from_bytes(second_path)),
408 });
409 }
410 Some(b'C') => {
413 let _ = fields.next();
414 }
415 _ => {}
416 }
417 }
418 Ok(renames)
419}
420
421pub fn try_get_changed_diff(root: &Path, git_ref: &str) -> Result<String, ChangedFilesError> {
426 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
427 let toplevel = resolve_git_toplevel(root)?;
428 let merge_base_output = spawn_output(&mut git_command(root, &["merge-base", git_ref, "HEAD"]))
429 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
430 if !merge_base_output.status.success() {
431 return Err(changed_files_error_from_output(&merge_base_output));
432 }
433 let merge_base = String::from_utf8_lossy(&merge_base_output.stdout)
434 .trim()
435 .to_owned();
436 if merge_base.is_empty() {
437 return Err(ChangedFilesError::GitFailed(
438 "git merge-base returned empty output".to_owned(),
439 ));
440 }
441
442 let output = spawn_output(&mut git_command(
443 root,
444 &[
445 "diff",
446 "--relative",
447 "--unified=0",
448 "--end-of-options",
449 &merge_base,
450 ],
451 ))
452 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
453
454 if !output.status.success() {
455 return Err(changed_files_error_from_output(&output));
456 }
457
458 let mut diff = String::from_utf8_lossy(&output.stdout).into_owned();
459 append_untracked_diffs(root, &toplevel, &mut diff)?;
460 Ok(diff)
461}
462
463fn append_untracked_diffs(
464 root: &Path,
465 toplevel: &Path,
466 diff: &mut String,
467) -> Result<(), ChangedFilesError> {
468 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
469 let mut untracked: Vec<PathBuf> = collect_git_paths(
470 root,
471 toplevel,
472 &[
473 "ls-files",
474 "--full-name",
475 "--others",
476 "--exclude-standard",
477 "-z",
478 ],
479 )?
480 .into_iter()
481 .filter_map(|path| {
482 path.strip_prefix(&canonical_root)
483 .ok()
484 .map(Path::to_path_buf)
485 })
486 .collect();
487 untracked.sort_unstable();
488
489 #[cfg(windows)]
490 let empty_file = "NUL";
491 #[cfg(not(windows))]
492 let empty_file = "/dev/null";
493
494 for path in untracked {
495 let mut command = git_command(root, &["diff", "--no-index", "--unified=0", "--"]);
496 command.arg(empty_file).arg(untracked_path_arg(&path));
497 let output =
498 spawn_output(&mut command).map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
499 if !output.status.success() && output.status.code() != Some(1) {
500 return Err(changed_files_error_from_output(&output));
501 }
502 if !diff.is_empty() && !diff.ends_with('\n') {
503 diff.push('\n');
504 }
505 diff.push_str(&String::from_utf8_lossy(&output.stdout));
506 }
507 Ok(())
508}
509
510fn untracked_path_arg(path: &Path) -> String {
515 path.to_string_lossy().replace('\\', "/")
516}
517
518fn changed_files_error_from_output(output: &Output) -> ChangedFilesError {
519 let stderr = String::from_utf8_lossy(&output.stderr);
520 if stderr.contains("not a git repository") {
521 ChangedFilesError::NotARepository
522 } else {
523 ChangedFilesError::GitFailed(stderr.trim().to_owned())
524 }
525}
526
527#[must_use]
529#[expect(
530 clippy::print_stderr,
531 reason = "intentional user-facing warning for the CLI's --changed-since fallback path; typed callers use try_get_changed_files instead"
532)]
533pub fn get_changed_files(root: &Path, git_ref: &str) -> Option<FxHashSet<PathBuf>> {
534 match try_get_changed_files(root, git_ref) {
535 Ok(files) => Some(files),
536 Err(err) => {
537 eprintln!("Warning: {}", err.changed_since_message(git_ref));
538 None
539 }
540 }
541}
542
543fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
544 if let Some(hook) = SPAWN_HOOK.get() {
545 hook(command)
546 } else {
547 command.output()
548 }
549}
550
551fn collect_git_paths(
552 cwd: &Path,
553 toplevel: &Path,
554 args: &[&str],
555) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
556 let output = spawn_output(&mut git_command(cwd, args))
557 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
558
559 if !output.status.success() {
560 let stderr = String::from_utf8_lossy(&output.stderr);
561 return Err(if stderr.contains("not a git repository") {
562 ChangedFilesError::NotARepository
563 } else {
564 ChangedFilesError::GitFailed(stderr.trim().to_owned())
565 });
566 }
567
568 let files = output
569 .stdout
570 .split(|byte| *byte == 0)
571 .filter(|path| !path.is_empty())
572 .map(git_path_from_bytes)
573 .map(|path| toplevel.join(path))
574 .collect();
575
576 Ok(files)
577}
578
579#[cfg(unix)]
585pub(crate) fn git_path_from_bytes(path: &[u8]) -> PathBuf {
586 use std::ffi::OsString;
587 use std::os::unix::ffi::OsStringExt;
588
589 PathBuf::from(OsString::from_vec(path.to_vec()))
590}
591
592#[cfg(windows)]
595pub(crate) fn git_path_from_bytes(path: &[u8]) -> PathBuf {
596 PathBuf::from(String::from_utf8_lossy(path).replace('/', "\\"))
597}
598
599#[expect(
600 clippy::disallowed_methods,
601 reason = "canonical engine-owned git spawn wrapper for changed-file orchestration"
602)]
603fn git_command(cwd: &Path, args: &[&str]) -> Command {
604 let mut command = Command::new("git");
605 clear_ambient_git_env(&mut command);
606 command.stdin(Stdio::null()).args(args).current_dir(cwd);
608 command
609}
610
611#[expect(
616 clippy::implicit_hasher,
617 reason = "fallow standardizes on FxHashSet across the workspace"
618)]
619pub fn filter_results_by_changed_files(
620 results: &mut AnalysisResults,
621 changed_files: &FxHashSet<PathBuf>,
622) {
623 let cf = normalize_changed_files_set(changed_files);
624 classify_changed_file_filter_fields(results);
625 retain_basic_issue_findings_by_changed_path(results, &cf);
626 retain_graph_findings_by_changed_files(results, &cf);
627 retain_boundary_policy_and_suppression_findings(results, &cf);
628 retain_security_and_workspace_findings(results, &cf);
629 retain_framework_findings_by_changed_files(results, &cf);
630}
631
632fn classify_changed_file_filter_fields(results: &AnalysisResults) {
633 let AnalysisResults {
634 unused_files: _unused_files,
635 unused_exports: _unused_exports,
636 unused_types: _unused_types,
637 private_type_leaks: _private_type_leaks,
638 deprecated_exports_in_use: _deprecated_exports_in_use,
639 unused_dependencies: _unused_dependencies,
640 unused_dev_dependencies: _unused_dev_dependencies,
641 unused_optional_dependencies: _unused_optional_dependencies,
642 unused_enum_members: _unused_enum_members,
643 unused_class_members: _unused_class_members,
644 unused_store_members: _unused_store_members,
645 unresolved_imports: _unresolved_imports,
646 unlisted_dependencies: _unlisted_dependencies,
647 duplicate_exports: _duplicate_exports,
648 type_only_dependencies: _type_only_dependencies,
649 test_only_dependencies: _test_only_dependencies,
650 dev_dependencies_in_production: _dev_dependencies_in_production,
651 circular_dependencies: _circular_dependencies,
652 re_export_cycles: _re_export_cycles,
653 boundary_violations: _boundary_violations,
654 boundary_coverage_violations: _boundary_coverage_violations,
655 boundary_call_violations: _boundary_call_violations,
656 policy_violations: _policy_violations,
657 stale_suppressions: _stale_suppressions,
658 unused_catalog_entries: _unused_catalog_entries,
659 empty_catalog_groups: _empty_catalog_groups,
660 unresolved_catalog_references: _unresolved_catalog_references,
661 unused_dependency_overrides: _unused_dependency_overrides,
662 misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
663 invalid_client_exports: _invalid_client_exports,
664 mixed_client_server_barrels: _mixed_client_server_barrels,
665 misplaced_directives: _misplaced_directives,
666 unprovided_injects: _unprovided_injects,
667 unrendered_components: _unrendered_components,
668 route_collisions: _route_collisions,
669 dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
670 unused_component_props: _unused_component_props,
671 unused_component_emits: _unused_component_emits,
672 unused_component_inputs: _unused_component_inputs,
673 unused_component_outputs: _unused_component_outputs,
674 unused_svelte_events: _unused_svelte_events,
675 unused_server_actions: _unused_server_actions,
676 unused_load_data_keys: _unused_load_data_keys,
677 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
678 prop_drilling_chains: _prop_drilling_chains,
679 thin_wrappers: _thin_wrappers,
680 duplicate_prop_shapes: _duplicate_prop_shapes,
681 suppression_count: _suppression_count,
682 unused_component_props_exempted: _unused_component_props_exempted,
683 active_suppressions: _active_suppressions,
684 feature_flags: _feature_flags,
685 security_findings: _security_findings,
686 security_unresolved_edge_files: _security_unresolved_edge_files,
687 security_unresolved_callee_sites: _security_unresolved_callee_sites,
688 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
689 export_usages: _export_usages,
690 entry_point_summary: _entry_point_summary,
691 render_fan_in: _render_fan_in,
692 react_component_intel: _react_component_intel,
693 semantic_framework_contracts: _semantic_framework_contracts,
694 } = results;
695}
696
697fn retain_basic_issue_findings_by_changed_path(
698 results: &mut AnalysisResults,
699 changed_files: &FxHashSet<PathBuf>,
700) {
701 retain_by_changed_path(&mut results.unused_files, changed_files, |f| &f.file.path);
702 retain_by_changed_path(&mut results.unused_exports, changed_files, |e| {
703 &e.export.path
704 });
705 retain_by_changed_path(&mut results.unused_types, changed_files, |e| &e.export.path);
706 retain_by_changed_path(&mut results.private_type_leaks, changed_files, |e| {
707 &e.leak.path
708 });
709 retain_by_changed_path(&mut results.deprecated_exports_in_use, changed_files, |e| {
710 &e.export.path
711 });
712 retain_by_changed_path(&mut results.unused_enum_members, changed_files, |m| {
713 &m.member.path
714 });
715 retain_by_changed_path(&mut results.unused_class_members, changed_files, |m| {
716 &m.member.path
717 });
718 retain_by_changed_path(&mut results.unused_store_members, changed_files, |m| {
719 &m.member.path
720 });
721 retain_by_changed_path(&mut results.unresolved_imports, changed_files, |i| {
722 &i.import.path
723 });
724}
725
726fn retain_graph_findings_by_changed_files(
727 results: &mut AnalysisResults,
728 changed_files: &FxHashSet<PathBuf>,
729) {
730 retain_unlisted_dependencies_by_import_site(&mut results.unlisted_dependencies, changed_files);
731 retain_duplicate_exports_by_changed_locations(&mut results.duplicate_exports, changed_files);
732 retain_circular_dependencies_by_changed_file(&mut results.circular_dependencies, changed_files);
733 retain_re_export_cycles_by_changed_file(&mut results.re_export_cycles, changed_files);
734}
735
736fn retain_boundary_policy_and_suppression_findings(
737 results: &mut AnalysisResults,
738 changed_files: &FxHashSet<PathBuf>,
739) {
740 retain_by_changed_path(&mut results.boundary_violations, changed_files, |v| {
741 &v.violation.from_path
742 });
743 retain_by_changed_path(
744 &mut results.boundary_coverage_violations,
745 changed_files,
746 |v| &v.violation.path,
747 );
748 retain_by_changed_path(&mut results.boundary_call_violations, changed_files, |v| {
749 &v.violation.path
750 });
751 retain_by_changed_path(&mut results.policy_violations, changed_files, |v| {
752 &v.violation.path
753 });
754 retain_by_changed_path(&mut results.stale_suppressions, changed_files, |s| &s.path);
755}
756
757fn retain_security_and_workspace_findings(
758 results: &mut AnalysisResults,
759 changed_files: &FxHashSet<PathBuf>,
760) {
761 retain_security_findings_by_changed_path(&mut results.security_findings, changed_files);
762 retain_by_changed_path(
763 &mut results.security_unresolved_callee_diagnostics,
764 changed_files,
765 |d| &d.path,
766 );
767 retain_by_changed_path(
768 &mut results.unresolved_catalog_references,
769 changed_files,
770 |r| &r.reference.path,
771 );
772 results
773 .empty_catalog_groups
774 .retain(|g| normalized_set_contains_path(changed_files, &g.group.path));
775 retain_by_changed_path(
776 &mut results.unused_dependency_overrides,
777 changed_files,
778 |o| &o.entry.path,
779 );
780 retain_by_changed_path(
781 &mut results.misconfigured_dependency_overrides,
782 changed_files,
783 |o| &o.entry.path,
784 );
785}
786
787fn retain_framework_findings_by_changed_files(
788 results: &mut AnalysisResults,
789 changed_files: &FxHashSet<PathBuf>,
790) {
791 retain_client_boundary_findings_by_changed_files(results, changed_files);
792 retain_component_contract_findings_by_changed_files(results, changed_files);
793 retain_react_health_findings_by_changed_files(results, changed_files);
794 retain_nextjs_findings_by_changed_files(results, changed_files);
795}
796
797fn retain_client_boundary_findings_by_changed_files(
798 results: &mut AnalysisResults,
799 changed_files: &FxHashSet<PathBuf>,
800) {
801 let AnalysisResults {
802 invalid_client_exports,
803 mixed_client_server_barrels,
804 misplaced_directives,
805 ..
806 } = results;
807
808 retain_by_changed_path(invalid_client_exports, changed_files, |e| &e.export.path);
809 retain_by_changed_path(mixed_client_server_barrels, changed_files, |b| {
810 &b.barrel.path
811 });
812 retain_by_changed_path(misplaced_directives, changed_files, |d| {
813 &d.directive_site.path
814 });
815}
816
817fn retain_component_contract_findings_by_changed_files(
818 results: &mut AnalysisResults,
819 changed_files: &FxHashSet<PathBuf>,
820) {
821 let AnalysisResults {
822 unprovided_injects,
823 unrendered_components,
824 unused_component_props,
825 unused_component_emits,
826 unused_component_inputs,
827 unused_component_outputs,
828 unused_svelte_events,
829 unused_server_actions,
830 unused_load_data_keys,
831 ..
832 } = results;
833
834 retain_by_changed_path(unprovided_injects, changed_files, |i| &i.inject.path);
835 retain_by_changed_path(unrendered_components, changed_files, |c| &c.component.path);
836 retain_by_changed_path(unused_component_props, changed_files, |p| &p.prop.path);
837 retain_by_changed_path(unused_component_emits, changed_files, |e| &e.emit.path);
838 retain_by_changed_path(unused_component_inputs, changed_files, |i| &i.input.path);
839 retain_by_changed_path(unused_component_outputs, changed_files, |o| &o.output.path);
840 retain_by_changed_path(unused_svelte_events, changed_files, |e| &e.event.path);
841 retain_by_changed_path(unused_server_actions, changed_files, |a| &a.action.path);
842 retain_by_changed_path(unused_load_data_keys, changed_files, |k| &k.key.path);
843}
844
845fn retain_react_health_findings_by_changed_files(
846 results: &mut AnalysisResults,
847 changed_files: &FxHashSet<PathBuf>,
848) {
849 let AnalysisResults {
850 prop_drilling_chains,
851 thin_wrappers,
852 duplicate_prop_shapes,
853 ..
854 } = results;
855
856 retain_prop_drilling_chains_by_anchor(prop_drilling_chains, changed_files);
857 retain_by_changed_path(thin_wrappers, changed_files, |w| &w.wrapper.file);
858 retain_duplicate_prop_shapes_by_anchor(duplicate_prop_shapes, changed_files);
859}
860
861fn retain_nextjs_findings_by_changed_files(
862 results: &mut AnalysisResults,
863 changed_files: &FxHashSet<PathBuf>,
864) {
865 let AnalysisResults {
866 route_collisions,
867 dynamic_segment_name_conflicts,
868 ..
869 } = results;
870
871 retain_by_changed_path(route_collisions, changed_files, |c| &c.collision.path);
872 retain_by_changed_path(dynamic_segment_name_conflicts, changed_files, |c| {
873 &c.conflict.path
874 });
875}
876
877fn retain_unlisted_dependencies_by_import_site(
878 dependencies: &mut Vec<UnlistedDependencyFinding>,
879 changed_files: &FxHashSet<PathBuf>,
880) {
881 dependencies.retain(|dependency| {
882 dependency
883 .dep
884 .imported_from
885 .iter()
886 .any(|site| contains_normalized(changed_files, &site.path))
887 });
888}
889
890fn retain_duplicate_exports_by_changed_locations(
891 duplicate_exports: &mut Vec<DuplicateExportFinding>,
892 changed_files: &FxHashSet<PathBuf>,
893) {
894 for duplicate in &mut *duplicate_exports {
895 duplicate
896 .export
897 .locations
898 .retain(|location| contains_normalized(changed_files, &location.path));
899 }
900 duplicate_exports.retain(|duplicate| duplicate.export.locations.len() >= 2);
901}
902
903fn retain_circular_dependencies_by_changed_file(
904 cycles: &mut Vec<CircularDependencyFinding>,
905 changed_files: &FxHashSet<PathBuf>,
906) {
907 cycles.retain(|cycle| {
908 cycle
909 .cycle
910 .files
911 .iter()
912 .any(|file| contains_normalized(changed_files, file))
913 });
914}
915
916fn retain_re_export_cycles_by_changed_file(
917 cycles: &mut Vec<ReExportCycleFinding>,
918 changed_files: &FxHashSet<PathBuf>,
919) {
920 cycles.retain(|cycle| {
921 cycle
922 .cycle
923 .files
924 .iter()
925 .any(|file| contains_normalized(changed_files, file))
926 });
927}
928
929fn retain_security_findings_by_changed_path(
930 findings: &mut Vec<SecurityFinding>,
931 changed_files: &FxHashSet<PathBuf>,
932) {
933 findings.retain(|finding| security_finding_touches_changed_path(finding, changed_files));
934}
935
936fn retain_prop_drilling_chains_by_anchor(
937 chains: &mut Vec<PropDrillingChainFinding>,
938 changed_files: &FxHashSet<PathBuf>,
939) {
940 chains.retain(|chain| {
941 chain
942 .chain
943 .hops
944 .first()
945 .is_some_and(|hop| contains_normalized(changed_files, &hop.file))
946 });
947}
948
949fn retain_duplicate_prop_shapes_by_anchor(
950 shapes: &mut Vec<DuplicatePropShapeFinding>,
951 changed_files: &FxHashSet<PathBuf>,
952) {
953 retain_by_changed_path(shapes, changed_files, |shape| &shape.shape.file);
954}
955
956fn retain_by_changed_path<T>(
957 items: &mut Vec<T>,
958 changed_files: &FxHashSet<PathBuf>,
959 path: impl Fn(&T) -> &Path,
960) {
961 items.retain(|item| contains_normalized(changed_files, path(item)));
962}
963
964fn security_finding_touches_changed_path(
965 finding: &SecurityFinding,
966 changed_files: &FxHashSet<PathBuf>,
967) -> bool {
968 contains_normalized(changed_files, &finding.path)
969 || finding
970 .trace
971 .iter()
972 .any(|hop| contains_normalized(changed_files, &hop.path))
973 || finding.reachability.as_ref().is_some_and(|reachability| {
974 reachability
975 .untrusted_source_trace
976 .iter()
977 .any(|hop| contains_normalized(changed_files, &hop.path))
978 })
979}
980
981fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
982 changed_files
983 .iter()
984 .map(|p| dunce::simplified(p).to_path_buf())
985 .collect()
986}
987
988fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
989 normalized.contains(dunce::simplified(path))
990}
991
992fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
993 contains_normalized(normalized, path)
994 || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
995}
996
997#[expect(
999 clippy::implicit_hasher,
1000 reason = "fallow standardizes on FxHashSet across the workspace"
1001)]
1002pub fn filter_duplication_by_changed_files(
1003 report: &mut DuplicationReport,
1004 changed_files: &FxHashSet<PathBuf>,
1005 root: &Path,
1006) {
1007 let cf = normalize_changed_files_set(changed_files);
1008 report.clone_groups.retain(|group| {
1009 group
1010 .instances
1011 .iter()
1012 .any(|instance| contains_normalized(&cf, &instance.file))
1013 });
1014 duplicates::refresh_clone_families(report, root);
1015 report.stats = duplicates::recompute_stats(report);
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020 use super::*;
1021 use fallow_types::{
1022 duplicates::{CloneGroup, CloneInstance, DuplicationStats},
1023 output_dead_code::{
1024 EmptyCatalogGroupFinding, UnusedDependencyFinding, UnusedExportFinding,
1025 UnusedFileFinding,
1026 },
1027 results::{
1028 DependencyLocation, EmptyCatalogGroup, UnusedDependency, UnusedExport, UnusedFile,
1029 },
1030 };
1031
1032 #[test]
1035 fn every_changed_files_cause_reports_its_own_reason() {
1036 let reasons = [
1037 ChangedFilesError::InvalidRef("unclosed brace".to_owned()).reason(),
1038 ChangedFilesError::GitMissing("no such file".to_owned()).reason(),
1039 ChangedFilesError::NotARepository.reason(),
1040 ChangedFilesError::GitFailed("unknown revision".to_owned()).reason(),
1041 ];
1042 assert_eq!(
1043 reasons,
1044 [
1045 "invalid-ref",
1046 "git-missing",
1047 "not-a-repository",
1048 "git-failed"
1049 ]
1050 );
1051 let unique: std::collections::BTreeSet<&str> = reasons.iter().copied().collect();
1052 assert_eq!(
1053 unique.len(),
1054 reasons.len(),
1055 "two causes must not share a token"
1056 );
1057 }
1058
1059 #[test]
1064 fn the_changed_since_message_names_the_ref_the_widening_and_the_next_step() {
1065 let message = ChangedFilesError::NotARepository.changed_since_message("origin/main");
1066 assert!(
1067 message.contains("--changed-since 'origin/main'"),
1068 "{message}"
1069 );
1070 assert!(message.contains("covers the whole project"), "{message}");
1071 assert!(message.ends_with("or drop --changed-since."), "{message}");
1072 }
1073
1074 #[test]
1078 fn the_changed_since_message_folds_git_stderr_onto_one_line() {
1079 let message = ChangedFilesError::GitFailed(
1080 "fatal: ambiguous argument 'x'\nUse '--' to separate paths".to_owned(),
1081 )
1082 .changed_since_message("x");
1083 assert!(!message.contains('\n'), "{message}");
1084 assert!(
1085 message.contains("ambiguous argument 'x' Use '--'"),
1086 "{message}"
1087 );
1088 }
1089
1090 #[test]
1091 fn git_command_clears_parent_git_environment() {
1092 let command = git_command(Path::new("."), &["status"]);
1093 let envs: Vec<_> = command.get_envs().collect();
1094
1095 for var in AMBIENT_GIT_ENV_VARS {
1096 assert!(
1097 envs.iter()
1098 .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
1099 "{var} should be cleared from the command env",
1100 );
1101 }
1102 }
1103
1104 #[test]
1105 fn try_get_changed_files_not_a_repository() {
1106 let temp = tempfile::tempdir().expect("tempdir");
1107 let result = try_get_changed_files(temp.path(), "main");
1108 assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
1109 }
1110
1111 #[cfg(unix)]
1112 #[test]
1113 fn changed_files_preserve_special_filenames() {
1114 let repo = tempfile::tempdir().expect("tempdir");
1115 for args in [
1116 &["init", "--quiet"][..],
1117 &["config", "user.email", "test@example.com"][..],
1118 &["config", "user.name", "Test User"][..],
1119 &["config", "commit.gpgsign", "false"][..],
1120 ] {
1121 run_git(repo.path(), args);
1122 }
1123 std::fs::write(repo.path().join("initial.ts"), "initial\n").expect("initial fixture");
1124 run_git(repo.path(), &["add", "."]);
1125 run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
1126 run_git(repo.path(), &["tag", "base"]);
1127
1128 let canonical_root = repo.path().canonicalize().expect("canonical repo");
1129 let special_files = [
1130 "src/line\nbreak.ts",
1131 "src/space name.ts",
1132 "src/quote\"name.ts",
1133 "src/back\\slash.ts",
1134 "src/unicode-λ.ts",
1135 ]
1136 .map(|path| canonical_root.join(path));
1137 std::fs::create_dir_all(canonical_root.join("src")).expect("source dir");
1138 for special in &special_files {
1139 std::fs::write(special, "changed\n").expect("special fixture");
1140 }
1141
1142 let changed = try_get_changed_files(repo.path(), "base").expect("changed files");
1143 for special in special_files {
1144 assert!(
1145 changed.contains(&special),
1146 "missing {special:?}: {changed:?}"
1147 );
1148 }
1149 }
1150
1151 #[cfg(windows)]
1152 #[test]
1153 fn git_path_bytes_use_windows_separators() {
1154 assert_eq!(
1155 git_path_from_bytes(b"src/nested/file.ts"),
1156 PathBuf::from(r"src\nested\file.ts")
1157 );
1158 }
1159
1160 #[test]
1161 fn changed_diff_covers_staged_unstaged_and_untracked_files() {
1162 let repo = tempfile::tempdir().expect("tempdir");
1163 for args in [
1164 &["init", "--quiet"][..],
1165 &["config", "user.email", "test@example.com"][..],
1166 &["config", "user.name", "Test User"][..],
1167 &["config", "commit.gpgsign", "false"][..],
1168 ] {
1169 run_git(repo.path(), args);
1170 }
1171 std::fs::write(repo.path().join("staged.ts"), "old\n").expect("staged fixture");
1172 std::fs::write(repo.path().join("unstaged.ts"), "old\n").expect("unstaged fixture");
1173 run_git(repo.path(), &["add", "."]);
1174 run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
1175 run_git(repo.path(), &["tag", "base"]);
1176
1177 std::fs::write(repo.path().join("committed.ts"), "committed\n").expect("committed fixture");
1178 run_git(repo.path(), &["add", "committed.ts"]);
1179 run_git(
1180 repo.path(),
1181 &["commit", "--quiet", "-m", "committed change"],
1182 );
1183
1184 std::fs::write(repo.path().join("staged.ts"), "staged\n").expect("staged edit");
1185 run_git(repo.path(), &["add", "staged.ts"]);
1186 std::fs::write(repo.path().join("unstaged.ts"), "unstaged\n").expect("unstaged edit");
1187 std::fs::write(repo.path().join("untracked.ts"), "untracked\n").expect("untracked edit");
1188
1189 let diff = try_get_changed_diff(repo.path(), "base").expect("complete changeset diff");
1190 let index = fallow_output::DiffIndex::from_unified_diff(&diff);
1191
1192 assert!(diff.contains("b/committed.ts"), "{diff}");
1193 assert!(diff.contains("b/staged.ts"), "{diff}");
1194 assert!(diff.contains("b/unstaged.ts"), "{diff}");
1195 assert!(diff.contains("b/untracked.ts"), "{diff}");
1196 assert_eq!(index.hunk_count(), 4);
1197 assert_eq!(index.net_lines(), 2);
1198 }
1199
1200 fn run_git(root: &Path, args: &[&str]) {
1201 let output = spawn_output(&mut git_command(root, args)).expect("git command");
1202 assert!(
1203 output.status.success(),
1204 "git {args:?} failed: {}",
1205 String::from_utf8_lossy(&output.stderr)
1206 );
1207 }
1208
1209 #[test]
1210 fn untracked_path_arg_uses_forward_slashes() {
1211 assert_eq!(
1212 super::untracked_path_arg(Path::new("src\\nested\\b.ts")),
1213 "src/nested/b.ts"
1214 );
1215 assert_eq!(super::untracked_path_arg(Path::new("src/b.ts")), "src/b.ts");
1216 }
1217
1218 #[test]
1219 fn changed_files_error_describe_matches_core_contract() {
1220 assert_eq!(
1221 ChangedFilesError::InvalidRef("bad ref".to_string()).describe(),
1222 "invalid git ref: bad ref"
1223 );
1224 assert_eq!(
1225 ChangedFilesError::GitMissing("not found".to_string()).describe(),
1226 "failed to run git: not found"
1227 );
1228 assert_eq!(
1229 ChangedFilesError::NotARepository.describe(),
1230 "not a git repository"
1231 );
1232 assert!(
1233 ChangedFilesError::GitFailed("unknown revision main".to_string())
1234 .describe()
1235 .contains("fetch-depth: 0")
1236 );
1237 }
1238
1239 #[test]
1240 fn filter_results_keeps_only_changed_file_findings() {
1241 let mut results = AnalysisResults::default();
1242 results
1243 .unused_files
1244 .push(UnusedFileFinding::with_actions(UnusedFile {
1245 path: PathBuf::from("/repo/a.ts"),
1246 }));
1247 results
1248 .unused_files
1249 .push(UnusedFileFinding::with_actions(UnusedFile {
1250 path: PathBuf::from("/repo/b.ts"),
1251 }));
1252 results
1253 .unused_exports
1254 .push(UnusedExportFinding::with_actions(UnusedExport {
1255 path: PathBuf::from("/repo/a.ts"),
1256 export_name: "foo".to_owned(),
1257 is_type_only: false,
1258 line: 1,
1259 col: 0,
1260 span_start: 0,
1261 is_re_export: false,
1262 deprecated: false,
1263 deprecated_reason: None,
1264 }));
1265
1266 let mut changed = FxHashSet::default();
1267 changed.insert(PathBuf::from("/repo/a.ts"));
1268
1269 filter_results_by_changed_files(&mut results, &changed);
1270
1271 assert_eq!(results.unused_files.len(), 1);
1272 assert_eq!(
1273 results.unused_files[0].file.path,
1274 PathBuf::from("/repo/a.ts")
1275 );
1276 assert_eq!(results.unused_exports.len(), 1);
1277 }
1278
1279 #[test]
1280 fn filter_results_preserves_graph_global_dependency_findings() {
1281 let mut results = AnalysisResults::default();
1282 results
1283 .unused_dependencies
1284 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1285 package_name: "lodash".to_owned(),
1286 location: DependencyLocation::Dependencies,
1287 path: PathBuf::from("/repo/package.json"),
1288 line: 3,
1289 used_in_workspaces: Vec::new(),
1290 }));
1291
1292 let changed = FxHashSet::default();
1293 filter_results_by_changed_files(&mut results, &changed);
1294
1295 assert_eq!(results.unused_dependencies.len(), 1);
1296 }
1297
1298 #[test]
1302 fn filter_results_keeps_deprecated_export_only_when_its_declaring_file_changed() {
1303 let finding = |path: &str| {
1304 fallow_types::output_dead_code::DeprecatedExportInUseFinding::with_actions(
1305 fallow_types::results::DeprecatedExportInUse {
1306 path: PathBuf::from(path),
1307 export_name: "old".to_owned(),
1308 is_type_only: false,
1309 line: 2,
1310 col: 0,
1311 span_start: 0,
1312 deprecated_reason: None,
1313 consumer_count: 1,
1314 consumers: vec![fallow_types::results::DeprecatedExportConsumer {
1315 path: PathBuf::from("/repo/consumer.ts"),
1316 line: 1,
1317 col: 0,
1318 kind: fallow_types::results::DeprecatedConsumerKind::NamedImport,
1319 }],
1320 public_api: false,
1321 },
1322 )
1323 };
1324 let mut results = AnalysisResults::default();
1325 results
1326 .deprecated_exports_in_use
1327 .push(finding("/repo/a.ts"));
1328 results
1329 .deprecated_exports_in_use
1330 .push(finding("/repo/b.ts"));
1331
1332 let mut changed = FxHashSet::default();
1333 changed.insert(PathBuf::from("/repo/a.ts"));
1334 changed.insert(PathBuf::from("/repo/consumer.ts"));
1335 filter_results_by_changed_files(&mut results, &changed);
1336
1337 let kept: Vec<_> = results
1338 .deprecated_exports_in_use
1339 .iter()
1340 .map(|f| f.export.path.clone())
1341 .collect();
1342 assert_eq!(kept, vec![PathBuf::from("/repo/a.ts")]);
1343 }
1344
1345 #[test]
1346 fn filter_results_keeps_relative_manifest_finding_when_manifest_changed() {
1347 let mut results = AnalysisResults::default();
1348 results
1349 .empty_catalog_groups
1350 .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
1351 catalog_name: "legacy".to_owned(),
1352 path: PathBuf::from("pnpm-workspace.yaml"),
1353 line: 4,
1354 }));
1355
1356 let mut changed = FxHashSet::default();
1357 changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
1358
1359 filter_results_by_changed_files(&mut results, &changed);
1360
1361 assert_eq!(results.empty_catalog_groups.len(), 1);
1362 }
1363
1364 #[test]
1365 fn filter_duplication_keeps_groups_with_changed_instances_and_recomputes_stats() {
1366 let mut report = DuplicationReport {
1367 clone_groups: vec![
1368 CloneGroup {
1369 instances: vec![
1370 CloneInstance {
1371 file: PathBuf::from("/repo/a.ts"),
1372 start_line: 1,
1373 end_line: 5,
1374 start_col: 0,
1375 end_col: 10,
1376 fragment: "code".to_owned(),
1377 },
1378 CloneInstance {
1379 file: PathBuf::from("/repo/b.ts"),
1380 start_line: 1,
1381 end_line: 5,
1382 start_col: 0,
1383 end_col: 10,
1384 fragment: "code".to_owned(),
1385 },
1386 ],
1387 token_count: 20,
1388 line_count: 5,
1389 similarity: None,
1390 },
1391 CloneGroup {
1392 instances: vec![
1393 CloneInstance {
1394 file: PathBuf::from("/repo/c.ts"),
1395 start_line: 1,
1396 end_line: 5,
1397 start_col: 0,
1398 end_col: 10,
1399 fragment: "other".to_owned(),
1400 },
1401 CloneInstance {
1402 file: PathBuf::from("/repo/d.ts"),
1403 start_line: 1,
1404 end_line: 5,
1405 start_col: 0,
1406 end_col: 10,
1407 fragment: "other".to_owned(),
1408 },
1409 ],
1410 token_count: 20,
1411 line_count: 5,
1412 similarity: None,
1413 },
1414 ],
1415 clone_families: Vec::new(),
1416 mirrored_directories: Vec::new(),
1417 stats: DuplicationStats {
1418 total_files: 4,
1419 files_with_clones: 4,
1420 total_lines: 100,
1421 duplicated_lines: 20,
1422 total_tokens: 200,
1423 duplicated_tokens: 80,
1424 clone_groups: 2,
1425 clone_families: 0,
1426 clone_instances: 4,
1427 duplication_percentage: 20.0,
1428 clone_groups_below_min_occurrences: 0,
1429 clone_groups_ignored: 0,
1430 near_candidates_skipped: 0,
1431 },
1432 };
1433
1434 let mut changed = FxHashSet::default();
1435 changed.insert(PathBuf::from("/repo/a.ts"));
1436
1437 filter_duplication_by_changed_files(&mut report, &changed, Path::new("/repo"));
1438
1439 assert_eq!(report.clone_groups.len(), 1);
1440 assert_eq!(report.stats.clone_groups, 1);
1441 assert_eq!(report.stats.clone_instances, 2);
1442 }
1443
1444 #[test]
1447 fn one_probe_resolves_the_common_dir_and_the_toplevel() {
1448 let repo = tempfile::tempdir().expect("tempdir");
1449 run_git(repo.path(), &["init", "--quiet"]);
1450 let nested = repo.path().join("packages/app");
1451 std::fs::create_dir_all(&nested).expect("nested dir");
1452 for cwd in [repo.path(), nested.as_path()] {
1453 let (common, toplevel) =
1454 resolve_git_common_dir_and_toplevel(cwd).expect("combined probe");
1455 assert_eq!(common, resolve_git_common_dir(cwd).expect("common dir"));
1456 assert_eq!(toplevel, resolve_git_toplevel(cwd).expect("toplevel"));
1457 }
1458 }
1459
1460 #[test]
1463 fn the_combined_probe_fails_without_a_work_tree() {
1464 let repo = tempfile::tempdir().expect("tempdir");
1465 run_git(repo.path(), &["init", "--quiet", "--bare"]);
1466 assert!(resolve_git_common_dir_and_toplevel(repo.path()).is_err());
1467 assert!(resolve_git_common_dir(repo.path()).is_ok());
1468 }
1469}