Skip to main content

frs/repo/
fix.rs

1//! Implementation of `frs repo fix`.
2
3use 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
22/// Runs the `frs repo fix` command.
23///
24/// # Errors
25/// - Repo maintenance fails.
26pub 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
135/// Configures Cargo target directories and removes quarantine metadata below `directory`.
136///
137/// # Errors
138/// - The directory or a required macOS/Cargo command is unavailable.
139/// - Confirmation input cannot be read, discovery is incomplete, or any repo operation fails.
140fn 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}