Skip to main content

frs/cmds/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!("{}", 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
142/// Configures Cargo target directories and removes quarantine metadata below `directory`.
143///
144/// # Errors
145/// - The directory or a required macOS/Cargo command is unavailable.
146/// - Confirmation input cannot be read, discovery is incomplete, or any repo operation fails.
147fn 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}