1use std::collections::HashSet;
4use std::ffi::OsStr;
5use std::ffi::OsString;
6use std::path::Path;
7use std::path::PathBuf;
8use std::process::Command;
9use std::thread;
10
11use git2::Repository as GitRepo;
12use owo_colors::OwoColorize;
13use rootcause::prelude::ResultExt;
14use rootcause::report;
15use serde::Deserialize;
16use ytil_sys::pico_args::Arguments;
17
18mod quarantine;
19
20const DEFAULT_JOBS: usize = 7;
21
22pub fn run(mut cli_args: Arguments) -> rootcause::Result<()> {
27 if cli_args.contains("--help") {
28 print!("{}", crate::cmds::Help::RepoFix.text());
29 return Ok(());
30 }
31 let options = match RepoFixOpts::try_from(cli_args.finish()) {
32 Ok(options) => options,
33 Err(error) => {
34 eprintln!("{}", crate::cmds::Help::RepoFix.text());
35 return Err(error);
36 }
37 };
38 self::fix(&options)
39}
40
41#[derive(Debug)]
42struct RepoFixOpts {
43 directory: PathBuf,
44 clean: bool,
45 jobs: usize,
46}
47
48impl TryFrom<Vec<OsString>> for RepoFixOpts {
49 type Error = rootcause::Report;
50
51 fn try_from(raw: Vec<OsString>) -> Result<Self, Self::Error> {
52 let mut before_dash_dash = Vec::new();
53 let mut after_dash_dash = Vec::new();
54 let mut after_separator = false;
55 for argument in raw {
56 if after_separator {
57 after_dash_dash.push(argument);
58 } else if argument == "--" {
59 after_separator = true;
60 } else {
61 before_dash_dash.push(argument);
62 }
63 }
64
65 let mut cli_args = Arguments::from_vec(before_dash_dash);
66 let mut clean = false;
67 while cli_args.contains("--clean") {
68 clean = true;
69 }
70 let mut jobs = DEFAULT_JOBS;
71 while let Some(value) = cli_args
72 .opt_value_from_str::<_, usize>("--jobs")
73 .map_err(|error| report!("--jobs requires a positive integer").attach(error.to_string()))?
74 {
75 if value == 0 {
76 return Err(report!("--jobs must be a positive integer"));
77 }
78 jobs = value;
79 }
80
81 let mut positionals = cli_args.finish();
82 if let Some(option) = positionals
83 .iter()
84 .find(|argument| argument.to_string_lossy().starts_with('-'))
85 {
86 return Err(report!("unknown repo fix option").attach(format!("option={}", option.to_string_lossy())));
87 }
88 positionals.append(&mut after_dash_dash);
89 let [directory] = positionals.as_slice() else {
90 return Err(report!("expected exactly one repo directory"));
91 };
92 Ok(Self {
93 directory: PathBuf::from(directory),
94 clean,
95 jobs,
96 })
97 }
98}
99
100#[derive(Clone, Debug)]
101struct Workspace {
102 repo: PathBuf,
103 root: PathBuf,
104 target: PathBuf,
105}
106
107#[derive(Debug, Deserialize)]
108struct CargoMetadata {
109 workspace_root: PathBuf,
110 target_directory: PathBuf,
111}
112
113#[derive(Debug)]
114enum Failure {
115 Repo { repo: PathBuf, message: String },
116 Quarantine { repo: PathBuf, failure: rootcause::Report },
117 Traversal { message: String },
118}
119
120impl Failure {
121 fn repo(repo: PathBuf, message: impl Into<String>) -> Self {
122 Self::Repo {
123 repo,
124 message: message.into(),
125 }
126 }
127}
128
129struct ManifestDiscovery {
130 manifests: Vec<PathBuf>,
131 failures: Vec<Failure>,
132}
133
134struct RepoDiscovery {
135 manifest_count: usize,
136 repos: Vec<PathBuf>,
137 workspaces: Vec<Workspace>,
138 skipped_manifests: Vec<PathBuf>,
139 failures: Vec<Failure>,
140}
141
142fn fix(opts: &RepoFixOpts) -> rootcause::Result<()> {
148 let directory = self::validate_directory(&opts.directory)?;
149 self::require_command("cargo")?;
150 self::require_command("tmutil")?;
151 self::require_command("xattr")?;
152
153 println!(
154 "{} below: {}",
155 if opts.clean {
156 "Preparing workspace build directories, quarantine cleanup, and cargo clean"
157 } else {
158 "Preparing workspace build directories and quarantine cleanup"
159 }
160 .blue()
161 .bold(),
162 directory.display()
163 );
164 print!("{} ", "Continue? [y/N]".yellow().bold());
165
166 std::io::Write::flush(&mut std::io::stdout())?;
167 let mut confirmation = String::new();
168 std::io::stdin().read_line(&mut confirmation)?;
169 if !matches!(confirmation.trim(), "y" | "Y") {
170 println!("{}", "Cancelled before making changes".yellow().bold());
171 return Ok(());
172 }
173
174 let manifest_discovery = self::collect_manifest_paths(&directory);
175 let repo_discovery = self::discover_repos(&manifest_discovery.manifests);
176 let mut failures = Vec::new();
177 failures.extend(manifest_discovery.failures);
178 failures.extend(repo_discovery.failures);
179
180 for manifest in repo_discovery.skipped_manifests {
181 eprintln!(
182 "{} skipping Cargo manifest outside a Git repo: {}",
183 "Warning".yellow().bold(),
184 manifest.display()
185 );
186 }
187 println!(
188 "{} {} Cargo manifest(s) in {} workspace(s) across {} Git repo(s)",
189 "Found".blue().bold(),
190 repo_discovery.manifest_count,
191 repo_discovery.workspaces.len(),
192 repo_discovery.repos.len()
193 );
194
195 if opts.clean {
196 for workspace in &repo_discovery.workspaces {
197 println!("{} {}", "Running cargo clean".blue().bold(), workspace.root.display());
198 }
199 for cleanup in self::clean_workspaces(&repo_discovery.workspaces, opts.jobs) {
200 match cleanup {
201 Ok(workspace) => println!("{} {}", "Cargo clean complete".green().bold(), workspace.root.display()),
202 Err(failure) => failures.push(failure),
203 }
204 }
205 }
206
207 for workspace in &repo_discovery.workspaces {
208 match self::configure_workspace_target(workspace) {
209 Ok(target) => println!("{} {}", "Configured workspace target".green().bold(), target.display()),
210 Err(error) => failures.push(Failure::repo(workspace.repo.clone(), error.to_string())),
211 }
212 }
213
214 for repo in &repo_discovery.repos {
215 println!("{} {}", "Removing quarantine metadata in".blue().bold(), repo.display());
216 }
217
218 for cleanup in quarantine::clean(&repo_discovery.repos, opts.jobs) {
219 failures.extend(cleanup.failures.into_iter().map(|failure| Failure::Quarantine {
220 repo: cleanup.repo.clone(),
221 failure,
222 }));
223 }
224
225 self::summarize_repos(repo_discovery.repos.len(), &failures)
226}
227
228fn validate_directory(directory: &Path) -> rootcause::Result<PathBuf> {
229 let metadata =
230 std::fs::symlink_metadata(directory).attach_with(|| format!("directory not found: {}", directory.display()))?;
231 if metadata.file_type().is_symlink() {
232 return Err(
233 report!("refusing to use a symbolic-link directory").attach(format!("path={}", directory.display()))
234 );
235 }
236 if !metadata.is_dir() {
237 return Err(report!("not a directory").attach(format!("path={}", directory.display())));
238 }
239 Ok(std::fs::canonicalize(directory)
240 .attach_with(|| format!("cannot canonicalize directory: {}", directory.display()))?)
241}
242
243fn require_command(command: &str) -> rootcause::Result<()> {
244 if Command::new(command).arg("--version").output().is_err() {
245 return Err(report!("required command not found").attach(format!("command={command}")));
246 }
247 Ok(())
248}
249
250fn collect_manifest_paths(directory: &Path) -> ManifestDiscovery {
251 let mut manifests = Vec::new();
252 let mut failures = Vec::new();
253 self::collect_manifest_paths_recursive(directory, &mut manifests, &mut failures);
254 ManifestDiscovery { manifests, failures }
255}
256
257fn collect_manifest_paths_recursive(directory: &Path, manifests: &mut Vec<PathBuf>, failures: &mut Vec<Failure>) {
258 let entries = match std::fs::read_dir(directory) {
259 Ok(entries) => entries,
260 Err(error) => {
261 failures.push(Failure::Traversal {
262 message: format!("Cargo manifest discovery failed below {}: {error}", directory.display()),
263 });
264 return;
265 }
266 };
267
268 for entry in entries {
269 let entry = match entry {
270 Ok(entry) => entry,
271 Err(error) => {
272 failures.push(Failure::Traversal {
273 message: format!("reading entry below {} failed: {error}", directory.display()),
274 });
275 continue;
276 }
277 };
278 let path = entry.path();
279 let file_type = match entry.file_type() {
280 Ok(file_type) => file_type,
281 Err(error) => {
282 failures.push(Failure::Traversal {
283 message: format!("reading file type for {} failed: {error}", path.display()),
284 });
285 continue;
286 }
287 };
288 if file_type.is_symlink() {
289 continue;
290 }
291 if file_type.is_dir() {
292 self::collect_manifest_paths_recursive(&path, manifests, failures);
293 } else if file_type.is_file() && path.file_name() == Some(OsStr::new("Cargo.toml")) {
294 manifests.push(path);
295 }
296 }
297}
298
299fn discover_repos(manifests: &[PathBuf]) -> RepoDiscovery {
300 let mut manifest_count = 0_usize;
301 let mut repos = Vec::new();
302 let mut repo_set = HashSet::new();
303 let mut workspaces = Vec::new();
304 let mut workspace_set = HashSet::new();
305 let mut skipped_manifests = Vec::new();
306 let mut failures = Vec::new();
307
308 for manifest in manifests {
309 let Some(manifest_directory) = manifest.parent() else {
310 continue;
311 };
312 let Some(repo) = self::repo_root(manifest_directory) else {
313 skipped_manifests.push(manifest.clone());
314 continue;
315 };
316 manifest_count = manifest_count.saturating_add(1);
317
318 if repo_set.insert(repo.clone()) {
319 repos.push(repo.clone());
320 }
321 let metadata = match self::cargo_metadata(manifest) {
322 Ok(metadata) => metadata,
323 Err(error) => {
324 failures.push(Failure::repo(
325 repo,
326 format!("could not resolve Cargo metadata for {}: {error}", manifest.display()),
327 ));
328 continue;
329 }
330 };
331
332 let root = match std::fs::canonicalize(&metadata.workspace_root) {
333 Ok(root) => root,
334 Err(error) => {
335 failures.push(Failure::repo(
336 repo,
337 format!(
338 "could not canonicalize Cargo workspace root {}: {error}",
339 metadata.workspace_root.display()
340 ),
341 ));
342 continue;
343 }
344 };
345
346 if workspace_set.contains(&root) {
347 continue;
348 }
349 let workspace_manifest = root.join("Cargo.toml");
350 let metadata = match self::cargo_metadata(&workspace_manifest) {
351 Ok(metadata) => metadata,
352 Err(error) => {
353 failures.push(Failure::repo(
354 repo,
355 format!(
356 "could not resolve Cargo metadata for workspace {}: {error}",
357 workspace_manifest.display()
358 ),
359 ));
360 continue;
361 }
362 };
363
364 if workspace_set.insert(root.clone()) {
365 workspaces.push(Workspace {
366 repo,
367 root,
368 target: metadata.target_directory,
369 });
370 }
371 }
372
373 RepoDiscovery {
374 manifest_count,
375 repos,
376 workspaces,
377 skipped_manifests,
378 failures,
379 }
380}
381
382fn repo_root(directory: &Path) -> Option<PathBuf> {
383 let repo = GitRepo::discover(directory).ok()?;
384 std::fs::canonicalize(repo.workdir()?).ok()
385}
386
387fn cargo_metadata(manifest: &Path) -> rootcause::Result<CargoMetadata> {
388 let directory = manifest
389 .parent()
390 .ok_or_else(|| report!("Cargo manifest has no parent").attach(format!("manifest={}", manifest.display())))?;
391 let output = Command::new("cargo")
392 .args([
393 "metadata",
394 "--no-deps",
395 "--offline",
396 "--locked",
397 "--format-version",
398 "1",
399 "--manifest-path",
400 ])
401 .arg(manifest)
402 .current_dir(directory)
403 .output()
404 .attach_with(|| format!("failed to run cargo metadata for {}", manifest.display()))?;
405
406 if !output.status.success() {
407 return Err(report!("cargo metadata failed").attach(format!("manifest={}", manifest.display())));
408 }
409
410 Ok(serde_json::from_slice(&output.stdout)
411 .attach_with(|| format!("invalid cargo metadata for {}", manifest.display()))?)
412}
413
414fn clean_workspaces(workspaces: &[Workspace], jobs: usize) -> Vec<Result<Workspace, Failure>> {
415 let mut pending = Vec::new();
416 let mut cleanups = Vec::new();
417
418 for workspace in workspaces {
419 pending.push((workspace.clone(), self::spawn_cargo_clean(workspace)));
420 if pending.len() >= jobs {
421 cleanups.push(self::collect_cargo_cleanup(pending.remove(0)));
422 }
423 }
424
425 for cleanup in pending {
426 cleanups.push(self::collect_cargo_cleanup(cleanup));
427 }
428 cleanups
429}
430
431fn spawn_cargo_clean(workspace: &Workspace) -> thread::JoinHandle<std::io::Result<bool>> {
432 let root = workspace.root.clone();
433 thread::spawn(move || {
434 Command::new("cargo")
435 .arg("clean")
436 .current_dir(&root)
437 .status()
438 .map(|status| status.success())
439 })
440}
441
442fn collect_cargo_cleanup(
443 (workspace, cleanup): (Workspace, thread::JoinHandle<std::io::Result<bool>>),
444) -> Result<Workspace, Failure> {
445 match cleanup.join() {
446 Ok(Ok(true)) => Ok(workspace),
447 Ok(Ok(false)) => Err(Failure::repo(
448 workspace.repo.clone(),
449 format!("cargo clean failed: {}", workspace.root.display()),
450 )),
451 Ok(Err(error)) => Err(Failure::repo(
452 workspace.repo.clone(),
453 format!("could not run cargo clean for {}: {error}", workspace.root.display()),
454 )),
455 Err(_) => Err(Failure::repo(
456 workspace.repo.clone(),
457 format!("cargo clean worker panicked: {}", workspace.root.display()),
458 )),
459 }
460}
461
462fn configure_workspace_target(workspace: &Workspace) -> rootcause::Result<PathBuf> {
463 std::fs::create_dir_all(&workspace.target).attach_with(|| {
464 format!(
465 "could not create workspace target directory: {}",
466 workspace.target.display()
467 )
468 })?;
469 let target = std::fs::canonicalize(&workspace.target).attach_with(|| {
470 format!(
471 "could not canonicalize workspace target directory: {}",
472 workspace.target.display()
473 )
474 })?;
475 if target == workspace.root || !target.starts_with(&workspace.root) {
476 return Err(
477 report!("Cargo target directory is outside its workspace").attach(format!(
478 "workspace={} target={}",
479 workspace.root.display(),
480 target.display()
481 )),
482 );
483 }
484
485 let output = Command::new("tmutil")
486 .arg("isexcluded")
487 .arg(&target)
488 .output()
489 .attach_with(|| format!("could not inspect Time Machine exclusion: {}", target.display()))?;
490 if !output.status.success() {
491 return Err(report!("Time Machine exclusion inspection failed").attach(format!("target={}", target.display())));
492 }
493
494 let state = String::from_utf8_lossy(&output.stdout);
495 match (state.contains("[Included]"), state.contains("[Excluded]")) {
496 (true, false) => {
497 let status = Command::new("tmutil")
498 .arg("addexclusion")
499 .arg(&target)
500 .status()
501 .attach_with(|| format!("could not add Time Machine exclusion: {}", target.display()))?;
502 if !status.success() {
503 return Err(
504 report!("could not add Time Machine exclusion").attach(format!("target={}", target.display()))
505 );
506 }
507 }
508 (false, true) => {}
509 _ => {
510 return Err(report!("unrecognized Time Machine exclusion state")
511 .attach(format!("target={} output={state:?}", target.display())));
512 }
513 }
514
515 std::fs::OpenOptions::new()
516 .create(true)
517 .truncate(false)
518 .write(true)
519 .open(target.join(".metadata_never_index"))
520 .attach_with(|| format!("could not create Spotlight sentinel in {}", target.display()))?;
521
522 Ok(target)
523}
524
525fn summarize_repos(processed: usize, failures: &[Failure]) -> rootcause::Result<()> {
526 for failure in failures {
527 match failure {
528 Failure::Repo { repo, message } => {
529 eprintln!("{} {message}: {}", "Error".red().bold(), repo.display());
530 }
531 Failure::Quarantine { repo, failure } => {
532 eprintln!("{} {failure}: {}", "Error".red().bold(), repo.display());
533 }
534 Failure::Traversal { message } => eprintln!("{} {message}", "Error".red().bold()),
535 }
536 }
537 if failures
538 .iter()
539 .any(|failure| matches!(failure, Failure::Traversal { .. }))
540 {
541 eprintln!("{} repo traversal was incomplete", "Error".red().bold());
542 }
543
544 let failed_repositories = failures
545 .iter()
546 .filter_map(|failure| match failure {
547 Failure::Repo { repo, .. } | Failure::Quarantine { repo, .. } => Some(repo),
548 Failure::Traversal { .. } => None,
549 })
550 .collect::<HashSet<_>>()
551 .len();
552
553 if processed == 0 {
554 println!("{}", "No Rust repos found".yellow().bold());
555 } else if failures.is_empty() {
556 println!("{} {processed} Rust repo(s)", "Cleaned".green().bold());
557 } else {
558 eprintln!(
559 "{} {} of {processed} Rust repo(s) failed",
560 "Error".red().bold(),
561 failed_repositories
562 );
563 }
564
565 if failures.is_empty() {
566 return Ok(());
567 }
568 Err(report!("Rust repo maintenance failed"))
569}