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