gitoxide_core/
organize.rs

1use std::{
2    borrow::Cow,
3    ffi::OsStr,
4    path::{Path, PathBuf},
5};
6
7use gix::{objs::bstr::ByteSlice, progress, NestedProgress, Progress};
8
9#[derive(Default, Copy, Clone, Eq, PartialEq)]
10pub enum Mode {
11    Execute,
12    #[default]
13    Simulate,
14}
15
16pub fn find_git_repository_workdirs(
17    root: impl AsRef<Path>,
18    mut progress: impl Progress,
19    debug: bool,
20    threads: Option<usize>,
21) -> impl Iterator<Item = (PathBuf, gix::repository::Kind)> {
22    progress.init(None, progress::count("filesystem items"));
23    fn is_repository(path: &Path) -> Option<gix::repository::Kind> {
24        // Can be git dir or worktree checkout (file)
25        if path.file_name() != Some(OsStr::new(".git")) && path.extension() != Some(OsStr::new("git")) {
26            return None;
27        }
28
29        if path.is_dir() {
30            if path.join("HEAD").is_file() && path.join("config").is_file() {
31                gix::discover::is_git(path).ok().map(Into::into)
32            } else {
33                None
34            }
35        } else {
36            // git files are always worktrees
37            Some(gix::repository::Kind::WorkTree { is_linked: true })
38        }
39    }
40    fn into_workdir(git_dir: PathBuf, kind: &gix::repository::Kind) -> PathBuf {
41        if matches!(kind, gix::repository::Kind::Bare) || gix::discover::is_bare(&git_dir) {
42            git_dir
43        } else {
44            git_dir.parent().expect("git is never in the root").to_owned()
45        }
46    }
47
48    #[derive(Debug, Default)]
49    struct State {
50        kind: Option<gix::repository::Kind>,
51    }
52
53    let walk = jwalk::WalkDirGeneric::<((), State)>::new(root)
54        .follow_links(false)
55        .sort(true)
56        .skip_hidden(false)
57        .parallelism(jwalk::Parallelism::RayonNewPool(threads.unwrap_or(0)));
58
59    walk.process_read_dir(move |_depth, path, _read_dir_state, siblings| {
60        if debug {
61            eprintln!("{}", path.display());
62        }
63        let mut found_any_repo = false;
64        let mut found_bare_repo = false;
65        for entry in siblings.iter_mut().flatten() {
66            let path = entry.path();
67            if let Some(kind) = is_repository(&path) {
68                let is_bare = kind.is_bare();
69                entry.client_state = State { kind: kind.into() };
70                entry.read_children_path = None;
71
72                found_any_repo = true;
73                found_bare_repo = is_bare;
74            }
75        }
76        // Only return paths which are repositories are further participating in the traversal
77        // Don't let bare repositories cause siblings to be pruned.
78        if found_any_repo && !found_bare_repo {
79            siblings.retain(|e| e.as_ref().map(|e| e.client_state.kind.is_some()).unwrap_or(false));
80        }
81    })
82    .into_iter()
83    .inspect(move |_| progress.inc())
84    .filter_map(Result::ok)
85    .filter_map(|mut e| {
86        e.client_state
87            .kind
88            .take()
89            .map(|kind| (into_workdir(e.path(), &kind), kind))
90    })
91}
92
93fn find_origin_remote(repo: &Path) -> anyhow::Result<Option<gix_url::Url>> {
94    let non_bare = repo.join(".git").join("config");
95    let local = gix::config::Source::Local;
96    let config = gix::config::File::from_path_no_includes(non_bare.as_path().into(), local)
97        .or_else(|_| gix::config::File::from_path_no_includes(repo.join("config"), local))?;
98    Ok(config
99        .string("remote.origin.url")
100        .map(|url| gix_url::Url::from_bytes(url.as_ref()))
101        .transpose()?)
102}
103
104fn handle(
105    mode: Mode,
106    kind: gix::repository::Kind,
107    git_workdir: &Path,
108    canonicalized_destination: &Path,
109    progress: &mut impl Progress,
110) -> anyhow::Result<()> {
111    if let gix::repository::Kind::WorkTree { is_linked: true } = kind {
112        return Ok(());
113    }
114    fn to_relative(path: PathBuf) -> PathBuf {
115        path.components()
116            .skip_while(|c| c == &std::path::Component::RootDir)
117            .collect()
118    }
119
120    fn find_parent_repo(mut git_workdir: &Path) -> Option<PathBuf> {
121        while let Some(parent) = git_workdir.parent() {
122            let has_contained_git_folder_or_file = std::fs::read_dir(parent).ok()?.any(|e| {
123                e.ok()
124                    .and_then(|e| {
125                        e.file_name()
126                            .to_str()
127                            .map(|name| name == ".git" && e.path() != git_workdir)
128                    })
129                    .unwrap_or(false)
130            });
131            if has_contained_git_folder_or_file {
132                return Some(parent.to_owned());
133            }
134            git_workdir = parent;
135        }
136        None
137    }
138
139    if let Some(parent_repo_path) = find_parent_repo(git_workdir) {
140        progress.fail(format!(
141            "Skipping repository at '{}' as it is nested within repository '{}'",
142            git_workdir.display(),
143            parent_repo_path.display()
144        ));
145        return Ok(());
146    }
147
148    let url = match find_origin_remote(git_workdir)? {
149        None => {
150            progress.info(format!(
151                "Skipping repository {:?} without 'origin' remote",
152                git_workdir.display()
153            ));
154            return Ok(());
155        }
156        Some(url) => url,
157    };
158    if url.path.is_empty() {
159        progress.info(format!(
160            "Skipping repository at '{}' whose remote does not have a path: {}",
161            git_workdir.display(),
162            url.to_bstring()
163        ));
164        return Ok(());
165    }
166
167    let destination = canonicalized_destination
168        .join(match url.host() {
169            Some(h) => h,
170            None => return Ok(()),
171        })
172        .join(to_relative({
173            let mut path = gix_url::expand_path(None, url.path.as_bstr())?;
174            match kind {
175                gix::repository::Kind::Submodule => {
176                    unreachable!("BUG: We should not try to relocated submodules and not find them the first place")
177                }
178                gix::repository::Kind::Bare => path,
179                gix::repository::Kind::WorkTree { .. } => {
180                    if let Some(ext) = path.extension() {
181                        if ext == "git" {
182                            path.set_extension("");
183                        }
184                    }
185                    path
186                }
187            }
188        }));
189
190    if let Ok(destination) = destination.canonicalize() {
191        if git_workdir.canonicalize()? == destination {
192            return Ok(());
193        }
194    }
195    match mode {
196        Mode::Simulate => progress.info(format!(
197            "WOULD move {} to {}",
198            git_workdir.display(),
199            destination.display()
200        )),
201        Mode::Execute => {
202            if destination.starts_with(
203                git_workdir
204                    .canonicalize()
205                    .ok()
206                    .map(Cow::Owned)
207                    .unwrap_or(Cow::Borrowed(git_workdir)),
208            ) {
209                let tempdir = tempfile::tempdir_in(canonicalized_destination)?;
210                let tempdest = tempdir
211                    .path()
212                    .join(destination.file_name().expect("repo destination is not the root"));
213                std::fs::rename(git_workdir, &tempdest)?;
214                std::fs::create_dir_all(destination.parent().expect("repo destination is not the root"))?;
215                std::fs::rename(&tempdest, &destination)?;
216            } else {
217                std::fs::create_dir_all(destination.parent().expect("repo destination is not the root"))?;
218                std::fs::rename(git_workdir, &destination)?;
219            }
220            progress.done(format!("Moving {} to {}", git_workdir.display(), destination.display()));
221        }
222    }
223    Ok(())
224}
225
226/// Find all working directories in the given `source_dir` and print them to `out` while providing `progress`.
227pub fn discover<P: NestedProgress>(
228    source_dir: impl AsRef<Path>,
229    mut out: impl std::io::Write,
230    mut progress: P,
231    debug: bool,
232    threads: Option<usize>,
233) -> anyhow::Result<()> {
234    for (git_workdir, _kind) in
235        find_git_repository_workdirs(source_dir, progress.add_child("Searching repositories"), debug, threads)
236    {
237        writeln!(&mut out, "{}", git_workdir.display())?;
238    }
239    Ok(())
240}
241
242pub fn run<P: NestedProgress>(
243    mode: Mode,
244    source_dir: impl AsRef<Path>,
245    destination: impl AsRef<Path>,
246    mut progress: P,
247    threads: Option<usize>,
248) -> anyhow::Result<()> {
249    let mut num_errors = 0usize;
250    let destination = destination.as_ref().canonicalize()?;
251    for (path_to_move, kind) in
252        find_git_repository_workdirs(source_dir, progress.add_child("Searching repositories"), false, threads)
253    {
254        if let Err(err) = handle(mode, kind, &path_to_move, &destination, &mut progress) {
255            progress.fail(format!(
256                "Error when handling directory {:?}: {}",
257                path_to_move.display(),
258                err
259            ));
260            num_errors += 1;
261        }
262    }
263
264    if num_errors > 0 {
265        anyhow::bail!("Failed to handle {} repositories", num_errors)
266    } else {
267        Ok(())
268    }
269}