| @@ -0,0 +1,294 @@ |
| | 1 | use crate::OutputFormat; |
| | 2 | |
| | 3 | #[derive(Default, Copy, Clone)] |
| | 4 | pub enum FindRepository { |
| | 5 | #[default] |
| | 6 | NonBare, |
| | 7 | All, |
| | 8 | } |
| | 9 | |
| | 10 | pub struct Options { |
| | 11 | pub debug: bool, |
| | 12 | pub format: OutputFormat, |
| | 13 | pub execute: bool, |
| | 14 | pub ignored: bool, |
| | 15 | pub precious: bool, |
| | 16 | pub directories: bool, |
| | 17 | pub repositories: bool, |
| | 18 | pub skip_hidden_repositories: Option<FindRepository>, |
| | 19 | pub find_untracked_repositories: FindRepository, |
| | 20 | } |
| | 21 | pub(crate) mod function { |
| | 22 | use crate::repository::clean::{FindRepository, Options}; |
| | 23 | use crate::OutputFormat; |
| | 24 | use anyhow::bail; |
| | 25 | use gix::bstr::BString; |
| | 26 | use gix::bstr::ByteSlice; |
| | 27 | use gix::dir::entry::{Kind, Status}; |
| | 28 | use gix::dir::walk::EmissionMode::CollapseDirectory; |
| | 29 | use gix::dir::walk::ForDeletionMode::*; |
| | 30 | use std::borrow::Cow; |
| | 31 | |
| | 32 | pub fn clean( |
| | 33 | repo: gix::Repository, |
| | 34 | out: &mut dyn std::io::Write, |
| | 35 | err: &mut dyn std::io::Write, |
| | 36 | patterns: Vec<BString>, |
| | 37 | Options { |
| | 38 | debug, |
| | 39 | format, |
| | 40 | execute, |
| | 41 | ignored, |
| | 42 | precious, |
| | 43 | directories, |
| | 44 | repositories, |
| | 45 | skip_hidden_repositories, |
| | 46 | find_untracked_repositories, |
| | 47 | }: Options, |
| | 48 | ) -> anyhow::Result<()> { |
| | 49 | if format != OutputFormat::Human { |
| | 50 | bail!("JSON output isn't implemented yet"); |
| | 51 | } |
| | 52 | let Some(workdir) = repo.work_dir() else { |
| | 53 | bail!("Need a worktree to clean, this is a bare repository"); |
| | 54 | }; |
| | 55 | |
| | 56 | let index = repo.index()?; |
| | 57 | let has_patterns = !patterns.is_empty(); |
| | 58 | let mut collect = gix::dir::walk::delegate::Collect::default(); |
| | 59 | let collapse_directories = CollapseDirectory; |
| | 60 | let options = repo |
| | 61 | .dirwalk_options()? |
| | 62 | .emit_pruned(true) |
| | 63 | .for_deletion(if (ignored || precious) && directories { |
| | 64 | match skip_hidden_repositories { |
| | 65 | Some(FindRepository::NonBare) => Some(FindNonBareRepositoriesInIgnoredDirectories), |
| | 66 | Some(FindRepository::All) => Some(FindRepositoriesInIgnoredDirectories), |
| | 67 | None => None, |
| | 68 | } |
| | 69 | } else { |
| | 70 | Some(IgnoredDirectoriesCanHideNestedRepositories) |
| | 71 | }) |
| | 72 | .classify_untracked_bare_repositories(matches!(find_untracked_repositories, FindRepository::All)) |
| | 73 | .emit_untracked(collapse_directories) |
| | 74 | .emit_ignored(Some(collapse_directories)) |
| | 75 | .emit_empty_directories(true); |
| | 76 | repo.dirwalk(&index, patterns, options, &mut collect)?; |
| | 77 | let prefix = repo.prefix()?.expect("worktree and valid current dir"); |
| | 78 | let prefix_len = if prefix.as_os_str().is_empty() { |
| | 79 | 0 |
| | 80 | } else { |
| | 81 | prefix.to_str().map_or(0, |s| s.len() + 1 /* slash */) |
| | 82 | }; |
| | 83 | |
| | 84 | let entries = collect.into_entries_by_path(); |
| | 85 | let mut entries_to_clean = 0; |
| | 86 | let mut skipped_directories = 0; |
| | 87 | let mut skipped_ignored = 0; |
| | 88 | let mut skipped_precious = 0; |
| | 89 | let mut skipped_repositories = 0; |
| | 90 | let mut pruned_entries = 0; |
| | 91 | let mut saw_ignored_directory = false; |
| | 92 | let mut saw_untracked_directory = false; |
| | 93 | for (entry, dir_status) in entries.into_iter() { |
| | 94 | if dir_status.is_some() { |
| | 95 | if debug { |
| | 96 | writeln!( |
| | 97 | err, |
| | 98 | "DBG: prune '{}' {:?} as parent dir is used instead", |
| | 99 | entry.rela_path, entry.status |
| | 100 | ) |
| | 101 | .ok(); |
| | 102 | } |
| | 103 | continue; |
| | 104 | } |
| | 105 | |
| | 106 | pruned_entries += usize::from(entry.pathspec_match.is_none()); |
| | 107 | if entry.status.is_pruned() || entry.pathspec_match.is_none() { |
| | 108 | continue; |
| | 109 | } |
| | 110 | let mut disk_kind = entry.disk_kind.expect("present if not pruned"); |
| | 111 | match disk_kind { |
| | 112 | Kind::File | Kind::Symlink => {} |
| | 113 | Kind::EmptyDirectory | Kind::Directory | Kind::Repository => { |
| | 114 | let keep = directories |
| | 115 | || entry |
| | 116 | .pathspec_match |
| | 117 | .map_or(false, |m| m != gix::dir::entry::PathspecMatch::Always); |
| | 118 | if !keep { |
| | 119 | skipped_directories += 1; |
| | 120 | if debug { |
| | 121 | writeln!(err, "DBG: prune '{}' as -d is missing", entry.rela_path).ok(); |
| | 122 | } |
| | 123 | continue; |
| | 124 | } |
| | 125 | } |
| | 126 | }; |
| | 127 | |
| | 128 | let keep = entry |
| | 129 | .pathspec_match |
| | 130 | .map_or(true, |m| m != gix::dir::entry::PathspecMatch::Excluded); |
| | 131 | if !keep { |
| | 132 | if debug { |
| | 133 | writeln!(err, "DBG: prune '{}' as it is excluded by pathspec", entry.rela_path).ok(); |
| | 134 | } |
| | 135 | continue; |
| | 136 | } |
| | 137 | |
| | 138 | let keep = match entry.status { |
| | 139 | Status::DotGit | Status::Pruned | Status::TrackedExcluded => { |
| | 140 | unreachable!("Pruned aren't emitted") |
| | 141 | } |
| | 142 | Status::Tracked => { |
| | 143 | unreachable!("tracked aren't emitted") |
| | 144 | } |
| | 145 | Status::Ignored(gix::ignore::Kind::Expendable) => { |
| | 146 | skipped_ignored += usize::from(!ignored); |
| | 147 | ignored |
| | 148 | } |
| | 149 | Status::Ignored(gix::ignore::Kind::Precious) => { |
| | 150 | skipped_precious += usize::from(!precious); |
| | 151 | precious |
| | 152 | } |
| | 153 | Status::Untracked => true, |
| | 154 | }; |
| | 155 | if !keep { |
| | 156 | if debug { |
| | 157 | writeln!(err, "DBG: prune '{}' as -x or -p is missing", entry.rela_path).ok(); |
| | 158 | } |
| | 159 | continue; |
| | 160 | } |
| | 161 | |
| | 162 | if disk_kind == gix::dir::entry::Kind::Directory |
| | 163 | && gix::discover::is_git(&workdir.join(gix::path::from_bstr(entry.rela_path.as_bstr()))).is_ok() |
| | 164 | { |
| | 165 | if debug { |
| | 166 | writeln!(err, "DBG: upgraded directory '{}' to repository", entry.rela_path).ok(); |
| | 167 | } |
| | 168 | disk_kind = gix::dir::entry::Kind::Repository; |
| | 169 | } |
| | 170 | |
| | 171 | let is_ignored = matches!(entry.status, gix::dir::entry::Status::Ignored(_)); |
| | 172 | let display_path = entry.rela_path[prefix_len..].as_bstr(); |
| | 173 | if (!repositories || is_ignored) && disk_kind == gix::dir::entry::Kind::Repository { |
| | 174 | if !is_ignored { |
| | 175 | skipped_repositories += 1; |
| | 176 | } |
| | 177 | if debug { |
| | 178 | writeln!(err, "DBG: skipped repository at '{display_path}'")?; |
| | 179 | } |
| | 180 | continue; |
| | 181 | } |
| | 182 | |
| | 183 | if disk_kind == gix::dir::entry::Kind::Directory { |
| | 184 | saw_ignored_directory |= is_ignored; |
| | 185 | saw_untracked_directory |= entry.status == gix::dir::entry::Status::Untracked; |
| | 186 | } |
| | 187 | writeln!( |
| | 188 | out, |
| | 189 | "{maybe}{suffix} {}{} {status}", |
| | 190 | display_path, |
| | 191 | disk_kind.is_dir().then_some("/").unwrap_or_default(), |
| | 192 | status = match entry.status { |
| | 193 | Status::Ignored(kind) => { |
| | 194 | Cow::Owned(format!( |
| | 195 | "({})", |
| | 196 | match kind { |
| | 197 | gix::ignore::Kind::Precious => "$", |
| | 198 | gix::ignore::Kind::Expendable => "❌", |
| | 199 | } |
| | 200 | )) |
| | 201 | } |
| | 202 | Status::Untracked => { |
| | 203 | "".into() |
| | 204 | } |
| | 205 | status => |
| | 206 | if debug { |
| | 207 | format!("(DBG: {status:?})").into() |
| | 208 | } else { |
| | 209 | "".into() |
| | 210 | }, |
| | 211 | }, |
| | 212 | maybe = if execute { "removing" } else { "WOULD remove" }, |
| | 213 | suffix = if disk_kind == gix::dir::entry::Kind::Repository { |
| | 214 | " repository" |
| | 215 | } else { |
| | 216 | "" |
| | 217 | }, |
| | 218 | )?; |
| | 219 | |
| | 220 | if execute { |
| | 221 | let path = workdir.join(gix::path::from_bstr(entry.rela_path)); |
| | 222 | if disk_kind.is_dir() { |
| | 223 | std::fs::remove_dir_all(path)?; |
| | 224 | } else { |
| | 225 | std::fs::remove_file(path)?; |
| | 226 | } |
| | 227 | } else { |
| | 228 | entries_to_clean += 1; |
| | 229 | } |
| | 230 | } |
| | 231 | if !execute { |
| | 232 | let mut messages = Vec::new(); |
| | 233 | messages.extend( |
| | 234 | (skipped_directories > 0).then(|| format!("Skipped {skipped_directories} directories - show with -d")), |
| | 235 | ); |
| | 236 | messages.extend( |
| | 237 | (skipped_repositories > 0) |
| | 238 | .then(|| format!("Skipped {skipped_repositories} repositories - show with -r")), |
| | 239 | ); |
| | 240 | messages.extend( |
| | 241 | (skipped_ignored > 0).then(|| format!("Skipped {skipped_ignored} expendable entries - show with -x")), |
| | 242 | ); |
| | 243 | messages.extend( |
| | 244 | (skipped_precious > 0).then(|| format!("Skipped {skipped_precious} precious entries - show with -p")), |
| | 245 | ); |
| | 246 | messages.extend( |
| | 247 | (pruned_entries > 0 && has_patterns).then(|| { |
| | 248 | format!("try to adjust your pathspec to reveal some of the {pruned_entries} pruned entries") |
| | 249 | }), |
| | 250 | ); |
| | 251 | let make_msg = || -> String { |
| | 252 | if messages.is_empty() { |
| | 253 | return String::new(); |
| | 254 | } |
| | 255 | messages.join("; ") |
| | 256 | }; |
| | 257 | let wrap_in_parens = |msg: String| if msg.is_empty() { msg } else { format!(" ({msg})") }; |
| | 258 | if entries_to_clean > 0 { |
| | 259 | let mut wrote_nl = false; |
| | 260 | let msg = make_msg(); |
| | 261 | let mut msg = if msg.is_empty() { None } else { Some(msg) }; |
| | 262 | if saw_ignored_directory && skip_hidden_repositories.is_none() { |
| | 263 | writeln!(err).ok(); |
| | 264 | wrote_nl = true; |
| | 265 | writeln!( |
| | 266 | err, |
| | 267 | "WARNING: would remove repositories hidden inside ignored directories - use --skip-hidden-repositories to skip{}", |
| | 268 | wrap_in_parens(msg.take().unwrap_or_default()) |
| | 269 | )?; |
| | 270 | } |
| | 271 | if saw_untracked_directory && matches!(find_untracked_repositories, FindRepository::NonBare) { |
| | 272 | if !wrote_nl { |
| | 273 | writeln!(err).ok(); |
| | 274 | wrote_nl = true; |
| | 275 | } |
| | 276 | writeln!( |
| | 277 | err, |
| | 278 | "WARNING: would remove repositories hidden inside untracked directories - use --find-untracked-repositories to find{}", |
| | 279 | wrap_in_parens(msg.take().unwrap_or_default()) |
| | 280 | )?; |
| | 281 | } |
| | 282 | if let Some(msg) = msg.take() { |
| | 283 | if !wrote_nl { |
| | 284 | writeln!(err).ok(); |
| | 285 | } |
| | 286 | writeln!(err, "{msg}").ok(); |
| | 287 | } |
| | 288 | } else { |
| | 289 | writeln!(err, "Nothing to clean{}", wrap_in_parens(make_msg()))?; |
| | 290 | } |
| | 291 | } |
| | 292 | Ok(()) |
| | 293 | } |
| | 294 | } |