use std::{ borrow::Cow, collections::HashMap, ffi::OsStr, path::{Path, PathBuf}, sync::Arc, }; use gix::{NestedProgress, Progress, objs::bstr::ByteSlice, progress}; use parking_lot::Mutex; fn walk_threads(requested: Option) -> usize { requested.filter(|threads| *threads != 0).unwrap_or_else(|| { if cfg!(target_os = "macos") { 4 } else { std::thread::available_parallelism().map_or(1, usize::from) } }) } #[derive(Default, Copy, Clone, Eq, PartialEq)] pub enum Mode { Execute, #[default] Simulate, } pub fn find_git_repository_workdirs( root: impl AsRef, mut progress: impl Progress, debug: bool, threads: Option, ) -> impl Iterator { progress.init(None, progress::count("filesystem items")); #[derive(Debug, Clone, Copy)] struct RepoInfo { kind: gix::repository::Kind, is_bare: bool, } fn is_repository(path: &Path, is_dir: bool, is_file: bool) -> Option { // Can be git dir or worktree checkout (file) if path.file_name() != Some(OsStr::new(".git")) && path.extension() != Some(OsStr::new("git")) { return None; } if is_dir { if path.join("HEAD").is_file() && path.join("config").is_file() { gix::discover::is_git(path).ok().map(|discovered_kind| { let is_bare = discovered_kind.is_bare(); let kind = match discovered_kind { gix::discover::repository::Kind::PossiblyBare => gix::repository::Kind::Common, gix::discover::repository::Kind::WorkTree { linked_git_dir: None } => { gix::repository::Kind::Common } gix::discover::repository::Kind::WorkTree { linked_git_dir: Some(_), } => gix::repository::Kind::LinkedWorkTree, gix::discover::repository::Kind::WorkTreeGitDir { .. } => gix::repository::Kind::LinkedWorkTree, gix::discover::repository::Kind::Submodule { .. } => gix::repository::Kind::Submodule, gix::discover::repository::Kind::SubmoduleGitDir => gix::repository::Kind::Submodule, }; RepoInfo { kind, is_bare } }) } else { None } } else if is_file { // git files are always linked worktrees Some(RepoInfo { kind: gix::repository::Kind::LinkedWorkTree, is_bare: false, }) } else { None } } fn into_workdir(git_dir: PathBuf, info: &RepoInfo) -> PathBuf { if info.is_bare { git_dir } else { git_dir.parent().expect("git is never in the root").to_owned() } } fn repository_at(entry: &dua_core::Entry) -> Option<(PathBuf, RepoInfo)> { let path = entry.path(); if let Some(info) = is_repository(&path, entry.file_type.is_dir(), entry.file_type.is_file()) { return Some((path, info)); } let git_dir = path.join(".git"); let metadata = git_dir.metadata().ok()?; is_repository(&git_dir, metadata.is_dir(), metadata.is_file()).map(|info| (git_dir, info)) } let repositories = Arc::new(Mutex::new(HashMap::new())); let repositories_in_workers = Arc::clone(&repositories); dua_core::walk( root.as_ref(), walk_threads(threads), dua_core::Order::ParentFirst, move |entry| { let Some(repository) = repository_at(entry) else { return true; }; let path = entry.path(); repositories_in_workers.lock().insert(path, repository); false }, ) .inspect(move |entry| { if debug && let Ok(entry) = entry { eprintln!("{}", entry.path().display()); } }) .inspect(move |_| progress.inc()) .filter_map(Result::ok) .filter_map(move |entry| { let path = entry.path(); let repository = if entry.file_type.is_dir() { repositories.lock().remove(&path) } else { is_repository(&path, false, entry.file_type.is_file()).map(|info| (path, info)) }; repository.map(|(git_dir, info)| (into_workdir(git_dir, &info), info.kind)) }) } fn find_origin_remote(repo: &Path) -> anyhow::Result> { let non_bare = repo.join(".git").join("config"); let local = gix::config::Source::Local; let config = gix::config::File::from_path_no_includes(non_bare.as_path().into(), local) .or_else(|_| gix::config::File::from_path_no_includes(repo.join("config"), local))?; Ok(config .string("remote.origin.url") .map(|url| gix_url::Url::from_bytes(url.as_ref())) .transpose()?) } fn handle( mode: Mode, kind: gix::repository::Kind, git_workdir: &Path, canonicalized_destination: &Path, progress: &mut impl Progress, ) -> anyhow::Result<()> { // Skip linked worktrees - we only handle Common and Submodule kinds if matches!(kind, gix::repository::Kind::LinkedWorkTree) { return Ok(()); } fn to_relative(path: PathBuf) -> PathBuf { path.components() .skip_while(|c| c == &std::path::Component::RootDir) .collect() } fn find_parent_repo(mut git_workdir: &Path) -> Option { while let Some(parent) = git_workdir.parent() { let has_contained_git_folder_or_file = std::fs::read_dir(parent).ok()?.any(|e| { e.ok() .and_then(|e| { e.file_name() .to_str() .map(|name| name == ".git" && e.path() != git_workdir) }) .unwrap_or(false) }); if has_contained_git_folder_or_file { return Some(parent.to_owned()); } git_workdir = parent; } None } if let Some(parent_repo_path) = find_parent_repo(git_workdir) { progress.fail(format!( "Skipping repository at '{}' as it is nested within repository '{}'", git_workdir.display(), parent_repo_path.display() )); return Ok(()); } let url = match find_origin_remote(git_workdir)? { None => { progress.info(format!( "Skipping repository {:?} without 'origin' remote", git_workdir.display() )); return Ok(()); } Some(url) => url, }; if url.path.is_empty() { progress.info(format!( "Skipping repository at '{}' whose remote does not have a path: {}", git_workdir.display(), url.to_bstring() )); return Ok(()); } let destination = canonicalized_destination .join(match url.host() { Some(h) => h, None => return Ok(()), }) .join(to_relative({ let mut path = gix_url::expand_path(None, url.path.as_bstr())?; match kind { gix::repository::Kind::Submodule => { unreachable!("BUG: We should not try to relocate submodules and not find them the first place") } gix::repository::Kind::LinkedWorkTree => { unreachable!("BUG: LinkedWorkTree should have been skipped earlier") } gix::repository::Kind::Common => { // For Common kind, check if it's bare let git_dir = if git_workdir.join(".git").is_dir() { git_workdir.join(".git") } else { git_workdir.to_owned() }; if !gix::discover::is_bare(&git_dir) { // Non-bare repository - strip .git extension if present if path.extension().is_some_and(|ext| ext == "git") { path.set_extension(""); } } path } } })); match destination.canonicalize() { Ok(destination) if git_workdir.canonicalize()? == destination => return Ok(()), _ => {} } match mode { Mode::Simulate => progress.info(format!( "WOULD move {} to {}", git_workdir.display(), destination.display() )), Mode::Execute => { if destination.starts_with( git_workdir .canonicalize() .ok() .map(Cow::Owned) .unwrap_or(Cow::Borrowed(git_workdir)), ) { let tempdir = tempfile::tempdir_in(canonicalized_destination)?; let tempdest = tempdir .path() .join(destination.file_name().expect("repo destination is not the root")); std::fs::rename(git_workdir, &tempdest)?; std::fs::create_dir_all(destination.parent().expect("repo destination is not the root"))?; std::fs::rename(&tempdest, &destination)?; } else { std::fs::create_dir_all(destination.parent().expect("repo destination is not the root"))?; std::fs::rename(git_workdir, &destination)?; } progress.done(format!("Moving {} to {}", git_workdir.display(), destination.display())); } } Ok(()) } /// Find all working directories in the given `source_dir` and print them to `out` while providing `progress`. pub fn discover( source_dir: impl AsRef, mut out: impl std::io::Write, mut progress: P, debug: bool, threads: Option, ) -> anyhow::Result<()> { let mut repositories = find_git_repository_workdirs(source_dir, progress.add_child("Searching repositories"), debug, threads) .collect::>(); repositories.sort_unstable_by(|a, b| a.0.cmp(&b.0)); for (git_workdir, _kind) in repositories { writeln!(&mut out, "{}", git_workdir.display())?; } Ok(()) } pub fn run( mode: Mode, source_dir: impl AsRef, destination: impl AsRef, mut progress: P, threads: Option, ) -> anyhow::Result<()> { let mut num_errors = 0usize; let destination = destination.as_ref().canonicalize()?; let mut repositories = find_git_repository_workdirs(source_dir, progress.add_child("Searching repositories"), false, threads) .collect::>(); repositories.sort_unstable_by(|a, b| a.0.cmp(&b.0)); for (path_to_move, kind) in repositories { if let Err(err) = handle(mode, kind, &path_to_move, &destination, &mut progress) { progress.fail(format!( "Error when handling directory {:?}: {}", path_to_move.display(), err )); num_errors += 1; } } if num_errors > 0 { anyhow::bail!("Failed to handle {num_errors} repositories") } else { Ok(()) } }