gitoxide_core/
organize.rs1use 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 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 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 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
226pub 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}