Skip to main content

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