1use std::path::{Path, PathBuf};
2use std::process::Command;
3use std::time::{Instant, SystemTime};
4
5use fallow_config::AuditGate;
6use fallow_engine::clear_ambient_git_env;
7use fallow_output::build_audit_next_steps;
8use fallow_types::output::NextStep;
9use rustc_hash::FxHashSet;
10
11use crate::{
12 AnalysisOptions, AuditAttribution, AuditOptions, AuditProgrammaticKeySnapshot,
13 AuditProgrammaticOutput, AuditSummary, AuditVerdict, ComplexityOptions, DeadCodeFilters,
14 DeadCodeOptions, DuplicationOptions, ProgrammaticError,
15 analysis_context::{changed_files_for_run, resolve_programmatic_analysis_context},
16};
17
18use super::{ProgrammaticResult, root_envelope_mode, run_dead_code, run_duplication, run_health};
19
20pub fn run_audit(options: &AuditOptions) -> ProgrammaticResult<AuditProgrammaticOutput> {
27 validate_audit_api_options(options)?;
28 let start = Instant::now();
29 let resolved_base = resolve_audit_base_ref(options)?;
30 let analysis = analysis_options_for_audit(options, &resolved_base.git_ref);
31 let resolved = resolve_programmatic_analysis_context(&analysis)?;
32 let changed_files = changed_files_for_run(&resolved)?.unwrap_or_default();
33 let changed_files_count = changed_files.len();
34
35 if changed_files.is_empty() {
36 return Ok(empty_audit_output(
37 options,
38 resolved_base,
39 resolved.root(),
40 changed_files_count,
41 start.elapsed(),
42 ));
43 }
44
45 let head = run_audit_subanalyses(options, &analysis, Some(&changed_files))?;
46 let current_snapshot = snapshot_from_analyses(&head);
47 let base_snapshot = if matches!(options.gate, AuditGate::NewOnly) {
48 Some(compute_base_snapshot(options, &resolved_base.git_ref)?)
49 } else {
50 None
51 };
52 let summary = build_programmatic_audit_summary(&head);
53 let attribution = compute_programmatic_audit_attribution(
54 options.gate,
55 ¤t_snapshot,
56 base_snapshot.as_ref(),
57 );
58 let verdict = compute_programmatic_audit_verdict(
59 options.gate,
60 &summary,
61 &head.duplication,
62 ¤t_snapshot,
63 base_snapshot.as_ref(),
64 );
65 let next_steps = audit_next_steps(&head.dead_code, &head.complexity);
66
67 Ok(AuditProgrammaticOutput {
68 verdict,
69 summary,
70 attribution,
71 changed_files_count,
72 base_ref: resolved_base.git_ref,
73 base_description: resolved_base.description,
74 head_sha: get_head_sha(resolved.root()),
75 elapsed: start.elapsed(),
76 base_snapshot_skipped: None,
77 base_snapshot,
78 dead_code: Some(head.dead_code),
79 duplication: Some(head.duplication),
80 complexity: Some(head.complexity),
81 next_steps,
82 envelope_mode: root_envelope_mode(),
83 telemetry_analysis_run_id: None,
84 })
85}
86
87fn validate_audit_api_options(options: &AuditOptions) -> ProgrammaticResult<()> {
88 if let Err(err) =
89 fallow_engine::validate_coverage_root_absolute(options.coverage_root.as_deref())
90 {
91 return Err(ProgrammaticError::new(err, 2)
92 .with_code("FALLOW_INVALID_COVERAGE_ROOT")
93 .with_context("audit.coverageRoot"));
94 }
95 if options.runtime_coverage.is_some() {
96 return Err(ProgrammaticError::new(
97 "programmatic audit does not yet support runtime coverage; use the CLI path",
98 2,
99 )
100 .with_code("FALLOW_AUDIT_RUNTIME_COVERAGE_UNSUPPORTED")
101 .with_context("audit.runtimeCoverage"));
102 }
103 Ok(())
104}
105
106#[derive(Debug, Clone)]
107pub(super) struct ResolvedAuditBase {
108 pub(super) git_ref: String,
109 pub(super) description: Option<String>,
110}
111
112pub(super) fn resolve_audit_base_ref(
113 options: &AuditOptions,
114) -> ProgrammaticResult<ResolvedAuditBase> {
115 if let Some(ref_str) = options
116 .base
117 .as_deref()
118 .or(options.analysis.changed_since.as_deref())
119 {
120 validate_git_ref(ref_str, "audit.base")?;
121 return Ok(ResolvedAuditBase {
122 git_ref: (*ref_str).to_string(),
123 description: None,
124 });
125 }
126 if let Some(env_ref) = audit_base_env_override() {
127 validate_git_ref(&env_ref, "FALLOW_AUDIT_BASE")?;
128 return Ok(ResolvedAuditBase {
129 description: Some(format!("FALLOW_AUDIT_BASE={env_ref}")),
130 git_ref: env_ref,
131 });
132 }
133 let root = options
134 .analysis
135 .root
136 .clone()
137 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
138 auto_detect_base_ref(&root).ok_or_else(|| {
139 ProgrammaticError::new(
140 "could not detect base branch. Set audit.base to specify the comparison target",
141 2,
142 )
143 .with_code("FALLOW_AUDIT_BASE_NOT_FOUND")
144 .with_context("audit.base")
145 })
146}
147
148fn analysis_options_for_audit(options: &AuditOptions, base_ref: &str) -> AnalysisOptions {
149 AnalysisOptions {
150 changed_since: Some(base_ref.to_string()),
151 production: options.production,
152 production_override: options.production.then_some(true),
153 ..options.analysis.clone()
154 }
155}
156
157fn analysis_with_production(
158 analysis: &AnalysisOptions,
159 production_override: Option<bool>,
160) -> AnalysisOptions {
161 AnalysisOptions {
162 production: production_override.unwrap_or(analysis.production),
163 production_override: production_override.or(analysis.production_override),
164 ..analysis.clone()
165 }
166}
167
168fn empty_audit_output(
169 options: &AuditOptions,
170 base: ResolvedAuditBase,
171 root: &Path,
172 changed_files_count: usize,
173 elapsed: std::time::Duration,
174) -> AuditProgrammaticOutput {
175 AuditProgrammaticOutput {
176 verdict: AuditVerdict::Pass,
177 summary: AuditSummary {
178 dead_code_issues: 0,
179 dead_code_has_errors: false,
180 complexity_findings: 0,
181 max_cyclomatic: None,
182 duplication_clone_groups: 0,
183 },
184 attribution: AuditAttribution {
185 gate: options.gate,
186 ..AuditAttribution::default()
187 },
188 changed_files_count,
189 base_ref: base.git_ref,
190 base_description: base.description,
191 head_sha: get_head_sha(root),
192 elapsed,
193 base_snapshot_skipped: None,
194 base_snapshot: None,
195 dead_code: None,
196 duplication: None,
197 complexity: None,
198 next_steps: Vec::new(),
199 envelope_mode: root_envelope_mode(),
200 telemetry_analysis_run_id: None,
201 }
202}
203
204struct AuditSubanalyses {
205 dead_code: crate::DeadCodeProgrammaticOutput,
206 duplication: crate::DuplicationProgrammaticOutput,
207 complexity: crate::HealthProgrammaticOutput,
208}
209
210fn run_audit_subanalyses(
211 options: &AuditOptions,
212 analysis: &AnalysisOptions,
213 changed_files: Option<&FxHashSet<PathBuf>>,
214) -> ProgrammaticResult<AuditSubanalyses> {
215 let dead_code_options = DeadCodeOptions {
216 analysis: analysis_with_production(analysis, options.production_dead_code),
217 filters: DeadCodeFilters::default(),
218 files: Vec::new(),
219 include_entry_exports: options.include_entry_exports,
220 };
221 let duplication_options = DuplicationOptions {
222 analysis: analysis_with_production(analysis, options.production_dupes),
223 ..DuplicationOptions::default()
224 };
225 let complexity_options = ComplexityOptions {
226 analysis: analysis_with_production(analysis, options.production_health),
227 max_crap: options.max_crap,
228 complexity: true,
229 coverage: options.coverage.clone(),
230 coverage_root: options.coverage_root.clone(),
231 ..ComplexityOptions::default()
232 };
233
234 if options.production_dead_code == options.production_dupes {
235 let resolved = resolve_programmatic_analysis_context(&dead_code_options.analysis)?;
236 return resolved.install(|| {
237 let session = super::dead_code::load_dead_code_session(&dead_code_options, &resolved)?;
238 Ok(AuditSubanalyses {
239 dead_code: super::dead_code::run_dead_code_with_session(
240 &dead_code_options,
241 &resolved,
242 &session,
243 changed_files,
244 |_| {},
245 Instant::now(),
246 )?,
247 duplication: super::duplication::run_duplication_with_session(
248 &duplication_options,
249 &resolved,
250 &session,
251 changed_files,
252 Instant::now(),
253 )?,
254 complexity: run_health(&complexity_options)?,
255 })
256 });
257 }
258
259 Ok(AuditSubanalyses {
260 dead_code: run_dead_code(&dead_code_options)?,
261 duplication: run_duplication(&duplication_options)?,
262 complexity: run_health(&complexity_options)?,
263 })
264}
265
266fn build_programmatic_audit_summary(analyses: &AuditSubanalyses) -> AuditSummary {
267 let dead_code_issues = analyses.dead_code.output.results.total_issues();
268 AuditSummary {
269 dead_code_issues,
270 dead_code_has_errors: dead_code_issues > 0,
271 complexity_findings: analyses.complexity.report.findings.len(),
272 max_cyclomatic: analyses
273 .complexity
274 .report
275 .findings
276 .iter()
277 .map(|finding| finding.cyclomatic)
278 .max(),
279 duplication_clone_groups: analyses.duplication.output.report.clone_groups.len(),
280 }
281}
282
283fn compute_programmatic_audit_verdict(
284 gate: AuditGate,
285 summary: &AuditSummary,
286 duplication: &crate::DuplicationProgrammaticOutput,
287 current: &AuditProgrammaticKeySnapshot,
288 base: Option<&AuditProgrammaticKeySnapshot>,
289) -> AuditVerdict {
290 if matches!(gate, AuditGate::NewOnly) {
291 return compute_programmatic_introduced_verdict(summary, duplication, current, base);
292 }
293 if summary.dead_code_has_errors || summary.complexity_findings > 0 {
294 return AuditVerdict::Fail;
295 }
296 if summary.duplication_clone_groups > 0 {
297 let pct = duplication.output.report.stats.duplication_percentage;
298 if duplication.threshold > 0.0 && pct > duplication.threshold {
299 return AuditVerdict::Fail;
300 }
301 return AuditVerdict::Warn;
302 }
303 AuditVerdict::Pass
304}
305
306fn compute_programmatic_introduced_verdict(
307 summary: &AuditSummary,
308 duplication: &crate::DuplicationProgrammaticOutput,
309 current: &AuditProgrammaticKeySnapshot,
310 base: Option<&AuditProgrammaticKeySnapshot>,
311) -> AuditVerdict {
312 let attribution = compute_programmatic_audit_attribution(AuditGate::NewOnly, current, base);
313 if attribution.dead_code_introduced > 0 || attribution.complexity_introduced > 0 {
314 return AuditVerdict::Fail;
315 }
316 if attribution.duplication_introduced > 0 {
317 let pct = duplication.output.report.stats.duplication_percentage;
318 if duplication.threshold > 0.0 && pct > duplication.threshold {
319 return AuditVerdict::Fail;
320 }
321 return AuditVerdict::Warn;
322 }
323 if summary.dead_code_issues == 0
324 && summary.complexity_findings == 0
325 && summary.duplication_clone_groups == 0
326 {
327 return AuditVerdict::Pass;
328 }
329 AuditVerdict::Pass
330}
331
332fn compute_programmatic_audit_attribution(
333 gate: AuditGate,
334 current: &AuditProgrammaticKeySnapshot,
335 base: Option<&AuditProgrammaticKeySnapshot>,
336) -> AuditAttribution {
337 let dead_code = count_introduced(¤t.dead_code, base.map(|snapshot| &snapshot.dead_code));
338 let complexity = count_introduced(¤t.health, base.map(|snapshot| &snapshot.health));
339 let duplication = count_introduced(¤t.dupes, base.map(|snapshot| &snapshot.dupes));
340 AuditAttribution {
341 gate,
342 dead_code_introduced: dead_code.0,
343 dead_code_inherited: dead_code.1,
344 complexity_introduced: complexity.0,
345 complexity_inherited: complexity.1,
346 duplication_introduced: duplication.0,
347 duplication_inherited: duplication.1,
348 }
349}
350
351fn count_introduced(
352 keys: &rustc_hash::FxHashSet<String>,
353 base: Option<&rustc_hash::FxHashSet<String>>,
354) -> (usize, usize) {
355 let Some(base) = base else {
356 return (0, 0);
357 };
358 keys.iter().fold((0, 0), |(introduced, inherited), key| {
359 if base.contains(key) {
360 (introduced, inherited + 1)
361 } else {
362 (introduced + 1, inherited)
363 }
364 })
365}
366
367fn snapshot_from_analyses(analyses: &AuditSubanalyses) -> AuditProgrammaticKeySnapshot {
368 AuditProgrammaticKeySnapshot {
369 dead_code: crate::audit_keys::dead_code_keys(
370 &analyses.dead_code.output.results,
371 &analyses.dead_code.root,
372 ),
373 health: crate::audit_keys::health_keys(
374 &analyses.complexity.report,
375 &analyses.complexity.root,
376 ),
377 dupes: analyses
378 .duplication
379 .output
380 .report
381 .clone_groups
382 .iter()
383 .map(|group| {
384 crate::audit_keys::dupe_group_key(&group.group, &analyses.duplication.root)
385 })
386 .collect(),
387 }
388}
389
390fn compute_base_snapshot(
391 options: &AuditOptions,
392 base_ref: &str,
393) -> ProgrammaticResult<AuditProgrammaticKeySnapshot> {
394 let current_root = analysis_root_from_options(options)?;
395 let worktree = BaseWorktree::create(¤t_root, base_ref)?;
396 let base_root = base_analysis_root(¤t_root, worktree.path());
397 let current_config_path = options
398 .analysis
399 .config_path
400 .clone()
401 .or_else(|| fallow_config::FallowConfig::find_config_path(¤t_root));
402 let base_analysis = AnalysisOptions {
403 root: Some(base_root),
404 config_path: current_config_path,
405 changed_since: None,
406 explain: false,
407 ..options.analysis.clone()
408 };
409 let base = run_audit_subanalyses(options, &base_analysis, None)?;
410 Ok(snapshot_from_analyses(&base))
411}
412
413fn analysis_root_from_options(options: &AuditOptions) -> ProgrammaticResult<PathBuf> {
414 match options.analysis.root.clone() {
415 Some(root) => Ok(root),
416 None => std::env::current_dir().map_err(|err| {
417 ProgrammaticError::new(
418 format!("failed to resolve current working directory: {err}"),
419 2,
420 )
421 .with_code("FALLOW_CWD_UNAVAILABLE")
422 .with_context("analysis.root")
423 }),
424 }
425}
426
427pub(super) struct BaseWorktree {
428 repo_root: PathBuf,
429 path: PathBuf,
430}
431
432impl BaseWorktree {
433 pub(super) fn create(repo_root: &Path, base_ref: &str) -> ProgrammaticResult<Self> {
434 let path = base_worktree_path()?;
435 let mut command = Command::new("git");
436 command
437 .args([
438 "worktree",
439 "add",
440 "--detach",
441 "--quiet",
442 path.to_string_lossy().as_ref(),
443 base_ref,
444 ])
445 .current_dir(repo_root);
446 clear_ambient_git_env(&mut command);
447 let output = command.output().map_err(|err| {
448 ProgrammaticError::new(
449 format!("could not create a temporary worktree for base ref `{base_ref}`: {err}"),
450 2,
451 )
452 .with_code("FALLOW_AUDIT_BASE_WORKTREE_FAILED")
453 .with_context("audit.base")
454 })?;
455 if !output.status.success() {
456 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
457 return Err(ProgrammaticError::new(
458 format!(
459 "could not create a temporary worktree for base ref `{base_ref}`: {stderr}"
460 ),
461 2,
462 )
463 .with_code("FALLOW_AUDIT_BASE_WORKTREE_FAILED")
464 .with_context("audit.base"));
465 }
466 Ok(Self {
467 repo_root: repo_root.to_path_buf(),
468 path,
469 })
470 }
471
472 pub(super) fn path(&self) -> &Path {
473 &self.path
474 }
475}
476
477impl Drop for BaseWorktree {
478 fn drop(&mut self) {
479 let mut command = Command::new("git");
480 command
481 .args([
482 "worktree",
483 "remove",
484 "--force",
485 self.path.to_string_lossy().as_ref(),
486 ])
487 .current_dir(&self.repo_root);
488 clear_ambient_git_env(&mut command);
489 let _ = command.output();
490 let _ = std::fs::remove_dir_all(&self.path);
491 }
492}
493
494fn base_worktree_path() -> ProgrammaticResult<PathBuf> {
495 let nanos = SystemTime::now()
496 .duration_since(SystemTime::UNIX_EPOCH)
497 .map_err(|err| {
498 ProgrammaticError::new(format!("system clock before unix epoch: {err}"), 2)
499 .with_code("FALLOW_CLOCK_ERROR")
500 .with_context("audit.base")
501 })?
502 .as_nanos();
503 Ok(std::env::temp_dir().join(format!(
504 "fallow-api-audit-base-{}-{nanos}",
505 std::process::id()
506 )))
507}
508
509pub(super) fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
510 let Some(git_root) = git_toplevel(current_root) else {
511 return base_worktree_root.to_path_buf();
512 };
513 let current_root =
514 dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
515 match current_root.strip_prefix(&git_root) {
516 Ok(relative) => base_worktree_root.join(relative),
517 Err(_) => base_worktree_root.to_path_buf(),
518 }
519}
520
521fn git_toplevel(root: &Path) -> Option<PathBuf> {
522 git_stdout(root, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
523}
524
525fn audit_next_steps(
526 dead_code: &crate::DeadCodeProgrammaticOutput,
527 complexity: &crate::HealthProgrammaticOutput,
528) -> Vec<NextStep> {
529 let input = fallow_output::build_audit_next_steps_input(
530 Some((&dead_code.output.results, dead_code.root.as_path())),
531 Some(&complexity.report),
532 crate::next_steps::suggestions_enabled(),
533 );
534 build_audit_next_steps(&input)
535}
536
537fn validate_git_ref(value: &str, context: &'static str) -> ProgrammaticResult<()> {
538 fallow_engine::validate::validate_git_ref(value)
539 .map(|_| ())
540 .map_err(|err| {
541 ProgrammaticError::new(format!("invalid git ref `{value}`: {err}"), 2)
542 .with_code("FALLOW_INVALID_GIT_REF")
543 .with_context(context)
544 })
545}
546
547fn audit_base_env_override() -> Option<String> {
548 std::env::var("FALLOW_AUDIT_BASE")
549 .ok()
550 .map(|value| value.trim().to_string())
551 .filter(|value| !value.is_empty())
552}
553
554fn auto_detect_base_ref(root: &Path) -> Option<ResolvedAuditBase> {
555 if let Some(upstream) = git_upstream_ref(root) {
556 if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
557 return Some(ResolvedAuditBase {
558 git_ref: sha,
559 description: Some(format!("merge-base with {upstream}")),
560 });
561 }
562 return Some(ResolvedAuditBase {
563 description: Some(format!("{upstream} (tip)")),
564 git_ref: upstream,
565 });
566 }
567
568 if let Some(remote_ref) = detect_remote_default_ref(root) {
569 if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
570 return Some(ResolvedAuditBase {
571 git_ref: sha,
572 description: Some(format!("merge-base with {remote_ref}")),
573 });
574 }
575 return Some(ResolvedAuditBase {
576 description: Some(format!("{remote_ref} (tip)")),
577 git_ref: remote_ref,
578 });
579 }
580
581 for candidate in ["main", "master"] {
582 if git_ref_exists(root, candidate) {
583 return Some(ResolvedAuditBase {
584 git_ref: candidate.to_string(),
585 description: Some(format!("local {candidate}")),
586 });
587 }
588 }
589
590 None
591}
592
593fn git_stdout(root: &Path, args: &[&str]) -> Option<String> {
594 let mut command = Command::new("git");
595 command.args(args).current_dir(root);
596 clear_ambient_git_env(&mut command);
597 let output = command.output().ok()?;
598 if !output.status.success() {
599 return None;
600 }
601 let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
602 (!trimmed.is_empty()).then_some(trimmed)
603}
604
605fn git_ref_exists(root: &Path, git_ref: &str) -> bool {
606 git_stdout(root, &["rev-parse", "--verify", "--quiet", git_ref]).is_some()
607}
608
609fn git_upstream_ref(root: &Path) -> Option<String> {
610 git_stdout(
611 root,
612 &[
613 "rev-parse",
614 "--abbrev-ref",
615 "--symbolic-full-name",
616 "@{upstream}",
617 ],
618 )
619}
620
621fn git_merge_base(root: &Path, a: &str, b: &str) -> Option<String> {
622 git_stdout(root, &["merge-base", a, b])
623}
624
625fn detect_remote_default_ref(root: &Path) -> Option<String> {
626 if let Some(full_ref) = git_stdout(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
627 && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
628 {
629 return Some(format!("origin/{branch}"));
630 }
631 ["origin/main", "origin/master"]
632 .into_iter()
633 .find(|candidate| git_ref_exists(root, candidate))
634 .map(str::to_string)
635}
636
637fn get_head_sha(root: &Path) -> Option<String> {
638 git_stdout(root, &["rev-parse", "--short", "HEAD"])
639}
640
641#[cfg(test)]
642mod tests {
643 use std::process::Command;
644
645 use fallow_config::AuditGate;
646
647 use super::*;
648
649 #[test]
650 fn run_audit_default_new_only_marks_untracked_added_file_introduced() {
651 let project = audit_fixture();
652 let output = run_audit(&AuditOptions {
653 analysis: AnalysisOptions {
654 root: Some(project.path().to_path_buf()),
655 no_cache: true,
656 explain: true,
657 ..AnalysisOptions::default()
658 },
659 base: Some("HEAD".to_string()),
660 gate: AuditGate::NewOnly,
661 ..AuditOptions::default()
662 })
663 .expect("audit output");
664
665 assert_eq!(output.verdict, AuditVerdict::Fail);
666 assert_eq!(output.summary.dead_code_issues, 1);
667 assert_eq!(output.attribution.dead_code_introduced, 1);
668 assert!(output.base_snapshot.is_some());
669
670 let json = crate::serialize_audit_programmatic_json(output).expect("audit json");
671 assert_eq!(
672 json["dead_code"]["unused_files"][0]["path"],
673 "src/feature.ts"
674 );
675 assert_eq!(json["dead_code"]["unused_files"][0]["introduced"], true);
676 }
677
678 #[test]
679 fn empty_audit_output_uses_resolved_root_for_head_sha() {
680 let project = audit_fixture();
681 let output = empty_audit_output(
682 &AuditOptions {
683 analysis: AnalysisOptions {
684 root: None,
685 ..AnalysisOptions::default()
686 },
687 base: Some("HEAD".to_string()),
688 gate: AuditGate::NewOnly,
689 ..AuditOptions::default()
690 },
691 ResolvedAuditBase {
692 git_ref: "HEAD".to_string(),
693 description: None,
694 },
695 project.path(),
696 0,
697 std::time::Duration::ZERO,
698 );
699
700 assert!(output.head_sha.is_some());
701 }
702
703 fn audit_fixture() -> tempfile::TempDir {
704 let project = tempfile::tempdir().expect("project");
705 std::fs::create_dir_all(project.path().join("src")).expect("create src");
706 std::fs::write(
707 project.path().join("package.json"),
708 r#"{"name":"audit-api","type":"module","main":"src/index.ts"}"#,
709 )
710 .expect("write package");
711 std::fs::write(
712 project.path().join("src/index.ts"),
713 "console.log('entry');\n",
714 )
715 .expect("write entry");
716 git(project.path(), &["init"]);
717 git(project.path(), &["add", "."]);
718 git(
719 project.path(),
720 &[
721 "-c",
722 "user.email=test@example.com",
723 "-c",
724 "user.name=Test",
725 "-c",
726 "commit.gpgsign=false",
727 "commit",
728 "-m",
729 "initial",
730 ],
731 );
732 std::fs::write(
733 project.path().join("src/feature.ts"),
734 "export const unused = 1;\n",
735 )
736 .expect("write changed source");
737 project
738 }
739
740 fn git(root: &Path, args: &[&str]) {
741 let status = Command::new("git")
742 .args(args)
743 .current_dir(root)
744 .status()
745 .expect("git command");
746 assert!(status.success(), "git {args:?} failed");
747 }
748}