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 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 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 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 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 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 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
263pub 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}